UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

264 lines (263 loc) 8.46 kB
import { $atom, $context, $inject, $module, $state, AlephaError, Primitive, createMiddleware, z } from "alepha"; import { $topic, MemoryTopicProvider, TopicProvider } from "alepha/topic"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; //#region ../../src/lock/core/providers/LockProvider.ts /** * Store Provider Interface */ var LockProvider = class {}; //#endregion //#region ../../src/lock/core/providers/LockTopicProvider.ts var LockTopicProvider = class extends TopicProvider {}; //#endregion //#region ../../src/lock/core/providers/MemoryLockProvider.ts /** * A simple in-memory store provider. */ var MemoryLockProvider = class { dateTimeProvider = $inject(DateTimeProvider); log = $logger(); /** * The in-memory store. */ store = {}; /** * Timeouts used to expire keys. */ storeTimeout = {}; async set(key, value, nx, px) { if (nx && this.store[key] != null) return this.store[key]; if (px) this.ttl(key, px); this.store[key] = value; return this.store[key]; } async del(...keys) { for (const key of keys) { delete this.store[key]; if (this.storeTimeout[key] != null) { this.storeTimeout[key].clear(); delete this.storeTimeout[key]; } } } ttl(key, ms) { if (this.storeTimeout[key] != null) { this.storeTimeout[key].clear(); delete this.storeTimeout[key]; } this.storeTimeout[key] = this.dateTimeProvider.createTimeout(() => { delete this.store[key]; delete this.storeTimeout[key]; }, ms); } }; //#endregion //#region ../../src/lock/core/errors/LockAcquireError.ts var LockAcquireError = class extends AlephaError { constructor(name) { super(`$lock: could not acquire lock '${name}'`); } }; //#endregion //#region ../../src/lock/core/primitives/$lock.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 }) => { ... }, * }); * ``` */ const $lock = (options) => { const { alepha } = $context(); const lockProvider = alepha.inject(LockProvider); const dateTimeProvider = alepha.inject(DateTimeProvider); return createMiddleware({ name: "$lock", options, handler: ({ alepha, next }) => { const maxDurationMs = dateTimeProvider.duration(options.maxDuration ?? [5, "minutes"]).asMilliseconds(); return async (...args) => { 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 [lockId, _createdAtStr, endedAtStr] = (await lockProvider.set(name, `${id},${dateTimeProvider.nowISOString()}`, true, maxDurationMs)).split(","); if (endedAtStr) { await alepha.events.emit("lock:contended", { name, id }); throw new LockAcquireError(name); } if (lockId !== id) { await alepha.events.emit("lock:contended", { name, id }); if (options.wait) { const start = dateTimeProvider.nowMillis(); while (dateTimeProvider.nowMillis() - start < maxDurationMs) { await dateTimeProvider.wait(500); const [currentId] = (await lockProvider.set(name, `${id},${dateTimeProvider.nowISOString()}`, true, maxDurationMs)).split(","); if (currentId === id) break; } const [finalId] = (await lockProvider.set(name, `${id},${dateTimeProvider.nowISOString()}`, true, maxDurationMs)).split(","); if (finalId !== id) throw new LockAcquireError(name); } else throw new LockAcquireError(name); } 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 }); } }; } }); }; /** * Lock configuration atom. */ const lockOptions = $atom({ name: "alepha.lock.options", schema: z.object({ prefixKey: z.text({ default: "", description: "Prefix for all lock keys." }) }), default: { prefixKey: "" } }); var LockPrimitive = class extends Primitive { log = $logger(); provider = $inject(LockProvider); settings = $state(lockOptions); dateTimeProvider = $inject(DateTimeProvider); /** * Lazy-initialized UUID to avoid calling crypto.randomUUID() in global scope. * Cloudflare Workers doesn't allow random value generation during initialization. */ _id; get id() { if (!this._id) this._id = crypto.randomUUID(); return this._id; } maxDuration = this.dateTimeProvider.duration(this.options.maxDuration ?? [5, "minutes"]); topicLockEnd = $topic({ name: `${this.settings.prefixKey}lock:end`, provider: LockTopicProvider, schema: { payload: z.object({ name: z.text() }) } }); async run(...args) { 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) { 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. */ async lock(key) { const value = await this.provider.set(key, `${this.id},${this.dateTimeProvider.nowISOString()}`, true, this.maxDuration.as("milliseconds")); return this.parse(value); } async setGracePeriod(key, lock, ...args) { const gracePeriod = this.options.gracePeriod ? this.dateTimeProvider.isDurationLike(this.options.gracePeriod) ? this.options.gracePeriod : this.options.gracePeriod(...args) : void 0; 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); } async wait(key, maxDuration) { 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`); } key(...args) { 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}`; } parse(value) { const [id, createdAtStr, endedAtStr] = value.split(","); return { id, createdAt: this.dateTimeProvider.of(createdAtStr), endedAt: endedAtStr ? this.dateTimeProvider.of(endedAtStr) : void 0 }; } }; //#endregion //#region ../../src/lock/core/index.ts /** * Resource locking for distributed systems. * * **Features:** * - Distributed locks with timeout * - Time-based lock expiration * - Automatic release on scope exit * - Distributed coordination via Redis * - Providers: Memory (dev), Redis (production) * * @module alepha.lock */ const AlephaLock = $module({ name: "alepha.lock", services: [LockProvider, LockTopicProvider], variants: [MemoryLockProvider], register: (alepha) => alepha.with({ optional: true, provide: LockTopicProvider, use: MemoryTopicProvider }).with({ optional: true, provide: LockProvider, use: MemoryLockProvider }) }); //#endregion export { $lock, AlephaLock, LockAcquireError, LockPrimitive, LockProvider, LockTopicProvider, MemoryLockProvider, lockOptions }; //# sourceMappingURL=index.js.map