Nobody has started this yet — be first.
Business impact
This is the exact "many replicas must not double-fire the same due work" guarantee the README calls out as this project's reason for running two app replicas by default. Without it, two dispatcher replicas polling at the same moment can both claim and dispatch the SAME outbox row -- meaning the bank/UPI rail can receive two genuinely concurrent, redundant calls for what should be a single side effect. A real bank/UPI integration that isn't perfectly idempotent under true concurrent calls would be exposed to a genuine double-charge risk.
Problem
SelectDueIDs's query is missing FOR UPDATE SKIP LOCKED. A second, concurrently-open transaction's identical due-work query can return the same row(s) a first transaction has already selected, even though the first hasn't committed or written anything yet.
Current behavior
A second, concurrently-open transaction's SelectDueIDs call returns the same row(s) a first transaction already selected, even though the first has not committed.
Expected behavior
A row already selected (and about to be claimed) by one transaction should not be selectable by a second, concurrently-open transaction running the same query -- FOR UPDATE SKIP LOCKED is what provides that.
Steps to reproduce
TEST_DATABASE_URL=postgresql://... go test ./tickets/tests/... -run TestTicket21 -v -count=5
A genuinely concurrent, goroutine-based test turns out to be too timing-dependent to reliably trigger this in practice, so this ticket's test instead drives outbox.SelectDueIDs from two manually-sequenced transactions, deterministically opening the race window: transaction B runs the same query while transaction A has already selected the row but not yet committed.
Why this matters
Compare SelectDueIDs's SELECT against internal/timeout/sweeper.go's claimDue, which does the equivalent claim for payment timeouts, for the exact clause that's missing here.
Suggested approach
Compare SelectDueIDs's SELECT against internal/timeout/sweeper.go's claimDue, which does the equivalent claim for payment timeouts, for the exact clause that's missing here.
Acceptance criteria
Verification
TEST_DATABASE_URL=postgresql://... go test ./tickets/tests/... -run TestTicket21 -v -count=5
Hints (0/2)
Try it without hints first — the reading is the exercise.
Working on this ticket
Work on a branch named for the ticket — that's what you'll submit.
Branch off your fork
Sign in to create it automatically, or do it yourself:
$git checkout -b fix/outbox-double-dispatchFix it and commit
Meet every acceptance criterion, and add a test that would have caught this.
Push the branch
$git push -u origin fix/outbox-double-dispatchSubmit it below
Paste your fork URL and the branch name, with a short write-up of the root cause.
Questions
Ask about anything unclear in the ticket — the maintainer and anyone who has solved it can answer. Please don't post full solutions.
Sign in to ask a question or reply.
Sign indocker composepaystate/paystate/go.modreplace reliable => ../reliabledocker compose up --buildpaystate/context: ..docker-compose.ymlCREATED_TIMEOUTAUTHORIZED_TIMEOUTWork the tickets in tickets/docs/ (ticket-01 through ticket-25); each names one Go test in tickets/tests/:
TEST_DATABASE_URL=postgresql://... go test ./tickets/tests/... -run TestTicket01 -v # a single ticket
TEST_DATABASE_URL=postgresql://... ./tickets/run_tickets.sh # all 25, clean pass/fail summary
Tickets that don't touch Postgres (02, 03, 04, 05, 06, 14, 15, 24) run fine without TEST_DATABASE_URL set; the rest report FAIL with a "TEST_DATABASE_URL not set" message if it's missing. make test-race runs the full go test -race -p 1 ./... suite the same way.
Level 1
Fix a bug
Read existing behaviour, correct it.