Skip to main content

Lock usage

Initial configuration

To begin using the LockProvider class, you'll need to create and configure an instance:

import { TimeSpan } from "@daiso-tech/core/utilities";
import { MemoryLockAdapter } from "@daiso-tech/core/lock/adapters";
import { LockProvider } from "@daiso-tech/core/lock";
import { Namespace } from "@daiso-tech/core/utilities";
import { Serde } from "@daiso-tech/core/serde";
import { SuperJsonSerdeAdapter } from "@daiso-tech/core/serde/adapters";

const serde = new Serde(new SuperJsonSerdeAdapter());

const lockProvider = new LockProvider({
// You can provide default TTL value
// If you set it to null it means locks will not expire and most be released manually.
defaultTtl: TimeSpan.fromSeconds(2),

serde,

namespace: new Namespace("lock"),

// You can choose the adapter to use
adapter: new MemoryLockAdapter(),
});
info

Here is a complete list of configuration settings for the LockProvider class.

Lock basics

Creating a lock

const lock = lockProvider.create("shared-resource");

Acquiring and releasing the lock the lock

const hasAquired = await lockProvider.aquire();
if (hasAquired) {
try {
// The critical section
} finally {
await lockProvider.release();
}
}

Alternatively you could write it as follows:

try {
// This method will throw if the lock is not acquired
await lockProvider.aquireOrFail();
// The critical section
} finally {
await lockProvider.release();
}
danger

You need always to wrap the critical section with try-finally so the lock get released when error occurs.

danger

Note lock object uses LazyPromise instead of a regular Promise. This means you must either await the LazyPromise or call its defer method to run it. Refer to the LazyPromise documentation for further information.

Locks with custom TTL

You can provide a custom TTL for the key.

const lock = lockProvider.create("shared-resource", {
// Default TTL is 5min if not overrided
// If you set it to null it means locks will not expire and most be released manually.
ttl: TimeSpan.fromSeconds(30),
});

Checking lock state

You can check whether the lock has expired. If it has, the lock is available for acquisition:

await lockProvider.isExpired();

You can check whether the lock is in use, in other words acquired:

await lockProvider.isLocked();

You can also get reamining expiration time:

await lockProvider.getRemainingTime();
info

Null is returned if the key has no expiration, the key doesnt exist or the key has expired.

Patterns

Acquire blocking

You can acquire the lock at regular intervals until either successful or a specified timeout is reached:

const lock = lockProvider.create("resource");

const hasAcquired = await lock.acquireBlocking({
// Time to wait 1 minute
time: TimeSpan.fromMinutes(1),
// Intervall to try acquire the lock
interval: TimeSpan.fromSeconds(1),
});
if (hasAcquired) {
try {
await doWork();
} finally {
await lock.release();
}
}
// Will be logged after 1min
console.log("END");
warning

Note using acquireBlocking in a HTTP request handler is discouraged because it blocks the HTTP request handler causing the handler wait until the lock becomes available or the timeout is reached. This will delay HTTP request handler to generate response and will make frontend app slow because of HTTP request handler.

Refreshing locks

The lock can be refreshed by the current owner before it expires. This is particularly useful for long-running tasks, instead of setting an excessively long TTL initially, you can start with a shorter one and use the refresh method to set the TTL of the lock:

const lock = lockProvider.create("resource", {
ttl: TimeSpan.fromMinutes(1),
});

const hasAcquired = await lock.aquire();
if (hasAcquired) {
try {
while (true) {
await lock.refresh(TimeSpan.fromMinutes(1));
const hasFinished = await doWork();
if (hasFinished) {
break;
}
await LazyPromise.delay(TimeSpan.fromSeconds(1));
}
} finally {
await lock.release();
}
}

Lock owners

Each lock has a unique owner to identify its holder. For example, if User-A owns the lock, User-B cannot acquire or release it. Only the current owner (User-A) can. User-B may acquire the lock only after it is either explicitly released by User-A or has expired:

const lockA = lockProvider.create("resource");
const lockB = lockProvider.create("resource");

const promiseA = (async () => {
const hasAquired = await lockA.acquire();
if (hasAquired) {
console.log("A acquired resource");
// Auto generated
console.log("Owner", await lockA.getOwner());
await LazyPromise.delay(TimeSpan.fromSeconds(2));
await lockA.release();
console.log("A released resource");
} else {
console.log("A failed to acquire resource");
}
})();
const promiseB = (async () => {
const hasAquired = await lockB.acquire();
if (hasAquired) {
console.log("B acquired resource");
// Auto generated
console.log("Owner", await lockB.getOwner());
await LazyPromise.delay(TimeSpan.fromSeconds(2));
await lockB.release();
console.log("B released resource");
} else {
console.log("B failed to acquire resource");
}
})();

