Locks and leases
Use LocksPort when only one process, server, worker, schedule, or task should
own a short piece of work at a time.
A lock coordinates ownership. A lease coordinates ownership with an expiration. Beignet models the runtime object as a lease so crashed workers, interrupted deploys, and lost processes do not hold ownership forever.
Acquire a lease
const result = await ctx.ports.locks.acquire("schedule:daily-report", {
ttlMs: 60_000,
waitMs: 0,
metadata: {
schedule: "daily-report",
},
});
if (!result.acquired) return;
try {
await runDailyReport(ctx);
} finally {
await result.lease.release();
}Use withLease(...) when the work fits a callback:
await ctx.ports.locks.withLease(
"outbox:drain",
{ ttlMs: 30_000, waitMs: 5_000 },
async ({ lease }) => {
await drainOutbox(ctx, {
fencingToken: lease.fencingToken,
});
},
);ttlMs should be long enough for the protected critical section and short
enough that a crashed process gives up ownership promptly. Renew the lease when
the work is intentionally longer than the original TTL:
const renewed = await lease.renew({ ttlMs: 60_000 });
if (!renewed) {
throw new Error("Lost lease ownership before the job finished.");
}When a later serverless invocation resumes work, restore the handle with the
persisted owner token and the TTL that a no-argument renew() should use:
const lease = ctx.ports.locks.restore(key, ownerToken, {
ttlMs: 60_000,
expiresAt: persistedExpiresAt,
fencingToken: persistedFencingToken,
});Only pass expiresAt and fencingToken when they were persisted from the
original lease. Beignet leaves omitted metadata unknown rather than fabricating
values. A stale handle can neither renew nor release a newer owner's lease.
When to use locks
Use locks for coordination:
- prevent overlapping schedule runs
- ensure only one worker owns a singleton maintenance job
- coordinate outbox drains or queue partitions when the underlying store does not already claim rows safely
- prevent cache stampedes while one process recomputes an expensive value
- guard short provider operations that should not run concurrently
Do not use locks as the only correctness mechanism for durable business invariants. For example, "create one invoice per order" should still use a database unique constraint or idempotency key. A lease can reduce duplicate work; the database remains the source of truth.
Setup with Redis
Install the Redis locks provider:
bun add @beignet/provider-locks-redis ioredisRegister it in server/providers.ts:
import { createRedisLocksProvider } from "@beignet/provider-locks-redis";
export const providers = [
createRedisLocksProvider({
prefix: "my-app:locks",
}),
];Set REDIS_LOCKS_URL in production when the provider should create its own
client. If you already manage a Redis client, pass it with
createRedisLocksProvider({ client }). Optional env vars include
REDIS_LOCKS_DB, REDIS_LOCKS_PREFIX, REDIS_LOCKS_CONNECT_TIMEOUT_MS,
REDIS_LOCKS_SHUTDOWN_TIMEOUT_MS, REDIS_LOCKS_MAX_RETRIES_PER_REQUEST, and
REDIS_LOCKS_CONNECT_MAX_ATTEMPTS.
Environment-backed numeric values use non-negative integer strings. Pass
numbers to the matching db, connectTimeoutMs, shutdownTimeoutMs, and
maxRetriesPerRequest factory options. Both forms require safe integers.
Connection and shutdown timeouts cannot exceed 2,147,483,647 milliseconds,
the JavaScript runtime timer ceiling; shutdown timeouts must be positive.
Lease waitMs accepts integers from 0 through that ceiling, and
retryDelayMs accepts integers from 1 through that ceiling.
For provider-owned clients, the shutdown deadline defaults to 5000ms.
server.stop() rejects when graceful Redis shutdown fails or exceeds that
deadline, after attempting a forced disconnect.
The provider contributes ctx.ports.locks and ctx.ports.redisLocks as an
escape hatch with the raw Redis client and configured prefix.
Lease owner tokens use Web Crypto randomUUID() or getRandomValues() when
available and securely fall back to node:crypto on supported Node runtimes.
The direct Redis adapter accepts createOwnerToken for deterministic tests;
production wiring should use the secure runtime default.
Correctness and Redis topology
The Redis provider targets a single Redis primary. Its acquisition script
atomically creates the lease and increments the per-key fencing counter on
that primary. When fencing tokens protect correctness, use a dedicated Redis
deployment with maxmemory-policy noeviction. The counter has no TTL, so an
allkeys-* eviction policy can delete it and let a later acquisition restart
at 1; noeviction makes memory pressure fail the lock operation instead of
reusing a token. Verify this setting in deployment configuration. The current
two-key Lua acquisition does not support Redis Cluster, and asynchronous
primary failover can lose recent lease or counter writes. Treat failover and
network partitions as application correctness concerns rather than guarantees
supplied by the provider.
A fencing token only protects a durable side effect when that resource stores the last accepted token and atomically rejects tokens that are not strictly greater. Otherwise, locks reduce duplicate work but cannot prove that a stale owner will never finish after its lease expires.
Testing
createTestPorts(...) includes an in-memory locks port by default:
const { ports, locks, clock } = createTestPorts<AppPorts>();
const result = await ports.locks.acquire("job:sync", { ttlMs: 1_000 });
expect(result.acquired).toBe(true);
clock.advance(1_000);
if (result.acquired) {
await expect(result.lease.renew()).resolves.toBe(false);
}
expect(locks.leases.has("job:sync")).toBe(false);You can also import the memory adapter directly:
import { createMemoryLocks } from "@beignet/core/locks";
const locks = createMemoryLocks();Beignet's provider suite also runs live single-primary Redis contention tests in CI. They exercise independent clients racing one key, monotonic fencing, expired-owner rejection, bounded waiting, and timeout behavior. They do not simulate Redis Cluster, primary failover, or network partitions.
Related pages
- Schedules for time-triggered workflows.
- Jobs for background work.
- Outbox for durable event and job delivery.
- Idempotency for retry-safe command handling.