#Locks
$lock is a middleware that lets one caller at a time run a handler. It takes a
lock before the handler and releases it after, whether the handler returned or
threw. Over Redis the lock holds across every instance of the app.
1import { $lock } from "alepha/lock";
#Guarding a handler
Put it in the use array of an $action, a $job, a $page or a $pipeline:
1import { $lock } from "alepha/lock"; 2import { $action } from "alepha/server"; 3 4class Payouts { 5 run = $action({ 6 method: "POST", 7 path: "/payouts/run", 8 use: [$lock({ name: "payouts" })], 9 handler: async () => {10 // only one caller runs this at a time11 },12 });13}
name can also be a function of the handler's arguments, to lock one resource
rather than the whole handler. name is required: a middleware has no property
key to fall back on.
1import { $pipeline } from "alepha"; 2import { $lock } from "alepha/lock"; 3 4class Accounts { 5 recompute = $pipeline({ 6 use: [$lock({ name: (id: string) => `account:${id}` })], 7 handler: async (id: string) => { 8 console.log("recomputing", id); 9 },10 });11}
#Options
| Option | Default | What it does |
|---|---|---|
name |
The lock's key: a string, or a function of the handler's arguments. | |
wait |
false |
Wait for the lock instead of failing at once. |
maxDuration |
[5, "minutes"] |
How long the lock can be held before it expires by itself. |
#When the lock is taken
Without wait, a call that finds the lock held throws LockAcquireError.
With wait: true, it tries again every 500 milliseconds for up to maxDuration,
then throws the same error.
LockAcquireError carries no HTTP status, so in an $action it answers a 500. The
handler cannot catch it, since the middleware throws before the handler runs, and
an action's errors cover the handler alone. If
a held lock is a normal outcome for that caller, put a
middleware of your own before $lock in use: the
first one is the outermost, so it can catch the error.
#How long a lock lasts
⚠️ maxDuration is not a timeout on your handler. The lock expires, the
handler keeps running, and the next caller gets in. Set it above the longest run
you expect.
The lock then protects what comes after: on release, a holder deletes the lock
only if it still owns it, so a handler that outlived maxDuration cannot release
the next holder's lock.
An expired lock is also what frees a key after a crash: an instance that dies
holding one blocks others for at most maxDuration.
#Providers
| Provider | Selected | Reach |
|---|---|---|
MemoryLockProvider |
by default | This process. |
RedisLockProvider |
when REDIS_URL is set |
Every instance. |
⚠️ Without REDIS_URL, a lock only excludes callers in the same process. Two
instances both enter, and nothing warns you. The same applies to Cloudflare
Workers, where a lock is memory only, per isolate. A substitution of your own for
LockProvider wins over both defaults.
#Cron jobs
A $job with a cron already takes a lock around each tick, so with REDIS_URL
set one instance runs it, and without it every replica does. If taking that lock
fails, the tick runs anyway. Its lock option is true by default, and it has no
effect on queue or direct jobs, which rely on claiming a row instead. See
Background jobs.
#Hooks
| Hook | Fires when | Payload |
|---|---|---|
lock:acquired |
A caller takes the lock. | name, id, maxDurationMs |
lock:released |
The handler finished or threw. | name, id, heldMs |
lock:contended |
A caller found it held, whether or not it gets the lock later. | name, id |