// Only one of locks can acquire the resource at a time.
await Promise.all([promiseA, promiseB]);
info

Note the owner name can be manually specified, primarily for debugging or implementing manual resource locking by the end user.

const lockA = lockProvider.create("resource", {
owner: "A",
});
console.log("Owner", await lockA.getOwner());

const lockB = lockProvider.create("resource", {
owner: "B",
});
console.log("Owner", await lockB.getOwner());

Manual end user resource locking is useful in scenarios like a CMS supporting multi-user collaboration, documents should be locked during editing to prevent data corruption. When a user opens a document for editing, the system should automatically set the lock owner name to that user's unique ID. This ensures exclusive access - only the lock owner can modify the document until they release the lock.

warning

In most cases, setting a custom owner is unnecessary. Misusing this feature could result in different locks sharing the same owner while modifying the same resource simultaneously, which may lead to race conditions.

Additional methods

You can get the owner of the lock:

const lock = lockProvider.create("resource");

await lock.getOwner();

The acquireBlockingOrFail method is the same as acquireBlocking method but it throws an error when not enable to acquire the lock:

const lock = lockProvider.create("resource");

await lock.acquireBlockingOrFail({
// You can provide the same configuration as in acquireBlocking method
});

The releaseOrFail method is the same release method but it throws an error when not enable to release the lock:

const lock = lockProvider.create("resource");

await lock.releaseOrFail();

You can force release the lock regardless of its current owner:

const lock = lockProvider.create("resource");

await lock.forceRelease();

The refreshOrFail method is the same refresh method but it throws an error when not enable to refresh the lock:

const lock = lockProvider.create("resource");

await lock.refreshOrFail();

The run method automatically manages lock acquisition and release around function execution. It calls acquire before invoking the function and calls release in a finally block, ensuring the lock is always freed, even if an error occurs during execution.

const lock = lockProvider.create("resource");

await lock.run(async () => {
await doWork();
});
info

Note the method returns a Result type that can be inspected to determine the operation's success or failure.

The runOrFail method automatically manages lock acquisition and release around function execution. It calls acquireOrFail before invoking the function and calls release in a finally block, ensuring the lock is always freed, even if an error occurs during execution.

const lock = lockProvider.create("resource");

await lock.runOrFail(async () => {
await doWork();
});
info

Note the method throws an error when the lock cannot be acquired.

The runBlocking method automatically manages lock acquisition and release around function execution. It calls acquireBlocking before invoking the function and calls release in a finally block, ensuring the lock is always freed, even if an error occurs during execution.

const lock = lockProvider.create("resource");

await lock.runBlocking(
async () => {
await doWork();
},
{
// You can provide the same configuration as in acquireBlocking method
},
);
info

Note the method returns a Result type that can be inspected to determine the operation's success or failure.

The runBlocking method automatically manages lock acquisition and release around function execution. It calls acquireBlockingOrFail before invoking the function and calls release in a finally block, ensuring the lock is always freed, even if an error occurs during execution.

const lock = lockProvider.create("resource");

await lock.runBlockingOrFail(
async () => {
await doWork();
},
{
// You can provide the same configuration as in acquireBlocking method
},
);
info

Note the method throws an error when the lock cannot be acquired.

info

You can provide LazyPromise, synchronous and asynchronous Invokable as default values for run, runOrFail, runBlocking and runBlockingOrFail methods.

Retrying acquiring lock

To retry acquiring lock you can use the retry middleware with LazyPromise.pipe method.

Retrying acquiring lock with aquireOrFail method:

import { retry } from "@daiso-tech/core/async";
import { KeyAlreadyAcquiredLockError } from "@daiso-tech/core/lock/contracts";

const lock = lockProvider.create("lock");

try {
await lock.acquireOrFail().pipe(
retry({
maxAttempts: 4,
errorPolicy: KeyAlreadyAcquiredLockError,
}),
);
// The critical section
} finally {
await lock.release();
}

Retrying acquiring lock with acquire method:

import { retry } from "@daiso-tech/core/async";

const lock = lockProvider.create("lock");

const hasAquired = await lock.acquire().pipe(
retry({
maxAttempts: 4,
errorPolicy: {
treatFalseAsError: true,
},
}),
);
if (hasAquired) {
try {
// The critical section
} finally {
await lock.release();
}
}

Retrying acquiring lock with runOrFail method:

import { retry } from "@daiso-tech/core/async";
import { KeyAlreadyAcquiredLockError } from "@daiso-tech/core/lock/contracts";

