UNPKG

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.

98 lines (97 loc) 4.22 kB
// SPDX-FileCopyrightText: 2025-present Kriasoft // SPDX-License-Identifier: MIT import { FAILURE_REASON } from "../../common/backend-semantics.js"; import { LockError, validateLockId, } from "../../common/backend.js"; import { TIME_TOLERANCE_MS, isLive } from "../../common/time-predicates.js"; import { checkAborted, mapPostgresError } from "../errors.js"; /** * Creates PostgreSQL release operation with atomic transaction and ownership verification. * * **Implementation Pattern:** * - Atomic transaction: Query by lockId → verify ownership → check liveness → delete * - TOCTOU protection: All steps within single `sql.begin()` transaction * - Ownership verification: Explicit `data.lock_id === opts.lockId` check (ADR-003) * - AbortSignal: Manual cancellation checks via `checkAborted()` at strategic points * * Transaction flow: * 1. Get server time for authoritative liveness check * 2. Query by lockId using index (FOR UPDATE for row-level lock) * 3. Verify ownership (data.lock_id === lockId) * 4. Check liveness using isLive() predicate * 5. If all checks pass, delete the lock * 6. Return simplified result with optional telemetry reason * * @param sql - postgres.js SQL instance * @param config - PostgreSQL backend configuration * @returns Release operation function * * @see docs/specs/interface.md#release-operation-requirements - Normative TOCTOU requirements */ export function createReleaseOperation(sql, config) { return async (opts) => { try { // Pre-transaction abort check checkAborted(opts.signal); // Validate lockId format (22-char base64url) validateLockId(opts.lockId); // Atomic transaction: lookup → verify → delete const result = await sql.begin(async (sql) => { // Check abort signal inside transaction checkAborted(opts.signal); // Get server time (authoritative time source) const timeResult = await sql `SELECT EXTRACT(EPOCH FROM NOW()) * 1000 AS now_ms`; const timeRow = timeResult[0]; if (!timeRow) { throw new LockError("Internal", "Failed to get server time"); } const nowMs = Math.floor(Number(timeRow.now_ms)); // Query by lockId index (FOR UPDATE for row-level lock) const rows = await sql ` SELECT * FROM ${sql(config.tableName)} WHERE lock_id = ${opts.lockId} FOR UPDATE `; // Check if lock exists if (rows.length === 0) { const result = { ok: false }; result[FAILURE_REASON] = { reason: "not-found" }; return result; } const data = rows[0]; if (!data) { const result = { ok: false }; result[FAILURE_REASON] = { reason: "not-found" }; return result; } // Explicit ownership verification (ADR-003: defense-in-depth) if (data.lock_id !== opts.lockId) { const result = { ok: false }; result[FAILURE_REASON] = { reason: "not-found" }; return result; } // Check liveness const expiresAtMs = Number(data.expires_at_ms); if (!isLive(expiresAtMs, nowMs, TIME_TOLERANCE_MS)) { const result = { ok: false }; result[FAILURE_REASON] = { reason: "expired" }; return result; } // Check abort signal before write checkAborted(opts.signal); // All checks passed - delete the lock await sql ` DELETE FROM ${sql(config.tableName)} WHERE key = ${data.key} `; return { ok: true }; }); return result; } catch (error) { if (error instanceof LockError) { throw error; } throw mapPostgresError(error); } }; }