fix: resolve queue job visibility against the database clock#112
Merged
Conversation
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.
The bug
A previously dismissed "flaky test" turned out to be a real defect in the queue core. A tight reproduction (300 enqueue-then-dequeue cycles) failed 299 times out of 300.
Job visibility is a comparison between a stored timestamp and "now", but the two sides came from different clocks:
visible_atwas written by the application clock. Prisma populated the@default(now())column client-side, whiledequeuecompared it against Postgresnow(). On a machine whose host runs 1 ms ahead of the database container, a freshly enqueued job was invisible. In production this generalizes to NTP drift between the app host and the database: a few seconds of skew stalls ingestion for that long.TIMESTAMP(3)rounds up. The column stores milliseconds whilenow()is microsecond-precision, so a stored value lands up to half a millisecond in the future. Measured across 200 samples: 24% of rows were written withvisible_at > now().Retry backoff had the same defect — it was computed as
new Date(Date.now() + backoffMs)in application code.The fix
visible_atis now database-generated (dbgenerated("now()")), with a migration altering the column default.now() + intervalinstead of from the application clock.dequeuecompares againstnow() + 1 millisecond— a tolerance equal to the column's storage granularity, so rounding cannot hide a job.Evidence
Added a regression test that enqueues and immediately dequeues 25 times — it would have caught this. Two full cold-database pipeline runs: 123 tests, 38/38 tasks, no skips.
Why it survived this long
The worker polls on an interval, so a millisecond of invisibility was masked in normal operation. The only symptom was an occasionally flaky test — which is precisely the signal this product exists to surface. Documented in ADR-0004 so the clock-domain rule is explicit.
Not addressed here
dequeueincrementsattemptson every pickup, so a job that outlives the visibility timeout gets picked up again and inflates its attempt count toward the dead-letter cap. That is a separate design decision (ownership token on completion, or a longer timeout) and is deliberately out of scope.