/ Docs

Project locks

Project locks are renewable leases for backend coordination, keeping one holder at a time on work like a migration or a scheduled rollup.

Project locks are renewable leases for backend coordination. They require a service-role key with locks.manage or full access.

import { VolcanoAuth } from '@volcano.dev/sdk';

const volcano = new VolcanoAuth({
  apiUrl: process.env.VOLCANO_API_URL,
  anonKey: process.env.ANON_KEY,
  accessToken: process.env.SERVICE_ROLE_KEY,
});

const result = await volcano.locks.withLock('daily-rollup', { ttl: 30 }, async ({ signal }) =>
  runRollup({ signal }),
);

if (result.error) throw result.error;
if (!result.acquired) console.log('another function is leader');

withLock renews near one-third of the TTL and releases in finally. Contention returns { acquired: false, error: null }, whether another holder owns the lock or a previous lease of your own has lapsed. If renewal fails, the callback signal is aborted; callbacks must honor it. A stalled renewal also aborts the signal when the locally measured TTL elapses.

For example, a scheduled function can skip work when another invocation is already the leader:

export const handler = async () => {
  const result = await volcano.locks.withLock(
    'scheduled-cleanup',
    { ttl: 60 },
    async ({ signal }) => {
      const deleted = await deleteExpiredRecords({ signal });
      return { deleted };
    },
  );

  if (result.error) throw result.error;
  return {
    statusCode: 200,
    body: JSON.stringify(
      result.acquired ? { leader: true, ...result.data } : { leader: false, skipped: true },
    ),
  };
};

For direct control:

const acquired = await volcano.locks.acquire('migration', { ttl: 10 });
if (!acquired.acquired || acquired.error) return acquired;

const renewal = new AbortController();
try {
  const renewed = await volcano.locks.renew('migration', acquired.lease, {
    ttl: 10,
    signal: renewal.signal,
  });
  if (renewed.error) throw renewed.error;
} finally {
  await volcano.locks.release('migration', acquired.lease);
}

Abort renewal to cancel the in-flight renewal request. Cancellation is reported through renewed.error; the existing lease remains unchanged.

Recover an uncertain acquisition

Acquisition retries a transport failure or HTTP 503 once with the original key, TTL, ownership token, request ID, and credential. Other HTTP errors are returned without retrying acquisition. Generate and retain IDs before acquiring if you need to recover after both attempts fail:

const token = crypto.randomUUID();
const requestId = crypto.randomUUID();
const result = await volcano.locks.acquire('migration', { ttl: 30, token, requestId });

Reuse those IDs for the same uncertain acquisition. Use a new ownership token for a new lease after release or expiry. Keep the token private. Every lock method accepts requestId; withLock forwards its token and requestId only to acquisition, and generates separate request IDs for renewal and release.

Fencing token

lease.fencingToken rises whenever the lock changes hands and stays the same across renewals. Pass it to whatever the lock protects and reject writes carrying a lower token than the highest already seen:

const { rowCount } = await sql`
  update rollup_state
  set    cursor = ${next}, fencing_token = ${lease.fencingToken}
  where  id = ${id} and fencing_token <= ${lease.fencingToken}
`;
if (rowCount === 0) throw new Error('another holder took over');

A lease cannot stop a holder whose renewal is delayed past expiresAt, so this check is what keeps its late writes out.

Inspect and recover

const { state } = await volcano.locks.get('migration');
// { held: true, expiresAt: '2026-07-20T14:00:10Z', fencingToken: 1784684410123 }

await volcano.locks.forceRelease('migration');

get requires no lock token, so monitoring code can read the holder. held: false means an acquire would succeed now.

forceRelease drops the lease whatever token holds it, for a holder that died without releasing. It breaks mutual exclusion by itself — the old holder keeps working until its next renewal fails — so use it only where the protected resource checks the fencing token.

Keys may contain letters, digits, ., _, :, and - and are limited to 128 characters. TTL is 5 seconds through 90 days. An unreleased lease expires after its requested TTL. Renewing sets the new expiry outright instead of adding to the current one, so a smaller TTL shortens the lease. Renewals cannot move an acquisition's absolute 90-day deadline; acquire a new lease after that point. Keep the returned lease private because its token proves ownership.

Rate-limit errors expose status: 429, code: "lock_rate_limited", and retryAfter in seconds. Every request counts against the project's 600-per-minute budget, reads and force releases included, and each holder spends 180 / ttl renewals per minute, so a 30-second TTL supports roughly 100 concurrent holders per project. Raise the TTL when you need more.

On this page