@beignet/core
Version:
Core framework primitives for Beignet
263 lines • 9.43 kB
JavaScript
/**
* @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,
metadata: {
ports: ["locks"],
watchers: ["locks"],
},
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