Repository navigation
fix(server): advisory lock released on the request context strands a task type in permanent already_running - #373
Conversation
ReleaseAdvisoryLock ran the unlock query on the caller's own context.
A long-running scheduled task whose invocation context (Lambda
deadline, or the HTTP request context) expires while dispatchTask is
still in flight makes the deferred release run against an already-dead
context: the unlock query fails immediately, is only logged as a
warning, and the otherwise-healthy pinned connection still goes back
to the pool via conn.Release() with the session-level advisory lock
still held. Every subsequent TryAdvisoryLock for that task type then
sees the lock held and returns {"status":"skipped","reason":
"already_running"} (HTTP 200) until that specific pooled connection
happens to be recycled, silently stalling the task for as long as the
connection stays warm.
Two changes, both from the issue's fix direction:
1. Detach the unlock query onto a fresh background context with a 5s
timeout, matching the pattern the scheduler's own deferred
marker-clear already uses for the identical reason.
2. When the unlock query itself errors, close the underlying
*pgx.Conn before the deferred Release() runs. Conn.Release()
already destroys (rather than pools) a connection it finds closed,
so a connection that might still hold the lock can never be
returned to the pool, regardless of what specifically caused the
unlock query to fail.
Regression test: internal/database/advisory_lock_integration_test.go
(build tag `integration`) acquires the lock, calls ReleaseAdvisoryLock
with an already-canceled context, then verifies from a second,
independent raw connection that the lock is free -- not by re-asking
the same pool, since pg_try_advisory_lock is reentrant per session and
could spuriously pass by reacquiring the exact connection that never
released it. Docker is unavailable in this environment (noted in the
project's local-verification constraints), so this could not be run
locally; it is gated behind the same testcontainers-backed Integration
Tests CI job every other DB-container test in this repo relies on.
Closes #105
Co-Authored-By: claude-flow <ruv@ruv.net>
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml Review profile: CHILL Plan: Essentials Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour. 📝 WalkthroughWalkthrough
ChangesAdvisory lock release
Priority: ➖ Normal Estimated code review effort: 2 (Simple) | ~10 minutes Change: Bug fix · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to The lock cleanup change is mergeable after normal checks; the integration test has not been run in the supplied evidence. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
CI's Security Scanning job flagged the unhandled error from conn.Conn().Close(releaseCtx) added in the ReleaseAdvisoryLock fix (G104, CWE-703). Log it at warn level instead of discarding it; the control flow (destroying rather than pooling the connection) is unchanged. Co-Authored-By: claude-flow <ruv@ruv.net>
|
Independent adversarial review + local verification: MERGE at 174b951. The advisory-lock unlock now runs on a detached 5s context (a query on an already-canceled ctx never reaches Postgres, pgconn.go:553-556), and on unlock failure the pinned conn is closed so pgxpool destroys rather than re-pools it (pgxpool v5.9.2 conn.go:33). The only session-level lock caller; xact locks elsewhere are unaffected. Pre-fix proof run against a real local Postgres 17 (lock still held after release on a canceled ctx); the CI integration test executed and passed. Follow-up: surface a persistent already_running as non-200. |
Summary
ReleaseAdvisoryLockran its unlock query on the caller's own context. When that context is already canceled or past its deadline by the time the deferred release runs (a scheduled task whose Lambda invocation context, or HTTP request context, expired whiledispatchTaskwas still in flight), the unlock query fails immediately, and the otherwise-healthy pinned connection still goes back to the pool with the session-level advisory lock still held. Every subsequent invocation of that task type then getsacquired=falseand returns{"status":"skipped","reason":"already_running"}with HTTP 200 until that specific pooled connection happens to be recycled -- collection (or whichever task) stops silently and stays stopped.Root cause
internal/database/connection.go'sReleaseAdvisoryLockissuedSELECT pg_advisory_unlock($1)on the caller'sctx, then unconditionally deferredconn.Release()regardless of whether the unlock actually ran. The scheduler's own deferred marker-clear (internal/scheduler/scheduler.go,CollectRecommendations) already detaches ontocontext.Background()with a short timeout for exactly this reason; this call site diverged from that established pattern.Fix
Both changes from the issue's fix direction:
context.Background()with a 5s timeout (advisoryLockReleaseTimeout), matching the scheduler's pattern, so a dead caller context can no longer skip the unlock.releasedbool), close the underlying*pgx.Connbefore the deferredRelease()runs.pgxpool.Conn.Release()already destroys (rather than pools) a connection it finds closed, so a connection that might still hold the lock can never be returned to the pool regardless of what specifically caused the unlock query to fail.Regression test
internal/database/advisory_lock_integration_test.go(build tagintegration,package database_test):TryAdvisoryLock.ReleaseAdvisoryLockwith an already-canceled context, simulating the dead-invocation-context scenario.container.DB's own pool) that the lock is free. This matters:pg_try_advisory_lockis reentrant per session, so re-asking the same connection pool could spuriously pass by reacquiring the exact pooled backend session that never actually released the lock.This test genuinely could not be run locally: Docker is unavailable in this environment (per the project's local-verification constraints for this session). It is gated behind the same testcontainers-backed
Integration TestsCI job every other DB-container test in this repo already relies on, and compiles cleanly with theintegrationbuild tag (go build -tags integration ./...,go vet -tags integration ./...).Verification
GOWORK=off go build ./...— passGOWORK=off go build -tags integration ./...— passGOWORK=off go vet ./...and-tags integration— passGOWORK=off go test ./... -short— 6133 tests passGOWORK=off go test ./internal/database/... -race -short -run TestReleaseAdvisoryLock— passGOWORK=off go mod tidy -diff— emptygofmt -lon changed files — cleangolangci-lintat the CI-pinned v2.10.1: reproduces the same pre-existing local toolchain issue noted on PR fix(frontend): RI-exchange execute never sends region, so the backend returns 400 #355/fix(api): PUT /api/plans/{id} resets the ramp schedule and re-buys completed steps #360/fix(api): "Run now" flips a purchase to running but nothing executes it #370 for this checkout, unrelated to this diff; relying on CI's clean go1.26.6 environment for the authoritative lint run.Note
The issue also suggests surfacing a
reason=already_runningresponse that persists past a plausible run duration, rather than always returning it as a 200. That's a separate observability improvement (detecting staleness, not fixing the underlying lock leak), so it's left out of this focused fix; flagging it here in case it's worth a follow-up issue.Closes #105
Summary by CodeRabbit