Skip to content
6 changes: 3 additions & 3 deletions .codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
codecov:
require_ci_to_pass: yes
notify:
# Wait for all 6 coverage uploads before reporting:
# 2 unit (latest + canary) + 3 contracts (redis, postgres, firestore) + 1 e2e
after_n_builds: 6
# Wait for all 5 coverage uploads before reporting:
# 1 unit + 3 contracts (redis, postgres, firestore) + 1 e2e
after_n_builds: 5

coverage:
precision: 2
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
permissions:
contents: read
packages: read
id-token: write

jobs:
unit:
Expand Down Expand Up @@ -35,11 +36,12 @@ jobs:
- run: bun run test:unit:coverage

- name: Upload coverage to Codecov
if: matrix.bun-version == 'latest'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: unit
use_oidc: true
fail_ci_if_error: false

lint:
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
permissions:
contents: read
packages: read
id-token: write

jobs:
contracts:
Expand Down Expand Up @@ -90,9 +91,9 @@ jobs:
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: contracts-${{ matrix.backend }}
use_oidc: true
fail_ci_if_error: false

- name: Kill Firestore emulator
Expand Down Expand Up @@ -161,16 +162,18 @@ jobs:
run: |
for i in {1..30}; do nc -z 127.0.0.1 6379 && break || sleep 1; done
for i in {1..60}; do nc -z 127.0.0.1 5432 && break || sleep 1; done
# Wait for Firestore emulator port, then additional warmup for full readiness
for i in {1..60}; do nc -z 127.0.0.1 8080 && break || sleep 1; done
sleep 5

- run: bun run test:e2e:coverage

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: e2e
use_oidc: true
fail_ci_if_error: false

