Lock Helpers
Prevent the same work from running concurrently in oRPC with a unified Locker interface, storage adapters, and procedure middleware.
Installation
npm install @orpc/experimental-lock@betapnpm add @orpc/experimental-lock@betayarn add @orpc/experimental-lock@betabun add @orpc/experimental-lock@betaBasic Usage
The core concept is the Locker interface, which defines a standard way to run work under a lock, so that work sharing the same key never runs concurrently. You can create your own custom locker or use one of the provided adapters:
| Name | Adapter for |
|---|---|
MemoryLocker |
In-memory storage |
RedisLocker |
Redis |
UpstashLocker |
Upstash Redis |
BunRedisLocker |
Bun’s Redis |
DurableLocker |
Cloudflare Durable Objects |
The lock method runs a callback while holding the lock for a key, and releases the lock afterwards, even when the callback throws. The callback receives waited, which tells whether the lock was acquired immediately (false) or only after waiting for another holder to release it (true).
const const locker: MemoryLockerlocker = new new MemoryLocker(options?: MemoryLockerOptions): MemoryLockerLocker adapter backed by in-memory storage, so locks are only shared within
the current process. Waiters acquire the lock in order, without polling.MemoryLocker()
const const report: {
url: string;
}
report = await const locker: MemoryLockerlocker.MemoryLocker.lock<{
url: string;
}>(key: string, fn: (options: LockCallbackOptions) => Promisable<{
url: string;
}>, options?: LockOptions): Promise<{
url: string;
}>
Runs `fn` while holding the lock for `key`, and releases the lock afterwards,
even when `fn` throws.lock('report:123', async ({ waited: booleanWhether the lock was acquired only after waiting for another holder to release it.
`false` means the lock was acquired immediately.waited }) => {
if (waited: booleanWhether the lock was acquired only after waiting for another holder to release it.
`false` means the lock was acquired immediately.waited) {
// Another holder just finished the same work, so its result may already be available
const const cached: {
url: string;
} | undefined
cached = await const cache: {
get: (key: string) => Promise<{
url: string;
} | undefined>;
}
cache.get: (key: string) => Promise<{
url: string;
} | undefined>
get('report:123')
if (const cached: {
url: string;
} | undefined
cached) {
return const cached: {
url: string;
}
cached
}
}
return await function generateReport(id: string): Promise<{
url: string;
}>
generateReport('123')
})
Options
ttl: how long the lock is held before it expires automatically, in milliseconds. Guards against holders that never release the lock. Each adapter has its own default.timeout: how long to wait for the lock to become available, in milliseconds.lockrejects with aLockTimeoutErroronce it elapses.signal: stops waiting early and rejects with the abort reason. Has no effect once the lock is acquired.
await locker.lock('report:123', () => generateReport('123'), {
ttl: 30_000,
timeout: 5_000,
signal: request.signal,
})
Lock Middleware
The lock helper creates middleware that runs procedures under a lock, so that calls sharing the same key never run concurrently. When the lock cannot be acquired before the timeout elapses, the procedure rejects with a CONFLICT error. The request signal is forwarded, so a call stops waiting as soon as the client disconnects.
import { lock, Locker } from '@orpc/experimental-lock'
const procedure = os
.$context<{ locker: Locker }>()
.input(z.object({ id: z.string() }))
.use(
lock({
locker: ({ context }) => context.locker,
key: ({ context }, input) => `report:${input.id}`,
/**
* How long the lock is held before it expires automatically, in milliseconds.
*
* @default the adapter default
*/
ttl: 30_000,
/**
* How long to wait for the lock to become available, in milliseconds.
* The procedure rejects with a `CONFLICT` error when it elapses before the lock is acquired.
*
* @default the adapter default
*/
timeout: 5_000,
}),
)
.handler(async ({ context, input }) => {
if (context['lock/waited']) {
// Another call with the same key just finished, so its result may already be available
}
return await generateReport(input.id)
})
const locker = new MemoryLocker()
const result = await call(
procedure,
{ id: '123' },
{ context: { locker } }
)
Adapters
Memory
Keeps locks in the memory of the current process, so they are not shared between instances. A good fit for development, tests, and single-process servers.
import { MemoryLocker } from '@orpc/experimental-lock/memory'
const locker = new MemoryLocker({
/**
* How long a lock is held before it expires automatically, in milliseconds.
* Can be overridden per call.
*
* @default undefined (held until released)
*/
ttl: 30_000,
/**
* How long to wait for a lock to become available, in milliseconds.
* Can be overridden per call.
*
* @default 10000
*/
timeout: 5_000,
})
Redis
Stores locks in Redis, so every instance using the same server shares the same locks. Works with both standalone and cluster clients.
import { RedisLocker } from '@orpc/experimental-lock/redis'
import { createClient } from 'redis'
// Both standalone (`createClient`) and cluster (`createCluster`) clients are supported.
const client = createClient({ url: 'redis://localhost:6379' })
// RedisLocker lazily connects to Redis when needed.
// You can still call `client.connect()` manually, but it is optional.
await client.connect()
const locker = new RedisLocker(client, {
/**
* The prefix to use for Redis keys.
*
* @default ''
*/
prefix: '',
/**
* How long a lock is held before it expires automatically, in milliseconds.
* Can be overridden per call.
*/
ttl: 30_000,
/**
* How long to wait for a lock to become available, in milliseconds.
* Can be overridden per call.
*
* @default 10000
*/
timeout: 5_000,
/**
* How long to wait between acquisition attempts while the lock
* is held by someone else, in milliseconds.
*
* @default 100
*/
retryInterval: 100,
})
Upstash
Stores locks in Upstash Redis, so it shares locks with RedisLocker. A good fit for serverless and edge runtimes, keeping in mind that every acquisition attempt is a REST request.
import { UpstashLocker } from '@orpc/experimental-lock/upstash'
import { Redis } from '@upstash/redis'
const redis = Redis.fromEnv()
const locker = new UpstashLocker(redis, {
/**
* The prefix to use for Redis keys.
*
* @default ''
*/
prefix: '',
/**
* How long a lock is held before it expires automatically, in milliseconds.
* Can be overridden per call.
*/
ttl: 30_000,
/**
* How long to wait for a lock to become available, in milliseconds.
* Can be overridden per call.
*
* @default 10000
*/
timeout: 5_000,
/**
* How long to wait between acquisition attempts while the lock
* is held by someone else, in milliseconds.
*
* @default 100
*/
retryInterval: 100,
})
Bun Redis
The Redis adapter for Bun’s built-in Redis client, with no extra dependency. It shares locks with RedisLocker.
import { experimental_BunRedisLocker as BunRedisLocker } from '@orpc/bun'
import { redis } from 'bun'
const locker = new BunRedisLocker(redis, {
/**
* The prefix to use for Redis keys.
*
* @default ''
*/
prefix: '',
/**
* How long a lock is held before it expires automatically, in milliseconds.
* Can be overridden per call.
*/
ttl: 30_000,
/**
* How long to wait for a lock to become available, in milliseconds.
* Can be overridden per call.
*
* @default 10000
*/
timeout: 5_000,
/**
* How long to wait between acquisition attempts while the lock
* is held by someone else, in milliseconds.
*
* @default 100
*/
retryInterval: 100,
})
Cloudflare
Stores locks in Durable Objects, so every Worker instance shares the same locks. Export a class extending DurableLockObject, bind it in your Wrangler config, and pass the namespace to DurableLocker.
{
"durable_objects": {
"bindings": [{ "name": "LOCK_DON", "class_name": "LockDO" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["LockDO"] }]
}
import { experimental_DurableLocker as DurableLocker, experimental_DurableLockObject as DurableLockObject } from '@orpc/cloudflare'
export class LockDO extends DurableLockObject {}
export default {
async fetch(request, env) {
const locker = new DurableLocker(env.LOCK_DON, {
/**
* The prefix to use for Durable Object names.
*
* @default ''
*/
prefix: '',
/**
* How long a lock is held before it expires automatically, in milliseconds.
* Can be overridden per call.
*/
ttl: 30_000,
/**
* How long to wait for a lock to become available, in milliseconds.
* Can be overridden per call.
*
* @default 10000
*/
timeout: 5_000,
/**
* Custom function to get the Durable Object stub for a lock key.
*
* @default ((namespace, key) => namespace.getByName(key))
*/
getStubByName: (namespace, key) => namespace.getByName(key),
})
},
}