# Distributed Locks for Agents and Workers | Volcano

URL: https://volcano.dev/features/distributed-locks

> Distributed locks on Volcano: renewable leases that allow at most one active holder per task, fencing tokens that stop a holder that lost its lease from overwriting the next one, and SDKs for JavaScript, Python and Ruby.

## One active holder per task, at any scale

Renewable leases that let one agent, worker or durable function hold a task at a time, with fencing tokens so a holder that lost its lease cannot overwrite the one that took over.

```console
# three agents reach for the same ticket
agent-a  withLock('ticket:4821')  acquired · fencing 17
agent-b  withLock('ticket:4821')  acquired: false · takes ticket:4822
agent-c  withLock('ticket:4821')  acquired: false · takes ticket:4823
agent-a  lease renewed every 10s while it works
✓ released · ticket 4821 handling complete
```

## Scale out, and the same task runs twice

Add a second worker and the races start. Two agents answer the same ticket, two durable executions sync the same account, two overlapping runs apply the same migration. The usual fix is a separate lock service and a hand-rolled lease that forgets to renew, or a database row locked for the whole length of the job.

Volcano gives every project named locks. A holder takes a lease with a TTL, the SDK renews it while the work runs and releases it when the work ends, and every other caller gets acquired: false straight away instead of waiting. If the holder dies, its lease expires and the next caller takes over. A fencing token that rises with each new holder lets the data you protect refuse a write from a holder that already lost the lock.

## Held while the work runs, released when it ends

*Leases*

withLock takes the lease, renews it at about a third of the TTL, and releases it when your callback returns. If a renewal fails, it aborts the signal it handed your callback, so the work stops instead of carrying on without the lock.

- TTLs from 5 seconds to 90 days
- A held lock answers acquired: false, it never blocks
- A crashed holder's lease expires on its own

```console
$ await volcano.locks.withLock('nightly-rollup', { ttl: 30 }, run)
acquired   expires in 30s · fencing 4503599627370497
renewed    expires in 30s
renewed    expires in 30s
released · the next run can take it
```

## A holder that lost the lock cannot write

*Fencing*

A lease cannot stop a process that stalled past its expiry. The fencing token can. It rises every time the lock changes hands, so a guarded write that carries an older token changes nothing and the stale holder knows to stop.

- Rises with every new holder, not on renewals
- One conditional UPDATE to enforce it
- Keeps a forced release safe

```console
$ update job_state set cursor = $1, fencing_token = $2
$   where id = $3 and fencing_token <= $2;
holder with token 18 (current)   UPDATE 1
holder with token 17 (stalled)   UPDATE 0 · stops
the stale write was refused by the data itself
```

## A fleet that shares work without a queue

*Scale*

Give each task its own key and any number of agents, workers or durable executions can reach for work at once. The first acquire wins, the rest move on to the next task, and a task whose worker died becomes available again when its lease runs out.

- A key per task, like ticket:4821
- Scoped to the project, so keys never collide across projects
- Longer TTLs keep more holders alive at once

```console
agent-a  ticket:4821  acquired
agent-b  ticket:4821  acquired: false → ticket:4822 acquired
agent-c  ticket:4822  acquired: false → ticket:4823 acquired
agent-a  crashed · lease on ticket:4821 runs out in 60s
ticket:4821 taken over by agent-b · fencing 18
```

## What every lock gives you

- **Renewable leases**: Acquire with a TTL from 5 seconds to 90 days, renew while you work, release when you are done.
- **withLock**: Acquire, renew and release in one call, with an abort signal the moment ownership is lost.
- **Fencing tokens**: A rising token per lock key, so the resource you protect can refuse a holder that lost its lease.
- **Non-blocking contention**: A held lock answers acquired: false at once, so a worker moves on to other work instead of waiting.
- **Inspect and recover**: Read whether a lock is held without owning it, and force-release the lease of a holder that died.
- **JavaScript, Python and Ruby**: The same leases from every SDK, and a REST API for anything else that speaks HTTP.

## Take it, fence it, share it

### Claim a task

```javascript
const { VolcanoAuth } = require('@volcano.dev/sdk');

// Locks take a service key, so run this in a function or on your own server.
const volcano = new VolcanoAuth({
  apiUrl: process.env.VOLCANO_API_URL,
  anonKey: process.env.VOLCANO_ANON_KEY,
  accessToken: process.env.VOLCANO_SERVICE_KEY,
});

// Every agent runs this over the same backlog. At most one agent holds a
// ticket's lease at a time; the others get acquired: false and move on.
async function takeNextTicket(tickets) {
  for (const ticket of tickets) {
    const result = await volcano.locks.withLock(
      `ticket:${ticket.id}`,
      { ttl: 60 },
      ({ signal, lease }) => answerTicket(ticket, { signal, fencingToken: lease.fencingToken }),
    );

    if (result.error) throw result.error;
    if (result.acquired) return result.data;
  }
  return null;
}
```

