@beignet/core
Version:
Core framework primitives for Beignet
486 lines (442 loc) • 12.7 kB
text/typescript
/**
* @beignet/core/locks
*
* Provider-neutral distributed lock and lease primitives for Beignet apps.
*/
import {
createProvider,
createProviderInstrumentation,
} from "../providers/index.js";
type MaybePromise<T> = T | Promise<T>;
/**
* Metadata attached to lock acquisition instrumentation and diagnostics.
*/
export type LeaseMetadata = Record<
string,
string | number | boolean | null | undefined
>;
/**
* Options for acquiring a lease.
*/
export type LeaseAcquireOptions = {
/**
* Lease time-to-live in milliseconds. The provider must treat ownership as
* expired after this duration unless the lease is renewed.
*/
ttlMs: number;
/**
* How long to wait for the lease before returning a non-acquired result.
*
* @default 0
*/
waitMs?: number;
/**
* Delay between acquisition attempts while waiting.
*
* @default 50
*/
retryDelayMs?: number;
/**
* Optional owner token supplied by the caller. Providers generate one when
* omitted. Release and renew operations must only succeed for the current
* owner token.
*/
ownerToken?: string;
/**
* Optional diagnostics metadata. Providers should not use this for lock
* correctness.
*/
metadata?: LeaseMetadata;
};
/**
* Options for renewing a lease.
*/
export type LeaseRenewOptions = {
/**
* New lease time-to-live in milliseconds. Defaults to the original ttlMs
* used to acquire the lease.
*/
ttlMs?: number;
};
/**
* State required to restore a lease handle in another runtime invocation.
*/
export type LeaseRestoreOptions = {
/**
* TTL to use when `renew()` is called without an explicit override.
*/
ttlMs: number;
/**
* Persisted expiry when the caller has it. Omitted values stay unknown.
*/
expiresAt?: Date;
/**
* Persisted fencing token when the caller has it. Omitted values stay
* unknown rather than being fabricated.
*/
fencingToken?: string | number;
};
/**
* Active lease handle returned by a lock provider.
*/
export type LeaseHandle = {
key: string;
ownerToken: string;
expiresAt?: Date;
/**
* Optional monotonically increasing token. Apps can store this with writes to
* reject stale lease owners when the underlying resource supports fencing.
*/
fencingToken?: string | number;
renew(options?: LeaseRenewOptions): Promise<boolean>;
release(): Promise<boolean>;
};
/**
* Lease acquisition result.
*/
export type LeaseAcquireResult =
| { acquired: true; lease: LeaseHandle }
| { acquired: false; reason: "unavailable" | "timeout" };
/**
* App-facing lock port.
*/
export type LocksPort = {
acquire(
key: string,
options: LeaseAcquireOptions,
): Promise<LeaseAcquireResult>;
withLease<T>(
key: string,
options: LeaseAcquireOptions,
fn: (ctx: { lease: LeaseHandle }) => MaybePromise<T>,
): Promise<T | undefined>;
restore(
key: string,
ownerToken: string,
options: LeaseRestoreOptions,
): LeaseHandle;
forceRelease(key: string): Promise<boolean>;
};
/**
* Captured memory lease state exposed for tests.
*/
export type MemoryLeaseRecord = {
key: string;
ownerToken: string;
expiresAt: Date;
ttlMs: number;
fencingToken: number;
metadata?: LeaseMetadata;
};
/**
* In-memory locks port exposed for assertions in tests.
*/
export type MemoryLocksPort = LocksPort & {
leases: Map<string, MemoryLeaseRecord>;
reset(): void;
};
/**
* Options for the in-memory locks adapter.
*/
export type CreateMemoryLocksOptions = {
now?: () => Date;
createOwnerToken?: () => string;
sleep?: (ms: number) => Promise<void>;
};
/**
* Options for the in-memory locks provider.
*/
export type MemoryLocksProviderOptions = CreateMemoryLocksOptions & {
name?: string;
};
/**
* Ports contributed by the memory locks provider.
*/
export interface MemoryLocksProviderPorts {
locks: LocksPort;
}
/**
* Error thrown when a lease option is invalid.
*/
export class LeaseOptionsError extends Error {
constructor(message: string) {
super(message);
this.name = "LeaseOptionsError";
}
}
/**
* Create an in-memory locks port for tests and single-process development.
*/
export function createMemoryLocks(
options: CreateMemoryLocksOptions = {},
): MemoryLocksPort {
const leases = new Map<string, MemoryLeaseRecord>();
const now = options.now ?? (() => new Date());
const sleep = options.sleep ?? defaultSleep;
const createOwnerToken = options.createOwnerToken ?? createRandomToken;
let nextFencingToken = 1;
const port: MemoryLocksPort = {
leases,
async acquire(key, acquireOptions) {
validateAcquireOptions(key, acquireOptions);
const waitMs = acquireOptions.waitMs ?? 0;
const retryDelayMs = acquireOptions.retryDelayMs ?? 50;
const deadline = now().getTime() + waitMs;
const ownerToken = acquireOptions.ownerToken ?? createOwnerToken();
while (true) {
pruneExpired(key, leases, now);
if (!leases.has(key)) {
const record: MemoryLeaseRecord = {
key,
ownerToken,
expiresAt: new Date(now().getTime() + acquireOptions.ttlMs),
ttlMs: acquireOptions.ttlMs,
fencingToken: nextFencingToken++,
metadata: acquireOptions.metadata,
};
leases.set(key, record);
return {
acquired: true,
lease: createMemoryLease(port, record, now),
};
}
if (waitMs <= 0) {
return { acquired: false, reason: "unavailable" };
}
const remainingMs = deadline - now().getTime();
if (remainingMs <= 0) {
pruneExpired(key, leases, now);
if (!leases.has(key)) continue;
return { acquired: false, reason: "timeout" };
}
await sleep(Math.min(retryDelayMs, remainingMs));
}
},
async withLease(key, acquireOptions, fn) {
const result = await port.acquire(key, acquireOptions);
if (!result.acquired) return undefined;
try {
return await fn({ lease: result.lease });
} finally {
await result.lease.release();
}
},
restore(key, ownerToken, restoreOptions) {
if (!key) throw new LeaseOptionsError("Lease key is required.");
if (!ownerToken) {
throw new LeaseOptionsError("Lease owner token is required.");
}
validateRestoreOptions(restoreOptions);
return createMemoryLease(
port,
{
key,
ownerToken,
ttlMs: restoreOptions.ttlMs,
expiresAt: restoreOptions.expiresAt,
fencingToken: restoreOptions.fencingToken,
},
now,
);
},
async forceRelease(key) {
pruneExpired(key, leases, now);
return leases.delete(key);
},
reset() {
leases.clear();
nextFencingToken = 1;
},
};
return port;
}
/**
* Create a provider that contributes an in-memory locks port.
*/
export function createMemoryLocksProvider(
options: MemoryLocksProviderOptions = {},
) {
const { name = "memory-locks", ...locksOptions } = options;
return createProvider({
name,
setup({ ports }) {
const instrumentation = createProviderInstrumentation(ports, {
providerName: name,
watcher: "locks",
});
const locks = createMemoryLocks(locksOptions);
return {
ports: {
locks: instrumentLocks(locks, instrumentation),
} satisfies MemoryLocksProviderPorts,
};
},
});
}
/**
* Convenience helper for any lock port.
*/
export function acquireLease(
locks: LocksPort,
key: string,
options: LeaseAcquireOptions,
): Promise<LeaseAcquireResult> {
return locks.acquire(key, options);
}
/**
* Convenience helper for any lock port.
*/
export function withLease<T>(
locks: LocksPort,
key: string,
options: LeaseAcquireOptions,
fn: (ctx: { lease: LeaseHandle }) => MaybePromise<T>,
): Promise<T | undefined> {
return locks.withLease(key, options, fn);
}
function createMemoryLease(
port: MemoryLocksPort,
record: {
key: string;
ownerToken: string;
ttlMs: number;
expiresAt?: Date;
fencingToken?: string | number;
},
now: () => Date,
): LeaseHandle {
const lease: LeaseHandle = {
key: record.key,
ownerToken: record.ownerToken,
...(record.expiresAt ? { expiresAt: record.expiresAt } : {}),
...(record.fencingToken !== undefined
? { fencingToken: record.fencingToken }
: {}),
async renew(options) {
const active = port.leases.get(record.key);
if (!active || active.ownerToken !== record.ownerToken) {
return false;
}
if (active.expiresAt.getTime() <= now().getTime()) {
port.leases.delete(record.key);
return false;
}
const ttlMs = options?.ttlMs ?? record.ttlMs;
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
throw new LeaseOptionsError("Lease ttlMs must be a positive number.");
}
active.ttlMs = ttlMs;
active.expiresAt = new Date(now().getTime() + ttlMs);
record.ttlMs = ttlMs;
lease.expiresAt = active.expiresAt;
return true;
},
async release() {
const active = port.leases.get(record.key);
if (!active || active.ownerToken !== record.ownerToken) {
return false;
}
if (active.expiresAt.getTime() <= now().getTime()) {
port.leases.delete(record.key);
return false;
}
return port.leases.delete(record.key);
},
};
return lease;
}
function validateRestoreOptions(options: LeaseRestoreOptions): void {
if (!options || !Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
throw new LeaseOptionsError(
"Lease restore ttlMs must be a positive number.",
);
}
if (
options.expiresAt !== undefined &&
(!(options.expiresAt instanceof Date) ||
!Number.isFinite(options.expiresAt.getTime()))
) {
throw new LeaseOptionsError(
"Lease restore expiresAt must be a valid Date.",
);
}
}
function instrumentLocks(
locks: LocksPort,
instrumentation: ReturnType<typeof createProviderInstrumentation>,
): LocksPort {
const acquire: LocksPort["acquire"] = async (key, options) => {
const startedAt = Date.now();
const result = await locks.acquire(key, options);
instrumentation.custom({
name: "locks.acquire",
label: "Lock acquire",
summary: result.acquired ? "Lease acquired" : "Lease not acquired",
details: {
key,
acquired: result.acquired,
reason: result.acquired ? undefined : result.reason,
ttlMs: options.ttlMs,
waitMs: options.waitMs ?? 0,
durationMs: Date.now() - startedAt,
},
});
return result;
};
return {
acquire,
async withLease(key, options, fn) {
const result = await acquire(key, options);
if (!result.acquired) return undefined;
try {
return await fn({ lease: result.lease });
} finally {
await result.lease.release();
}
},
restore(key, ownerToken, options) {
return locks.restore(key, ownerToken, options);
},
async forceRelease(key) {
const released = await locks.forceRelease(key);
instrumentation.custom({
name: "locks.forceRelease",
label: "Lock force release",
summary: released ? "Lease force released" : "Lease not found",
details: { key, released },
});
return released;
},
};
}
function validateAcquireOptions(key: string, options: LeaseAcquireOptions) {
if (!key) throw new LeaseOptionsError("Lease key is required.");
if (!Number.isFinite(options.ttlMs) || options.ttlMs <= 0) {
throw new LeaseOptionsError("Lease ttlMs must be a positive number.");
}
if (options.waitMs !== undefined && options.waitMs < 0) {
throw new LeaseOptionsError("Lease waitMs must be zero or greater.");
}
if (options.retryDelayMs !== undefined && options.retryDelayMs <= 0) {
throw new LeaseOptionsError("Lease retryDelayMs must be positive.");
}
}
function pruneExpired(
key: string,
leases: Map<string, MemoryLeaseRecord>,
now: () => Date,
) {
const active = leases.get(key);
if (active && active.expiresAt.getTime() <= now().getTime()) {
leases.delete(key);
}
}
function createRandomToken(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}