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:

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 ioredis

Register 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();