alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
575 lines (526 loc) • 18.5 kB
text/typescript
import {
$atom,
$context,
$inject,
$state,
AlephaError,
type AsyncFn,
createMiddleware,
type Middleware,
Primitive,
type Static,
z,
} from "alepha";
import {
type DateTime,
DateTimeProvider,
type DurationLike,
} from "alepha/datetime";
import { $logger } from "alepha/logger";
import { $topic } from "alepha/topic";
import { LockAcquireError } from "../errors/LockAcquireError.ts";
import { LockProvider } from "../providers/LockProvider.ts";
import { LockTopicProvider } from "../providers/LockTopicProvider.ts";
/**
* Distributed lock middleware for `use` arrays in `$action`, `$job`, `$page`, `$pipeline`.
*
* Acquires a distributed lock before the handler runs and releases it after completion.
* Throws `LockAcquireError` if the lock cannot be acquired (unless `wait: true`).
*
* ```ts
* processOrder = $action({
* use: [$lock({ name: "process-order" })],
* handler: async ({ body }) => { ... },
* });
* ```
*/
export const $lock = (options: LockMiddlewareOptions): Middleware => {
const { alepha } = $context();
const lockProvider = alepha.inject(LockProvider);
const dateTimeProvider = alepha.inject(DateTimeProvider);
return createMiddleware({
name: "$lock",
options: options as unknown as Record<string, unknown>,
handler: ({ alepha, next }) => {
const maxDurationMs = dateTimeProvider
.duration(options.maxDuration ?? [5, "minutes"])
.asMilliseconds();
return async (...args: any[]) => {
// Per-invocation identity. The pipeline composes this middleware once
// and memoizes the wrapped function, so an id created in the handler
// body (outside this closure) would be shared by every concurrent
// invocation — `SET NX GET` would then hand each caller back that same
// shared id, and every one would read `lockId === id` and enter the
// critical section together. Generating it here, per call, is what makes
// mutual exclusion actually hold within a single process.
const id = crypto.randomUUID();
const name =
typeof options.name === "function"
? options.name(...args)
: options.name;
if (!name) {
throw new AlephaError(
"$lock middleware requires a name option (no class context available)",
);
}
const value = await lockProvider.set(
name,
`${id},${dateTimeProvider.nowISOString()}`,
true,
maxDurationMs,
);
const [lockId, _createdAtStr, endedAtStr] = value.split(",");
// Lock already ended (grace period active)
if (endedAtStr) {
await alepha.events.emit("lock:contended", { name, id });
throw new LockAcquireError(name);
}
// Lock held by someone else
if (lockId !== id) {
await alepha.events.emit("lock:contended", { name, id });
if (options.wait) {
// Poll until lock is released
const start = dateTimeProvider.nowMillis();
while (dateTimeProvider.nowMillis() - start < maxDurationMs) {
await dateTimeProvider.wait(500);
const current = await lockProvider.set(
name,
`${id},${dateTimeProvider.nowISOString()}`,
true,
maxDurationMs,
);
const [currentId] = current.split(",");
if (currentId === id) {
break;
}
}
// Check if we got the lock
const final = await lockProvider.set(
name,
`${id},${dateTimeProvider.nowISOString()}`,
true,
maxDurationMs,
);
const [finalId] = final.split(",");
if (finalId !== id) {
throw new LockAcquireError(name);
}
} else {
throw new LockAcquireError(name);
}
}
// We hold the lock — execute handler
const acquiredAt = dateTimeProvider.nowMillis();
await alepha.events.emit("lock:acquired", {
name,
id,
maxDurationMs,
});
try {
return await next(...args);
} finally {
await lockProvider.del(name);
await alepha.events.emit("lock:released", {
name,
id,
heldMs: dateTimeProvider.nowMillis() - acquiredAt,
});
}
};
},
});
};
// ---------------------------------------------------------------------------------------------------------------------
/**
* Options for $lock in middleware mode (no handler).
*/
export interface LockMiddlewareOptions {
/**
* Lock key name. Required in middleware mode (no class context available).
* Can be a static string or a function that derives the key from handler args.
*/
name: string | ((...args: any[]) => string);
/**
* Whether to wait for the lock to become available.
*
* @default false
*/
wait?: boolean;
/**
* Maximum duration the lock can be held before automatic expiration.
*
* @default [5, "minutes"]
*/
maxDuration?: DurationLike;
}
// ---------------------------------------------------------------------------------------------------------------------
export interface LockPrimitiveOptions<TFunc extends AsyncFn> {
/**
* The function to execute when the lock is successfully acquired.
*
* This function:
* - Only executes on the instance that successfully acquires the lock
* - Has exclusive access to the protected resource during execution
* - Should contain the critical section logic that must not run concurrently
* - Can be async and perform any operations needed
* - Will automatically release the lock upon completion or error
* - Has access to the full Alepha dependency injection container
*
* **Handler Design Guidelines**:
* - Keep critical sections as short as possible to minimize lock contention
* - Include proper error handling to ensure locks are released
* - Use timeouts for external operations to prevent deadlocks
* - Log important operations for debugging and monitoring
* - Consider idempotency for handlers that might be retried
*
* @param ...args - The arguments passed to the lock execution
* @returns Promise that resolves when the protected operation is complete
*
* @example
* ```ts
* handler: async (batchId: string) => {
* console.log(`Processing batch ${batchId} - only one instance will run this`);
*
* const batch = await this.getBatchData(batchId);
* const results = await this.processBatchItems(batch.items);
* await this.saveBatchResults(batchId, results);
*
* console.log(`Batch ${batchId} completed successfully`);
* }
* ```
*/
handler: TFunc;
/**
* Whether the lock should wait for other instances to complete before giving up.
*
* **wait = false (default)**:
* - Non-blocking behavior - if lock is held, immediately return without executing
* - Perfect for scheduled tasks where you only want one execution per trigger
* - Use when multiple triggers are acceptable but concurrent execution is not
* - Examples: periodic cleanup, cron jobs, background maintenance
*
* **wait = true**:
* - Blocking behavior - wait for the current lock holder to finish
* - All instances will eventually execute (one after another)
* - Perfect for initialization tasks where all instances need the work completed
* - Examples: database migrations, cache warming, resource initialization
*
* **Trade-offs**:
* - Non-waiting: Better performance, may miss executions if timing is off
* - Waiting: Guaranteed execution order, slower overall throughput
*
* @default false
*
* @example
* ```ts
* // Scheduled task - don't wait, just skip if already running
* scheduledCleanup = $lock({
* wait: false, // Skip if cleanup already running
* handler: async () => { } // perform cleanup
* });
*
* // Migration - wait for completion before proceeding
* migration = $lock({
* wait: true, // All instances wait for migration to complete
* handler: async () => { } // perform migration
* });
* ```
*/
wait?: boolean;
/**
* The unique identifier for the lock.
*
* Can be either:
* - **Static string**: A fixed identifier for the lock
* - **Dynamic function**: A function that generates the lock key based on arguments
*
* **Dynamic Lock Keys**:
* - Enable per-resource locking (e.g., per-user, per-file, per-product)
* - Allow fine-grained concurrency control
* - Prevent unnecessary blocking between unrelated operations
*
* **Key Design Guidelines**:
* - Use descriptive names that indicate the protected resource
* - Include relevant identifiers for dynamic keys
* - Keep keys reasonably short but unique
* - Consider using hierarchical naming (e.g., "service:operation:resource")
*
* If not provided, defaults to `{serviceName}:{propertyKey}`.
*
* @example "user-migration"
* @example "daily-report-generation"
* @example (userId: string) => `user-profile-update:${userId}`
* @example (fileId: string, operation: string) => `file-${operation}:${fileId}`
*
* @example
* ```ts
* // Static lock key - all instances compete for the same lock
* globalCleanup = $lock({
* name: "system-cleanup",
* handler: async () => { } // perform cleanup
* });
*
* // Dynamic lock key - per-user locks, users don't block each other
* updateUserProfile = $lock({
* name: (userId: string) => `user-update:${userId}`,
* handler: async (userId: string, data: UserData) => {
* // Only one update per user at a time, but different users can update concurrently
* }
* });
* ```
*/
name?: string | ((...args: Parameters<TFunc>) => string);
/**
* Maximum duration the lock can be held before it expires automatically.
*
* This prevents deadlocks when a process dies while holding a lock or when
* operations take longer than expected. The lock will be automatically released
* after this duration, allowing other instances to proceed.
*
* **Duration Guidelines**:
* - Set based on expected operation duration plus safety margin
* - Too short: Operations may be interrupted by early expiration
* - Too long: Failed processes block others for extended periods
* - Consider worst-case scenarios and external dependency timeouts
*
* **Typical Values**:
* - Quick operations: 30 seconds - 2 minutes
* - Database operations: 5 - 15 minutes
* - File processing: 10 - 30 minutes
* - Large migrations: 30 minutes - 2 hours
*
* @default [5, "minutes"]
*
* @example [30, "seconds"] // Quick operations
* @example [10, "minutes"] // Database migrations
* @example [1, "hour"] // Long-running batch jobs
*
* @example
* ```ts
* quickTask = $lock({
* maxDuration: [2, "minutes"], // Quick timeout for fast operations
* handler: async () => { } // perform quick task
* });
*
* heavyProcessing = $lock({
* maxDuration: [30, "minutes"], // Longer timeout for heavy work
* handler: async () => { } // perform heavy processing
* });
* ```
*/
maxDuration?: DurationLike;
/**
* Additional time to keep the lock active after the handler completes successfully.
*
* This provides a "cooling off" period that can be useful for:
* - Preventing immediate re-execution of the same operation
* - Giving time for related systems to process the results
* - Avoiding race conditions with dependent operations
* - Providing a buffer for cleanup operations
*
* Can be either:
* - **Static duration**: Fixed grace period for all executions
* - **Dynamic function**: Grace period determined by execution arguments
* - **undefined**: No grace period, lock released immediately after completion
*
* **Grace Period Use Cases**:
* - File processing: Prevent immediate reprocessing of uploaded files
* - Cache updates: Allow time for cache propagation
* - Batch operations: Prevent overlapping batch processing
* - External API calls: Respect rate limiting requirements
*
* @default undefined (no grace period)
*
* @example [5, "minutes"] // Fixed 5-minute grace period
* @example [30, "seconds"] // Short grace for quick operations
* @example (userId: string) => userId.startsWith("premium") ? [10, "minutes"] : [2, "minutes"]
*
* @example
* ```ts
* fileProcessor = $lock({
* gracePeriod: [10, "minutes"], // Prevent reprocessing same file immediately
* handler: async (filePath: string) => {
* await this.processFile(filePath);
* }
* });
*
* userOperation = $lock({
* gracePeriod: (userId: string, operation: string) => {
* // Dynamic grace based on operation type
* return operation === 'migration' ? [30, "minutes"] : [5, "minutes"];
* },
* handler: async (userId: string, operation: string) => {
* await this.performUserOperation(userId, operation);
* }
* });
* ```
*/
gracePeriod?:
| ((...args: Parameters<TFunc>) => DurationLike | undefined)
| DurationLike;
}
// ---------------------------------------------------------------------------------------------------------------------
/**
* Lock configuration atom.
*/
export const lockOptions = $atom({
name: "alepha.lock.options",
schema: z.object({
prefixKey: z.text({
default: "",
description: "Prefix for all lock keys.",
}),
}),
default: {
prefixKey: "",
},
});
export type LockAtomOptions = Static<typeof lockOptions.schema>;
declare module "alepha" {
interface State {
[lockOptions.key]: LockAtomOptions;
}
}
// ---------------------------------------------------------------------------------------------------------------------
export class LockPrimitive<TFunc extends AsyncFn> extends Primitive<
LockPrimitiveOptions<TFunc>
> {
protected readonly log = $logger();
protected readonly provider = $inject(LockProvider);
protected readonly settings = $state(lockOptions);
protected readonly dateTimeProvider = $inject(DateTimeProvider);
/**
* Lazy-initialized UUID to avoid calling crypto.randomUUID() in global scope.
* Cloudflare Workers doesn't allow random value generation during initialization.
*/
protected _id?: string;
protected get id(): string {
if (!this._id) {
this._id = crypto.randomUUID();
}
return this._id;
}
public readonly maxDuration = this.dateTimeProvider.duration(
this.options.maxDuration ?? [5, "minutes"],
);
protected readonly topicLockEnd = $topic({
name: `${this.settings.prefixKey}lock:end`,
provider: LockTopicProvider,
schema: {
payload: z.object({
name: z.text(),
}),
},
});
public async run(...args: Parameters<TFunc>): Promise<void> {
const key = this.key(...args);
const handler = this.options.handler;
const lock = await this.lock(key);
if (lock.endedAt) {
return;
}
if (lock.id !== this.id) {
if (this.options.wait) {
// Poll until the lock is released, then re-attempt
const start = this.dateTimeProvider.nowMillis();
const maxMs = this.maxDuration.as("milliseconds");
let acquired = false;
while (this.dateTimeProvider.nowMillis() - start < maxMs) {
await this.dateTimeProvider.wait(500);
const current = await this.lock(key);
if (current.id === this.id || !current.id || current.endedAt) {
acquired = true;
break;
}
}
if (acquired) {
return this.run(...args);
}
this.log.warn(`Lock wait timeout for '${key}', giving up`);
}
return;
}
this.log.debug(`Lock '${key}' ...`);
try {
await handler(...args);
} finally {
await this.topicLockEnd.publish({
name: key,
});
await this.setGracePeriod(key, lock, ...args);
this.log.debug(`Lock '${key}' OK`);
}
}
/**
* Set the lock for the given key.
*/
protected async lock(key: string): Promise<LockResult> {
const value = await this.provider.set(
key,
`${this.id},${this.dateTimeProvider.nowISOString()}`,
true,
this.maxDuration.as("milliseconds"),
);
return this.parse(value);
}
protected async setGracePeriod(
key: string,
lock: LockResult,
...args: Parameters<TFunc>
): Promise<void> {
const gracePeriod = this.options.gracePeriod
? this.dateTimeProvider.isDurationLike(this.options.gracePeriod)
? this.options.gracePeriod
: this.options.gracePeriod(...args)
: undefined;
if (gracePeriod) {
await this.provider.set(
key,
`${this.id},${lock.createdAt.toISOString()},${this.dateTimeProvider.nowISOString()}`,
false,
this.dateTimeProvider.duration(gracePeriod).as("milliseconds"),
);
} else {
await this.provider.del(key);
}
}
protected async wait(key: string, maxDuration: DurationLike): Promise<void> {
this.log.debug(`Wait for lock '${key}' ...`);
await this.topicLockEnd.wait({
filter: (message) => message.payload.name === key,
timeout: maxDuration,
});
this.log.debug(`Wait for lock '${key}' OK`);
}
protected key(...args: Parameters<TFunc>) {
let base = "";
if (this.options.name) {
if (typeof this.options.name === "string") {
base = this.options.name;
} else {
base = this.options.name(...args);
}
} else {
base = `${this.config.service.name}:${this.config.propertyKey}`;
}
return `${this.settings.prefixKey}${base}`;
}
protected parse(value: string): LockResult {
const [id, createdAtStr, endedAtStr] = value.split(",");
const createdAt = this.dateTimeProvider.of(createdAtStr);
const endedAt = endedAtStr
? this.dateTimeProvider.of(endedAtStr)
: undefined;
return {
id,
createdAt,
endedAt,
};
}
}
export interface LockResult {
id: string;
createdAt: DateTime;
endedAt?: DateTime;
response?: string;
}