DocsGuidesCounters & locks

Counters & locks

Counters and locks: TopGun ships a PN-Counter (increment/decrement with automatic conflict resolution) and named distributed locks (single-node, fencing-token-based — cluster-safe locks are on the Raft roadmap). Both are available from the same TopGunClient instance and work offline.


Section 1 — PN-Counter

A PN-Counter (Positive-Negative Counter) is a CRDT that supports both increment and decrement operations. It works correctly when multiple clients update it concurrently or offline: writes queue locally, sync on reconnect, and converge automatically.

Basic usage

src/lib/counters.ts
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';

const client = new TopGunClient({
  serverUrl: 'ws://localhost:8080',
  storage: new IDBAdapter(),
});

await client.start();

// Get a counter instance — namespaced by entity type and ID
const likes = client.getPNCounter('likes:post-123');

// Increment (+1)
likes.increment();

// Decrement (-1)
likes.decrement();

// Add arbitrary amount (positive or negative)
likes.addAndGet(10);   // +10
likes.addAndGet(-5);   // -5

// Get current value (reads from local state — no network round-trip)
console.log('Likes:', likes.get());

Subscribing to changes

src/lib/counters.ts
const likes = client.getPNCounter('likes:post-123');

// Callback fires on local operations AND on remote updates
const unsubscribe = likes.subscribe((value) => {
  console.log('Current likes:', value);
  updateUI(value);
});

// Stop listening when done
unsubscribe();

React hook

components/LikeButton.tsx
import { usePNCounter } from '@topgunbuild/react';

function LikeButton({ postId }: { postId: string }) {
  const { value, increment, decrement, loading } = usePNCounter(`likes:${postId}`);

  return (
    <div>
      <button onClick={decrement} disabled={loading}>-</button>
      <span>{value}</span>
      <button onClick={increment} disabled={loading}>+</button>
    </div>
  );
}

PNCounterHandle API

MethodReturnsDescription
get()numberCurrent counter value (local read)
increment()numberIncrement by 1, returns new value
decrement()numberDecrement by 1, returns new value
addAndGet(delta)numberAdd delta, returns new value
subscribe(fn)() => voidSubscribe to changes, returns unsubscribe

Use cases

// Like / upvote counter
const likes = client.getPNCounter('likes:post-123');
likes.increment(); // user liked
likes.decrement(); // user unliked

// Inventory tracking across locations
const stock = client.getPNCounter('inventory:sku-abc');
stock.addAndGet(100);  // warehouse receives shipment
stock.addAndGet(-5);   // warehouse ships items

// Game score
const score = client.getPNCounter('score:player-abc');
score.addAndGet(100);  // points earned
score.addAndGet(-20);  // penalty applied

PN-Counter operations work offline — changes persist to IndexedDB and sync when the connection is restored.


Section 2 — Distributed locks (single-node)

Deployment Mode Semantics

Single-node: stable (fencing tokens, TTL-based expiry, disconnect-release). Cluster — current: partition-routing (safe when a single node owns the relevant partition; weaker guarantees under split-brain). Cluster — planned: Raft-replicated consensus via openraft, tracked on the roadmap.

DeploymentStatusNotes
Single-nodeStableFencing tokens, TTL-based expiry, disconnect-release semantics in place
Cluster (current)Partition-routingSafe when a single node owns the relevant partition; weaker guarantees under split-brain
Cluster (planned)Raft-replicatedVia openraft — Raft-backed cluster locks are on the roadmap

Distributed locks in cluster mode currently use partition-routing without Raft consensus. They are safe for single-node deployments and for cluster deployments where a single node owns the relevant partition, but provide weaker guarantees under split-brain. Raft-backed cluster locks are in development.

Basic usage

src/workers/job-processor.ts
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';

const client = new TopGunClient({
  serverUrl: 'ws://localhost:8080',
  storage: new IDBAdapter(),
});

await client.start();

async function processJob() {
  const lock = client.getLock('process-queue-1');

  // Acquire lock with 5 s TTL (auto-released if client crashes)
  const acquired = await lock.lock(5000);

  if (acquired) {
    try {
      console.log('Lock acquired, processing job...');
      await doHeavyWork();
    } finally {
      // Always release in a finally block
      await lock.unlock();
      console.log('Lock released');
    }
  } else {
    console.log('Could not acquire lock — another worker holds it.');
  }
}

DistributedLock API

MethodReturnsDescription
lock(ttlMs?)Promise<boolean>Attempt to acquire. Returns true if granted, false if held by another client. ttlMs is the auto-release timeout if the client crashes.
unlock()Promise<void>Release the lock. No-op if not held.
isLocked()booleanReturns true if this handle currently holds the lock.

Fencing tokens

When a lock is granted, the server returns a monotonically increasing fencing token. Successive acquire → release → acquire sequences always yield a strictly increasing token (invariant I2 in coordination_lock.rs).

Why fencing tokens matter

If a client pauses (e.g., a long GC pause) and its lock TTL expires, another client may acquire the lock. When the first client wakes up, it still thinks it holds the lock. Fencing tokens allow downstream storage systems to reject writes from the old client (its token is lower than the current holder's) — preventing silent data corruption. The high-level lock() / unlock() API handles this internally for TopGun operations.

Lock naming

Use descriptive, namespaced names:

// Good: entity type + ID makes collisions unlikely
client.getLock('job-queue:partition-0');
client.getLock('resource:payment-processor');
client.getLock('cron:daily-report');

// Avoid: ambiguous names that risk accidental sharing
client.getLock('lock1');

Next steps