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_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.