syncguard
Version:
Functional TypeScript library for distributed locking across microservices. Prevents race conditions with Redis, PostgreSQL, Firestore, and custom backends. Features automatic lock management, timeout handling, and extensible architecture.
73 lines (72 loc) • 3.24 kB
JavaScript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import { LockError, makeStorageKey, validateLockId, } from "../../common/backend.js";
import { FAILURE_REASON, } from "../../common/backend-semantics.js";
import { TIME_TOLERANCE_MS } from "../../common/time-predicates.js";
import { checkAborted, mapRedisError } from "../errors.js";
import { EXTEND_SCRIPT } from "../scripts.js";
/**
* Creates extend operation that atomically renews lock TTL (replaces entirely, not additive).
* @see docs/specs/redis-backend.md
*/
export function createExtendOperation(redis, config) {
return async (opts) => {
try {
// Pre-dispatch abort check (ioredis does not accept AbortSignal)
checkAborted(opts.signal);
validateLockId(opts.lockId);
if (!Number.isInteger(opts.ttlMs) || opts.ttlMs <= 0) {
throw new LockError("InvalidArgument", "ttlMs must be a positive integer");
}
const REDIS_LIMIT_BYTES = 1000;
const RESERVE_BYTES = 26; // ":id:" (4 bytes) + 22-char lockId
const lockIdKey = makeStorageKey(config.keyPrefix, `id:${opts.lockId}`, REDIS_LIMIT_BYTES, RESERVE_BYTES);
// Prefer cached script (extendLock) over eval for performance (ADR-013)
const scriptResult = redis.extendLock
? await redis.extendLock(lockIdKey, opts.lockId, TIME_TOLERANCE_MS.toString(), opts.ttlMs.toString())
: (await redis.eval(EXTEND_SCRIPT, 1, lockIdKey, opts.lockId, TIME_TOLERANCE_MS.toString(), opts.ttlMs.toString()));
// Script returns: [1, expiresAtMs] on success, 0 on failure
if (Array.isArray(scriptResult)) {
const [status, expiresAtMs] = scriptResult;
if (status === 1) {
if (typeof expiresAtMs !== "number") {
throw new LockError("Internal", `Malformed script result: missing expiresAtMs`);
}
return {
ok: true,
expiresAtMs,
};
}
// Failure: attach telemetry metadata (0=mismatch, -1=not found, -2=expired)
const result = { ok: false };
let reason;
if (status === -2) {
reason = "expired";
}
else {
reason = "not-found"; // -1 or 0 → "not-found"
}
result[FAILURE_REASON] = { reason };
return result;
}
else if (scriptResult === 1) {
// Test mock: success without expiresAtMs
return { ok: true, expiresAtMs: Date.now() + opts.ttlMs };
}
else {
// Test mock: failure
const result = { ok: false };
result[FAILURE_REASON] = {
reason: "not-found",
};
return result;
}
}
catch (error) {
if (error instanceof LockError) {
throw error;
}
throw mapRedisError(error);
}
};
}