UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

259 lines 9.33 kB
/** * @beignet/core/locks * * Provider-neutral distributed lock and lease primitives for Beignet apps. */ import { createProvider, createProviderInstrumentation, } from "../providers/index.js"; /** * Error thrown when a lease option is invalid. */ export class LeaseOptionsError extends Error { constructor(message) { super(message); this.name = "LeaseOptionsError"; } } /** * Create an in-memory locks port for tests and single-process development. */ export function createMemoryLocks(options = {}) { const leases = new Map(); const now = options.now ?? (() => new Date()); const sleep = options.sleep ?? defaultSleep; const createOwnerToken = options.createOwnerToken ?? createRandomToken; let nextFencingToken = 1; const port = { 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 = { 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 = {}) { 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), }, }; }, }); } /** * Convenience helper for any lock port. */ export function acquireLease(locks, key, options) { return locks.acquire(key, options); } /** * Convenience helper for any lock port. */ export function withLease(locks, key, options, fn) { return locks.withLease(key, options, fn); } function createMemoryLease(port, record, now) { const lease = { 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) { 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, instrumentation) { const 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, options) { 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, leases, now) { const active = leases.get(key); if (active && active.expiresAt.getTime() <= now().getTime()) { leases.delete(key); } } function createRandomToken() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } function defaultSleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } //# sourceMappingURL=index.js.map