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:

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.

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.