Cluster locks
Run work exactly once across instances.
When an app runs as several instances, some work must still happen exactly once: a nightly digest, a cache rebuild, a cleanup pass. withLock runs a function while holding a named lock that every instance respects.
import { withLock } from 'ohnejs';
await withLock('emails:digest', () => sendDailyDigest());The lock is a row in the main database, so it excludes every instance of the app, not just other code in this process. Whoever acquires the lock runs the function; everyone else calling withLock with the same key waits until it is released, then takes their turn.
The lock releases when the function settles - on success and on throw alike - and withLock returns whatever the function returned.
withLock needs the connected main database. Inside a running app - a handler, a hook - that is a given. Boot files run before the connection opens, so a boot file calls withLock from a hook such as server:ready, never at its top level. A standalone script, such as a cron job, opens the connection itself.
Timing
A third argument tunes a bid:
await withLock('reports:rebuild', () => rebuildReports(), {
pollInterval: 500,
staleAfter: 5 * 60_000,
});pollIntervalis how often a waiting instance re-checks a held lock, in milliseconds. Defaults to250.staleAfteris when a held lock counts as abandoned - a holder that crashed without releasing - and is taken over. Defaults to one minute.
staleAfter must exceed the worst-case duration of the guarded work. If the work can take five minutes, a one-minute staleAfter lets another instance steal the lock mid-run.
Waiting is unbounded. There is no timeout and no try-once option - a contender blocks until the lock is released or goes stale.
Rules
withLockis not reentrant. Nesting it on the same key stalls until the inner call steals the outer lock afterstaleAfter.- The key
syncis reserved for the schema sync. Passing it throws immediately.