### Durable function

```javascript
const { durable } = require('@volcano.dev/sdk/durable');
const { VolcanoAuth } = require('@volcano.dev/sdk');

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

// Many executions can start for the same account. The lock makes sure only
// one of them syncs it at a time, and the step records which one did.
exports.handler = durable(async (input, ctx) => {
  const sync = await ctx.step('sync', async () => {
    const result = await volcano.locks.withLock(
      `account-sync:${input.account_id}`,
      { ttl: 60 },
      ({ signal }) => syncAccount(input.account_id, { signal }),
    );
    if (result.error) throw result.error;
    return { ran: result.acquired };
  });

  if (!sync.ran) return { skipped: 'another execution is syncing this account' };

  await ctx.step('notify', () => notifyOwner(input.account_id), { atMostOnce: true, retry: false });
  return { synced: true };
});
```

### Fence writes

```sql
-- Store the highest fencing token seen next to the state the lock protects.
alter table job_state add column if not exists fencing_token bigint not null default 0;

-- Every write from a holder carries its token. A holder that lost the lock
-- to a newer one has a lower token, so its update touches no rows.
update job_state
set    cursor = $1, fencing_token = $2
where  id = $3 and fencing_token <= $2;

-- 0 rows updated means someone else holds the lock now: stop the work.
```

### Python

```python
import os

from volcano_sdk import ConflictError, VolcanoClient

client = VolcanoClient(
    anon_key=os.environ["VOLCANO_ANON_KEY"],
    service_key=os.environ["VOLCANO_SERVICE_KEY"],
    api_url=os.environ["VOLCANO_API_URL"],
)


def handler(event, context):
    try:
        # Renewed in the background for as long as the block runs, and
        # released when it exits.
        with client.locks.with_lock("nightly-report", ttl=60) as guard:
            build_report(fencing_token=guard.lease.fencing_token, lost=lambda: guard.lost)
    except ConflictError:
        return {"statusCode": 200, "body": "another run holds the report"}

    return {"statusCode": 200, "body": "report built"}
```

## What people coordinate with locks

### Agents that never work a task concurrently

A support fleet, a research swarm or a crew of coding agents reaches into one backlog. A lock per task prevents concurrent work, and a task whose agent crashed goes back to the pool when its lease runs out. Make side effects idempotent or fence them because a takeover can retry unfinished work.

### One durable execution per resource

Webhooks, schedules and users can all start a workflow for the same account at once. The execution that holds the account's lock does the work, and the others record that it was already in hand and finish.

### Scheduled work that never overlaps

A rollup that sometimes takes longer than its interval, or a migration a deploy might start twice. Wrap it in withLock and an overlapping run returns straight away instead of racing the one in progress.

## Works with the rest of Volcano

- [Coordinate executions](https://volcano.dev/features/durable-functions): Take a lock inside a durable step so only one execution works on a shared resource at a time.
- [Elect a leader](https://volcano.dev/features/edge-functions): Wrap a scheduled or fanned-out function in withLock, and the runs that lose skip instead of doubling up.
- [Fence your writes](https://volcano.dev/features/postgres): Keep the fencing token beside the rows the lock protects, and one conditional UPDATE refuses stale holders.
- [Tell the others](https://volcano.dev/features/realtime): Broadcast when a holder finishes, so waiting agents pick up the next task without polling.

## Frequently asked questions

### Who can take a lock?

Trusted code holding the project's service key: a function, a durable function or your own backend. Locks are scoped to the project, so two projects can use the same key without meeting, and browser credentials cannot take them.

### What happens if the holder crashes?

Its lease stops being renewed and expires at its TTL. One second later, a grace period for clock skew, the next caller can take it. Choose the TTL for how long you are willing to wait before a dead holder's task is picked up again.

### Does acquiring wait for the lock to be free?

No. A held lock answers acquired: false at once in JavaScript and raises ConflictError in Python, so a worker can move on to another task or try again on its own schedule.

### Why do I need fencing tokens?

Because a lease cannot stop code that is already running. A holder that pauses past its expiry, in a long garbage collection or a slow network call, can wake up after someone else took the lock. The fencing token rises with each new holder, so a write guarded by it is refused when it comes from the older one.

### How many locks can a project hold at once?

A project gets 600 lock operations a minute, renewals included. withLock renews at a third of the TTL, so a 30-second TTL keeps about 100 holders alive at once and a 60-second TTL about 200. Longer TTLs raise that further, at the cost of a slower takeover after a crash.

### How is this different from a durable function's execution name?

An execution name makes one start idempotent: the same name never starts twice. A lock coordinates work that runs in many places at once, like several agents or executions reaching for the same resource. Use both: name executions after the task, and lock the shared resource they touch.

## Get started

Ready to coordinate your workers? Create a free account at https://volcano.dev/signup, or have your coding agent follow the setup playbook at https://volcano.dev/setup.md.