- name: Kill Firestore emulator
Expand Down
2 changes: 0 additions & 2 deletions common/auto-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,8 +232,6 @@ export async function lock<T, C extends BackendCapabilities>(
// Execute user function, auto-release in finally block
try {
return await fn();
} catch (error) {
throw error; // Re-throw after release in finally
} finally {
// Best-effort release: don't throw, lock expires via TTL
try {
Expand Down
51 changes: 42 additions & 9 deletions common/disposable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ export function createDisposableHandle<C extends BackendCapabilities>(
type State = "active" | "disposing" | "disposed";
let state: State = "active";
let disposePromise: Promise<void> | null = null;
// Track in-flight release() so dispose() can wait for it (fixes race condition
// where dispose() was called while release() awaited backend, returning null)
let pendingRelease: Promise<void> | null = null;

const handle: DisposableLockHandle = {
async release(signal?: AbortSignal): Promise<ReleaseResult> {
Expand All @@ -241,20 +244,33 @@ export function createDisposableHandle<C extends BackendCapabilities>(
// even if the call throws (network error, timeout, etc.)
state = "disposing";

// Store promise BEFORE await so dispose() can wait for it if called concurrently.
// Wrap in try/catch to handle synchronous throws (e.g., validation failures).
let releaseOp: Promise<ReleaseResult>;
try {
// Throw on errors for consistency with backend API
// Only disposal swallows errors (see asyncDispose below)
const releaseResult = await backend.release({
releaseOp = backend.release({
lockId: result.lockId,
signal,
});
state = "disposed";
return releaseResult;
} catch (error) {
// Release failed - mark as disposed anyway (at-most-once semantics)
// Synchronous throw - mark disposed to maintain at-most-once semantics
state = "disposed";
throw error;
}

pendingRelease = releaseOp.then(
() => {
state = "disposed";
},
() => {
state = "disposed";
},
);

// Throw on errors for consistency with backend API
// Only disposal swallows errors (see asyncDispose below)
// State is set to "disposed" by pendingRelease handler on both success/failure
return releaseOp;
},

async extend(ttlMs: number, signal?: AbortSignal): Promise<ExtendResult> {
Expand All @@ -269,8 +285,14 @@ export function createDisposableHandle<C extends BackendCapabilities>(
return;
}

// Re-entry during disposal: return same promise (idempotent)
// Re-entry during disposal: wait for in-flight operation
if (state === "disposing") {
// If release() initiated disposal, wait for it (pendingRelease is set)
// If dispose() initiated, wait for disposePromise
if (pendingRelease) {
await pendingRelease;
return;
}
return disposePromise!;
}

Expand Down Expand Up @@ -493,7 +515,18 @@ export async function acquireHandle<C extends BackendCapabilities>(
return result;
}

// Result is already decorated by backend, just return it
// Type assertion safe here since we checked ok: true
// Validate backend returned decorated result (catches misconfigured mocks)
const lock = result as AsyncLock<C>;
if (
typeof lock.release !== "function" ||
typeof lock.extend !== "function" ||
typeof lock[Symbol.asyncDispose] !== "function"
) {
throw new Error(
"Backend.acquire() must return a decorated result. " +
"Use decorateAcquireResult() or implement LockBackend correctly.",
);
}

return result as AsyncLock<C>;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "syncguard",
"version": "2.5.1",
"version": "2.5.2",
"description": "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.",
"keywords": [
"atomic",
Expand Down
110 changes: 1 addition & 109 deletions test/e2e/cross-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import {
expect,
it,
} from "bun:test";
import { makeStorageKey } from "../../common/crypto.js";
import type { LockBackend } from "../../common/types.js";
import { getAvailableBackends } from "../fixtures/backends.js";

Expand Down Expand Up @@ -144,7 +143,7 @@ describe("E2E: Cross-Backend Consistency", async () => {
expect(isLocked).toBe(false);
});

it("should handle lookup consistently within tolerance window", async () => {
it("should return lock info for active lock", async () => {
const key = "lookup:consistency:test";

// Acquire lock
Expand Down Expand Up @@ -233,111 +232,4 @@ describe("E2E: Cross-Backend Consistency", async () => {
});
});
}

describe("Storage Key Consistency", () => {
it("should produce identical base storage keys for same user key", () => {
const userKey = "resource:payment:12345";

// Redis-style computation
const redisReserve = 25; // "id:" + 22-char lockId
const redisBaseKey = makeStorageKey("test:", userKey, 1000, redisReserve);

// Firestore-style computation
const firestoreReserve = 0; // No derived keys
const firestoreBaseKey = makeStorageKey(
"",
userKey,
1500,
firestoreReserve,
);

// Both should preserve user key when no truncation needed
expect(redisBaseKey).toContain(userKey);
expect(firestoreBaseKey).toBe(userKey);

// Verify both use makeStorageKey() for consistent hashing
const longKey = "x".repeat(600);
const redisLongKey = makeStorageKey("test:", longKey, 1000, redisReserve);
const firestoreLongKey = makeStorageKey(
"",
longKey,
1500,
firestoreReserve,
);

// When truncation occurs, both should use same hash algorithm
expect(redisLongKey.length).toBeLessThanOrEqual(1000);
expect(firestoreLongKey.length).toBeLessThanOrEqual(1500);
});

it("should derive fence keys from same base storage key (ADR-006 two-step pattern)", () => {
const userKey = "resource:critical:operation";

// Redis: two-step derivation
const redisReserve = 25;
// Step 1: Compute base storage key
const redisBaseKey = makeStorageKey("test:", userKey, 1000, redisReserve);
// Step 2: Derive fence key from base
const redisFenceKey = makeStorageKey(
"test:",
`fence:${redisBaseKey}`,
1000,
redisReserve,
);

// Firestore: two-step derivation
const firestoreReserve = 0;
// Step 1: Compute base storage key
const firestoreBaseKey = makeStorageKey(
"",
userKey,
1500,
firestoreReserve,
);
// Step 2: Derive fence document ID from base
const firestoreFenceDocId = makeStorageKey(
"",
`fence:${firestoreBaseKey}`,
1500,
firestoreReserve,
);

// Verify both backends use two-step pattern
expect(redisFenceKey).toContain("fence:");
expect(firestoreFenceDocId).toContain("fence:");

// Test with long keys
const longKey = "x".repeat(2000);
const redisBaseLong = makeStorageKey(
"test:",
longKey,
1000,
redisReserve,
);
const redisFenceLong = makeStorageKey(
"test:",
`fence:${redisBaseLong}`,
1000,
redisReserve,
);
const firestoreBaseLong = makeStorageKey(
"",
longKey,
1500,
firestoreReserve,
);
const firestoreFenceLong = makeStorageKey(
"",
`fence:${firestoreBaseLong}`,
1500,
firestoreReserve,
);

// Both backends ensure 1:1 mapping
expect(redisBaseLong.length).toBeLessThanOrEqual(1000);
expect(redisFenceLong.length).toBeLessThanOrEqual(1000);
expect(firestoreBaseLong.length).toBeLessThanOrEqual(1500);
expect(firestoreFenceLong.length).toBeLessThanOrEqual(1500);
});
});
});
Loading