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 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.
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();Related pages
- Schedules for time-triggered workflows.
- Jobs for background work.
- Outbox for durable event and job delivery.
- Idempotency for retry-safe command handling.