Typed Node.js / TypeScript gRPC client for
pathlockd — fast, scalable,
opinionated path-based distributed locking primitives backed by an embedded
Multi-Raft + RocksDB engine.
It wraps the daemon's gRPC API in a small, fully-typed, promise-based surface:
string-union enums ('write' | 'read'), camelCase fields, a typed event emitter
for the per-owner lifecycle stream, and the .proto + generated .d.ts bundled
in the package — no codegen in your project.
This is a thin client: it forwards primitives. Lock orchestration (renewal cadence, contention waiting, deadlock policy) belongs in your code.
npm install github:alexpacio/pathlockd-nodejs-client#v0.6.1The GitHub release includes an installable npm tarball. The repository also ships
prebuilt JS + type declarations in dist/, so
installing from a Git ref needs no build step. Linux and macOS are supported on
x86_64 and arm64.
import { PathlockdClient } from 'pathlockd-nodejs-client';
const client = new PathlockdClient({ endpoint: 'localhost:50051' });
await client.waitForReady(5000);
// 1) acquire a write lock on the subtree "google_drive:/a/b"
const res = await client.acquire({
ownerId: 'owner-1',
ttlMs: 10_000,
requests: [{ path: 'google_drive:/a/b', mode: 'write', state: 'new' }],
});
if (res.status === 'ok') {
// ...do work, renewing periodically so the lease never lapses...
await client.renew('owner-1', 10_000);
} else if (res.status === 'queued') {
console.log(`queued behind ${res.owner} on ${res.path} (${res.reason})`);
}
// 2) before an external side effect, prove you still own the path at its token
const fencingToken = res.fencingToken;
if (res.status !== 'ok' || fencingToken === null) throw new Error('lock not acquired');
const check = await client.assertFencing('owner-1', fencingToken, ['google_drive:/a/b']);
if (check.status === 'fail') throw new Error(`lost the lock: ${check.reason}`);
// 3) release everything when done
await client.releaseAll('owner-1', true);
client.close();A subscription is bound to a single owner and only ever surfaces that owner's
lifecycle events: a cooperative revoke, a forced kill, or the grant
of a queued acquire.
const sub = client.subscribe('owner-1');
sub.on('event', (e) => {
// e.type is 'grant' | 'killed' | 'revoke'; e.ownerId === 'owner-1'
if (e.type === 'revoke') {
// Cooperative preemption: finish the in-flight unit of work ASAP, then
// release (e.g. releaseAll(ownerId, true)).
} else if (e.type === 'killed') {
// Forced preemption: your locks are ALREADY gone and your fencing token is
// now stale. Stop touching the backing store immediately and abort — do NOT
// keep working, and there is nothing to release (a late write would be
// rejected by the new holder's AssertFencing anyway).
} else if (e.type === 'grant') {
// A previously queued acquire now holds its requested locks.
}
});
sub.on('error', (err) => console.error(err)); // attach this — EventEmitter throws otherwise
// later:
sub.close();A subscription never carries information about other owners. Contended acquires are granted in place by the daemon; wait for the queued owner's
grantevent instead of retrying the acquire.
Share one client. A PathlockdClient owns a single gRPC channel that
multiplexes every call — all unary RPCs and any number of subscribe(ownerId)
streams — over one HTTP/2 connection. Construct it once per process and reuse it;
constructing a client per lock owner (or per request) churns TCP/HTTP-2
connections and pays connection-setup latency on your hot path for no benefit.
// Process-wide, created once.
const client = new PathlockdClient({ endpoint, defaultCallTimeoutMs: 15_000 });
// Per owner: only the subscription stream is owner-scoped. Open it on the
// shared client; do NOT create a new client and do NOT call client.close()
// when an owner finishes (that tears down the channel for everyone) — just
// sub.close().
const sub = client.subscribe(ownerId);
// ...
sub.close();Built-in channel defaults. Every channel is created with reliability defaults
(DEFAULT_CHANNEL_OPTIONS), shallow-merged under any channelOptions you pass
(your keys win):
- Keepalive (30s ping / 10s timeout) so a half-open connection on a long-lived subscription is detected promptly instead of hanging until the OS TCP timeout.
- Automatic retry of calls that fail with
UNAVAILABLE(a Raft leader election/failover, a load-shed, a transient blip), bounded and backing off. Every mutation receives anidempotencyKeyonce per method invocation; the same key is reused by transport retries. Supply your own key when retrying across separate method invocations. Fresh acquisition polls and renewal heartbeats are new operations and require new keys. - 64 MiB receive limit so large
dumpLockspages aren't rejected.
Deadlines. Without a deadline a call waits forever for the daemon. Set
defaultCallTimeoutMs on the client (applied to unary and client-streaming calls), or pass a per-call
deadlineMs — e.g. on acquire, bound it by your own acquisition budget so a
stuck round-trip can't outlive the operation. Pass a signal (AbortSignal) on
any call to cancel the in-flight RPC the instant your operation is aborted,
rather than waiting for the deadline:
const ac = new AbortController();
await client.acquire({
ownerId, ttlMs: 10_000, requests,
deadlineMs: 5_000, // overrides defaultCallTimeoutMs for this call
signal: ac.signal, // ac.abort() cancels the call
});The deadline spans any automatic retries (it is not reset per attempt).
new PathlockdClient({ endpoint, tls?, channelOptions?, defaultCallTimeoutMs? })
| Method | Returns |
|---|---|
acquire(params) |
AcquireResult — status: 'ok' | 'conflict' | 'lost' | 'queued' |
setNamespacePolicy(namespace, algorithm, options?) |
void |
getNamespacePolicy(namespace) |
NamespacePolicyResult |
deleteNamespacePolicy(namespace, options?) |
void |
release(ownerId, requests, delWaitKeyOrOptions?) |
void |
releaseAll(ownerId, delWaitKeyOrOptions?) |
void |
renew(ownerId, ttlMs, options?) |
RenewResult — status: 'ok' | 'lost' |
forceRelease(victimId, options?) |
void |
assertFencing(ownerId, fencingToken, paths) |
AssertResult — status: 'ok' | 'fail' |
detectCycle(startOwnerId, maxDepth) |
CycleResult — kind: 'none' | 'cycle' | 'truncated' |
isBlocking(path, owner, reason) |
boolean |
incrFencingToken(options?) |
bigint (monotonic token; exact beyond 2^53) |
setWaitEdge(ownerId, conflictOwner, ttlMs, metadata?, options?) |
void |
clearWaitEdge(ownerId, options?) |
void |
isOwnerAlive(ownerId) |
boolean |
requestRevoke(ownerId) |
void |
inspectPath(path) |
PathLockInfo — write owner, read owners, semaphore owners, fence |
listOwnerLocks(ownerId) |
OwnerLocksResult — { alive, locks } |
dumpLocks({ ownerPage?, maxEntries? }) |
LockEntry[] — every live lock, auto-paginated |
dumpLocksPages(ownerPage?) |
AsyncGenerator<LockEntry[]> — stream the dump one page at a time |
subscribe(ownerId) |
PathlockdSubscription (typed EventEmitter) |
health() |
{ ok, detail } |
waitForReady(timeoutMs?) / close() |
— |
All request/result shapes are exported types (AcquireParams, AcquireResult,
LockRequest, LockMode, LockState, RenewResult, AssertResult,
CycleResult, LockEvent, LockAlgorithm, ReasonCode, …).
Namespace policies select one of recursive_rw, point_rw,
recursive_write, point_write, or semaphore. A path namespace is also
an explicit routing root:
await client.setNamespacePolicy('jobs:/workers', 'semaphore');
const permit = await client.acquire({
ownerId: 'worker-7',
ttlMs: 10_000,
requests: [{ path: 'jobs:/workers/render', permits: 8 }],
});Semaphore capacity is per path, not per namespace. The first acquire establishes
the path's capacity; later acquires for that path must supply the same
permits value. Semaphore locks are point-scoped, write-only, and do not use
fencing tokens. Changing a namespace's effective algorithm clears its held and
queued locks, and affected owners receive killed events.
Mutating RPCs that support daemon-side apply-once semantics accept
idempotencyKey either in their params object (acquire) or an optional
options object.
For renew, options.domains targets the renew fan-out at exactly the routing
namespaces where the owner holds locks — strongly recommended, since an empty
list makes the daemon probe every lock group on every heartbeat. You don't
have to compute namespaces yourself: a successful acquire echoes the routing
namespace it resolved to in AcquireResult.namespace. Record it per held path
and replay the distinct set as domains. Because the daemon resolved it, the
mapping is always correct (explicit namespaces included), and it can never
mis-target a group the owner doesn't hold:
const heldNamespaces = new Set<string>();
const res = await client.acquire({ ownerId: 'owner-1', ttlMs: 10_000, requests });
if (res.status === 'ok') heldNamespaces.add(res.namespace);
await client.renew('owner-1', 10_000, {
domains: [...heldNamespaces],
idempotencyKey: 'owner-1:renew:42',
});
await client.releaseAll('owner-1', {
delWaitKey: true,
idempotencyKey: 'owner-1:release-all:42',
});For deadlock detection, pass the path and reason from a conflict response as
metadata:
await client.setWaitEdge(ownerId, conflict.owner, ttlMs, {
conflictPath: conflict.path,
reason: conflict.reason,
});The daemon uses that metadata to discard stale live-owner wait edges before they
can be reported as deadlock cycles. Acquire fencing is optional: omit it or pass
0n to let the daemon mint a token, then read the exact bigint from
AcquireResult.fencingToken. Non-empty fencing assertions require a positive
bigint.
Three read-only calls expose live lock state for operators and tooling. They filter by owner liveness (so they reflect what would actually block) and never mutate daemon state:
// Path-centric: who holds this exact path?
const info = await client.inspectPath('google_drive:/a/b');
// info.writeOwner, info.readOwners[], info.semaphoreOwners[], info.fence
// Owner-centric: what does this owner hold?
const { alive, locks } = await client.listOwnerLocks('owner-1');
// Cluster-wide: every live lock (auto-paginated; bounded by maxEntries).
const all = await client.dumpLocks({ maxEntries: 50_000 });
for (const e of all) console.log(e.owner, e.mode, e.path, e.fence);
// Or stream a very large cluster a page at a time:
for await (const page of client.dumpLocksPages()) {
for (const e of page) { /* ... */ }
}npm install
npm run build # tsc -> dist/Keep proto/pathlockd.proto in sync with the daemon's contract; the client
loads it at runtime, so a stale copy silently drops new fields.
The client supports daemon 0.12 without protobuf field-number changes. The daemon
requires a coordinated storage migration; do not mix old and new cluster replicas.
Existing owner IDs remain caller-chosen. With confined JWT grants, always pass
namespaces returned by acquire in domains for renew, release-all and owner reads.
A domain selects a Raft group, so an owner shared across namespaces with different
grants can receive PERMISSION_DENIED on mutations. Use separate owner IDs or a
token covering every affected namespace. Owner reads filter requested namespaces.
The client surfaces permission and legacy-idempotency errors without retrying them.
const client = new PathlockdClient({
endpoint: 'locks.example:50051',
tls: true,
bearerToken: () => currentJwt,
});
await client.setNamespaceQuota('drive:/team', 1000n, {
idempotencyKey: 'set-team-quota-1',
});
const quota = await client.getNamespaceQuota('drive:/team');
// quota.maxElements and quota.heldElements are bigint.bearerToken accepts a string or a synchronous provider evaluated for each new
RPC, including subscriptions and streamed acquire. Existing streams retain their
original authentication. Namespace quotas count locked paths, regardless of how
many readers or semaphore holders share a path. Zero removes the quota. Contended
quota acquisitions return queued with reason quota_exceeded.
import { subscribeSse } from 'pathlockd-nodejs-client';
for await (const event of subscribeSse({
endpoint: 'https://locks.example:8443',
ownerId: 'worker-1',
bearerToken: () => currentJwt,
after: savedCursor,
signal: abortController.signal,
})) {
savedCursor = event.id;
// Recheck lock state before doing protected work.
}SSE IDs are opaque strings. Persist and resend them unchanged; never convert them
to number. The helper makes one connection and yields retained/new events.
Reconnect by calling it again with the last saved ID. Old-generation, legacy
numeric, and future cursors replay available history. Handle duplicate wakeups
and gaps; a grant event alone does not establish ownership. gRPC subscribe
remains live-only and does not support resume cursors.
Legacy daemon idempotency keys remain reserved across the upgrade and return
INVALID_ARGUMENT on reuse until expiry. Reconcile ambiguous old requests before
issuing new logical operations with new keys.
Run npm test for wire, authentication, quota, integer, cancellation and SSE tests.
After a server namespace split, a fresh acquisition using an old external token
may return stale_fencing_token, even when another live lock still uses that
token. Released paths retain a conservative fencing floor from the source shard.
Use fencingToken: 0n to request a new server-minted token for a fresh acquisition;
keep the returned token for assertFencing. Existing live holds keep their tokens.
Version 0.6.1 adds idempotent requestRevoke options and per-call deadlines and
abort signals for fencing assertions, inspection, cycle detection, blocking
checks, lock dumps, and health. A successful RPC response does not extend a
client's lease deadline by its round-trip time: callers should measure validity
conservatively from the original acquisition or renewal invocation.
Subscriptions accept cancellation and an explicit per-stream deadline. Unary client defaults do not impose a lifetime on a subscription. Applications should preserve their current held fencing token while an acquisition with a newer candidate token is queued, and switch tokens after confirming the grant.
Client 0.6.1 is the tested companion to pathlockd 0.13.0. Successful calls may switch cluster endpoints while preserving causal visibility. State reads require quorum; keep explicit idempotency keys when retrying a mutation manually.