const lock = lockProvider.create("lock");

await lock
.runOrFail(async () => {
// The critical section
})
.pipe(
retry({
maxAttempts: 4,
errorPolicy: KeyAlreadyAcquiredLockError,
}),
);

Retrying acquiring lock with run method:

import { retry } from "@daiso-tech/core/async";

const lock = lockProvider.create("lock");

await lock
.run(async () => {
// The critical section
})
.pipe(
retry({
maxAttempts: 4,
errorPolicy: {
treatFalseAsError: true,
},
}),
);

Iterable as key name and owner name

You can use an Iterable<string> as a key. The elements will be joined into a single string, and the delimiter used for joining is configurable in the Namespace class:

const lockProvider = new LockProvider({
namespace: new Namespace("lock"),
// rest of the settings ....
});

const lock = lockProvider.create(["resource", "1"], {
owner: ["user", "1"],
});

Serialization and deserialization of lock

Locks can be serialized, allowing them to be transmitted over the network to another server and later deserialized for reuse. This means you can, for example, acquire the lock on the main server, transfer it to a queue worker server, and release it there.

Manually serializing and deserializing the lock:

import { RedisLockAdapter } from "@daiso-tech/core/lock/adapters";
import { LockProvider } from "@daiso-tech/core/lock";
import { Namespace } from "@daiso-tech/core/utilities";
import { Serde } from "@daiso-tech/core/serde";
import { SuperJsonSerdeAdapter } from "@daiso-tech/core/serde/adapters";

const serde = new Serde(new SuperJsonSerdeAdapter());

const redisClient = new Redis("YOUR_REDIS_CONNECTION");

const lockProvider = new LockProvider({
// You can laso pass in an array of Serde class instances
serde,
namespace: new Namespace("lock"),
adapter: new RedisLockAdapter(redisClient),
});

const lock = lockProvider.create("resource");
const serializedLock = serde.serialize(lock);
const deserializedLock = serde.deserialize(lock);
danger

When serializing or deserializing a lock, you must use the same Serde (Serializer/Deserializer) instances that were provided to the LockProvider. This is required because the LockProvider injects custom serialization logic for ILock instance into Serde instances.

info

Note you only need manuall serialization and deserialization when integrating with external libraries.

As long you pass the same Serde instances with all other components you dont need to serialize and deserialize the lock manually.

import { RedisLockAdapter } from "@daiso-tech/core/lock/adapters";
import type { ILock } from "@daiso-tech/core/lock/contracts";
import { LockProvider } from "@daiso-tech/core/lock";
import { RedisPubSubEventBusAdapter } from "@daiso-tech/core/event-bus/adapters";
import { EventBus } from "@daiso-tech/core/event-bus";
import { Namespace } from "@daiso-tech/core/utilities";
import { Serde } from "@daiso-tech/core/serde";
import { SuperJsonSerdeAdapter } from "@daiso-tech/core/serde/adapters";

const serde = new Serde(new SuperJsonSerdeAdapter());
const mainRedisClient = new Redis("YOUR_REDIS_CONNECTION");
const listenerRedisClient = new Redis("YOUR_REDIS_CONNECTION");

type EventMap = {
"sending-lock-over-network": {
lock: ILock;
};
};
const eventBus = new EventBus<EventMap>({
namespace: new Namespace("event-bus"),
adapter: new RedisPubSubEventBusAdapter({
listenerClient,
dispatcherClient: mainRedisClient,
serde,
}),
});

const lockProvider = new LockProvider({
serde,
namespace: new Namespace("lock"),
adapter: new RedisLockAdapter(mainRedisClient),
eventBus,
});
const lock = lockProvider.create("resource");

// We are sending the lock over the network to other servers.
await eventBus.dispatch("sending-lock-over-network", {
lock,
});

// The other servers will recieve the serialized lock and automattically deserialize it.
await eventBus.addListener("sending-lock-over-network", ({ lock }) => {
// The lock is serialized and can be used
console.log("LOCK:", lock);
});

Lock events

You can listen to different lock events (LockEventMap) that are triggered by the Lock. Refer to the EventBus documentation to learn how to use events.

import { LOCK_EVENTS } from "@daiso-tech/core/lock/contracts";

// Will log whenever an lock is acquiured
await lockProvider.subscribe(LOCK_EVENTS.ACQUIRED, (event) => {
console.log(event);
});

const lock = lockProvider.create("resource");
await lock.acquire();
console.log("");
await lock.release();
info

Note the Lock class uses MemoryEventBusAdapter by default. You can choose what event bus adapter to use:

