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_MAX_RETRIES_PER_REQUEST, and
REDIS_LOCKS_CONNECT_MAX_ATTEMPTS.
The provider contributes ctx.ports.locks and ctx.ports.redisLocks as an
escape hatch with the raw Redis client and configured prefix.
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. 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.