alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
264 lines (263 loc) • 8.46 kB
JavaScript
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