import { MemoryLockAdapter } from "@daiso-tech/core/lock/adapters";
import { LockProvider } from "@daiso-tech/core/lock";
import { RedisPubSubEventBus } from "@daiso-tech/core/event-bus/adapters";
import { EventBus } from "@daiso-tech/core/event-bus";
import { Namespace } from "@daiso-tech/core/utilities";
import { RedisPubSubEventBusAdapter } from "@daiso-tech/core/event-bus/adapters";
import { Serde } from "@daiso-tech/core/serde";
import { SuperJsonSerdeAdapter } from "@daiso-tech/core/serde/adapters";
import Redis from "ioredis";

const serde = new Serde(new SuperJsonSerdeAdapter());

const redisPubSubEventBusAdapter = new RedisPubSubEventBusAdapter({
dispatcherClient: new Redis("YOUR_REDIS_CONNECTION_STRING"),
listenerClient: new Redis("YOUR_REDIS_CONNECTION_STRING"),
serde,
});

const lock = new LockProvider({
namespace: new Namespace("lock"),
adapter: new MemoryLockAdapter(),
eventBus: new EventBus({
namespace: new Namespace("event-bus"),
adapter: redisPubSubEventBusAdapter,
}),
});
info

Note you can disable dispatching Lock events by passing an EventBus that uses NoOpEventBusAdapter

warning

If multiple lock adapters (e.g., RedisLockAdapter and MemoryLockAdapter) are used at the same time, isolate their events by assigning separate namespaces. This prevents listeners from unintentionally capturing events across adapters.

import { MemoryLockAdapter } from "@daiso-tech/core/cache/adapters";
import { Lock } from "@daiso-tech/core/cache";
import { EventBus } from "@daiso-tech/core/event-bus";
import { Namespace } from "@daiso-tech/core/utilities";
import { RedisPubSubEventBusAdapter } from "@daiso-tech/core/event-bus/adapters";
import { Serde } from "@daiso-tech/core/serde";
import { SuperJsonSerdeAdapter } from "@daiso-tech/core/serde/adapters";
import Redis from "ioredis";

const serde = new Serde(new SuperJsonSerdeAdapter());

const redisPubSubEventBusAdapter = new RedisPubSubEventBusAdapter({
dispatcherClient: new Redis("YOUR_REDIS_CONNECTION_STRING"),
listenerClient: new Redis("YOUR_REDIS_CONNECTION_STRING"),
serde,
});

const memoryLockAdapter = new MemoryLockAdapter();
const memoryLockProvider = new LockProvider({
namespace: new Namespace("cache"),
adapter: memoryLockAdapter,
eventBus: new EventBus({
// We assign distinct namespaces to MemoryLockAdapter and RedisLockAdapter to isolate their events.
namespace: new Namespace(["memory-cache", "event-bus"]),
adapter: redisPubSubEventBusAdapter,
}),
});

const redisLockAdapter = new RedisLockAdapter({
serde,
database: new Redis("YOUR_REDIS_CONNECTION_STRING"),
});
const redisLockProvider = new LockProvider({
namespace: new Namespace("cache"),
adapter: redisLockAdapter,
eventBus: new EventBus({
// We assign distinct namespaces to MemoryLockAdapter and RedisLockAdapter to isolate their events.
namespace: new Namespace(["redis-cache", "event-bus"]),
adapter: redisPubSubEventBusAdapter,
}),
});

Seperating creating locks, listening to locks and manipulating locks

The library includes 3 additional contracts:

This seperation makes it easy to visually distinguish the 3 contracts, making it immediately obvious that they serve different purposes.

import { MemoryLockAdapter } from "@daiso-tech/core/lock/adapters";
import {
type ILock,
type ILockProvider,
type ILockListenable,
LOCK_EVENTS,
} from "@daiso-tech/core/lock/contracts";

async function lockFunc(lock: ILock): Promise<void> {
await lock.run(async () => {
await doWork();
});
}

async function lockProviderFunc(lockProvider: ILockProvider): Promise<void> {
// You cannot access the listener methods
// You will get typescript error if you try

const lock = lockProvider.create("resource");
await lockFunc(lock);
}

async function lockListenableFunc(
lockListenable: ILockListenable,
): Promise<void> {
// You cannot access the lockProvider methods
// You will get typescript error if you try

await lockListenable.addListener(LOCK_EVENTS.ACQUIRED, (event) => {
console.log("ACQUIRED:", event);
});
await lockListenable.addListener(LOCK_EVENTS.RELEASED, (event) => {
console.log("RELEASED:", event);
});
}

await lockListenableFunc(lockProvider);
await lockProviderFunc(lockProvider);