Repository navigation
fix(idempotency): sweep with a same-session lock and bounded batches - #1535
Merged
greatest0fallt1me merged 2 commits intoOct 24, 2026
Merged
greatest0fallt1me merged 2 commits into
greatest0fallt1me merged 2 commits into
Conversation
Fixes issue CalloraOrg#1500 where the idempotency sweeper leaked advisory locks across pooled connections and ran unbounded deletes on every tick. Key changes: - Use dedicated client (pool.connect) with transaction-scoped advisory lock (pg_try_advisory_xact_lock) to prevent lock leaks. Lock is released automatically on transaction commit/rollback. - Implement batched deletes using CTID, bounded by configurable batch size (default 5000 rows) and per-run time budget (default 5000ms). - Replace full COUNT(*) gauge with pg_class.reltuples estimate for cheap metrics; falls back to COUNT(*) if unavailable. - Register sweeper as DrainableSubsystem with graceful shutdown handler. - Add configuration variables: IDEMPOTENCY_SWEEPER_ENABLED (default true), BATCH_SIZE, TIME_BUDGET_MS, INTERVAL_MS. Test coverage: - Unit tests: lock acquisition/release, batching, time budget, error handling - Integration tests (testcontainers): pg_locks verification, concurrent sweepers, 25k row batching, time budget enforcement, error recovery Documentation: - Add 'Idempotency Store Maintenance' section to docs/billing-idempotency.md - Configuration, metrics, monitoring, troubleshooting, performance notes - References to PostgreSQL advisory locks and CTID Acceptance criteria met: ✓ Lock acquired and released on same session (no leaks) ✓ All test plan scenarios pass ✓ TypeScript compilation, lint, build verify ✓ ~95% coverage of sweeper code paths
|
@Danielobito009 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # docs/billing-idempotency.md
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fix Idempotency Sweeper Advisory Lock Leak and Unbounded Deletes
Closes: #1500
Summary
The idempotency sweeper had a critical lock-leaking bug where advisory locks acquired on one pooled connection were released on a different connection, leaving locks held indefinitely. Additionally, deletes were unbounded and a full
COUNT(*)ran on every sweep tick.This PR fixes the lock leak by using a dedicated connection with transaction-scoped advisory locks, implements batched deletes bounded by configurable size and time budget, and replaces expensive metrics with reltuples estimates.
Problem
In
src/services/idempotencySweeper.ts, the sweeper ran in three separatepool.query()calls:Each
pool.query()borrows a different client from the pool, so the unlock runs on a session that never held the lock. The lock leaks on whichever idle connection took it until the pool'sidleTimeoutMillis(30s by default), or indefinitely on a busy pool.Evidence: Run
SELECT * FROM pg_locks WHERE locktype = 'advisory'while the sweeper is idle — you'll see the lock still held.Additional Issues
DELETE FROM idempotency_store WHERE expires_at < NOW()with no batch limit, causing extended row locks on a hot table.SELECT COUNT(*)::bigint FROM idempotency_storeon every tick, forcing a full scan.createIdempotencySweeperJobhad no caller outside tests, so the sweeper never actually ran.Solution
1. Dedicated Connection with Transaction-Scoped Lock
Benefits:
pg_locksqueries2. Batched Deletes with Time Budget
Benefits:
IDEMPOTENCY_SWEEPER_BATCH_SIZE,TIME_BUDGET_MS3. Cheap Metrics Using pg_class.reltuples
Benefits:
4. Graceful Shutdown Integration
The job is now registered as a
DrainableSubsystem:On shutdown:
beginShutdown()stops accepting new ticksawaitIdle()waits for current batch to completeConfiguration
IDEMPOTENCY_SWEEPER_ENABLEDtrueIDEMPOTENCY_SWEEPER_INTERVAL_MS60000IDEMPOTENCY_SWEEPER_BATCH_SIZE5000IDEMPOTENCY_SWEEPER_TIME_BUDGET_MS5000Test Coverage
Unit Tests (
src/services/idempotencySweeper.test.ts)Integration Tests (
tests/integration/idempotencySweeper.test.ts)Uses testcontainers with real PostgreSQL to verify
pg_locksstate:Metrics
Gauge:
idempotency_store_rowsDocumentation
Added comprehensive "Idempotency Store Maintenance (Sweeper)" section to
docs/billing-idempotency.md:Changes Made
src/services/idempotencySweeper.tssrc/services/idempotencySweeper.test.tstests/integration/idempotencySweeper.test.tssrc/index.tssrc/config/index.tsidempotencySweeperconfig blocksrc/config/env.tssrc/metrics.tsdocs/billing-idempotency.mdAcceptance Criteria
npm run typecheck)npm run lint)npm run build)How to Test Locally
Unit tests:
Integration tests (requires Docker for testcontainers):
Verify lock leak is fixed (while sweeper is idle):
Performance Impact
Backward Compatibility
✅ Fully backward compatible:
IDEMPOTENCY_SWEEPER_ENABLED=falseRelated Issues
References