Fix #91: atomic unread dedup #100

Merged
crueber merged 1 commit from fix/issue-91 into main 2026-09-05 02:53:48 +00:00
Owner

Closes #91.

Unread-dedup check-then-act race in internal/notify/emit.go: createOne checked hasUnread then Created with a seq-embedded id, so concurrent same-(user,thread,reason) emissions minted duplicate tray rows (different ids defeat the index CAS).

Fix (entry-level discipline, no new locks): the triple is re-checked INSIDE the unread-index CAS loop (new indexClaim over shared indexUpsert body; indexAdd keeps id-only semantics). hasUnread stays as a fast path. A CAS loser best-effort deletes its just-Created orphan object and reports createSkipped, converging tray and bucket to one row + one object. notify-fanout backfill (fanoutOne, same race shape) now delegates to createOne instead of duplicating the sequence. §4 Create->index->activity->stream order unchanged.

Tests: new TestEmitConcurrentSameTripleDedups (16 goroutines, start barrier, no sleeps) asserts 1 created / 15 skipped, one unread index row with count 1, one notification object. Fails pre-fix (14 created), green post-fix -race -count=20. Package coverage 97.3% (>=95%). gofmt/vet clean.

Docs: Decisions entry in docs/features/06_notifications.md (law 12).

Closes #91. Unread-dedup check-then-act race in internal/notify/emit.go: createOne checked hasUnread then Created with a seq-embedded id, so concurrent same-(user,thread,reason) emissions minted duplicate tray rows (different ids defeat the index CAS). Fix (entry-level discipline, no new locks): the triple is re-checked INSIDE the unread-index CAS loop (new indexClaim over shared indexUpsert body; indexAdd keeps id-only semantics). hasUnread stays as a fast path. A CAS loser best-effort deletes its just-Created orphan object and reports createSkipped, converging tray and bucket to one row + one object. notify-fanout backfill (fanoutOne, same race shape) now delegates to createOne instead of duplicating the sequence. §4 Create->index->activity->stream order unchanged. Tests: new TestEmitConcurrentSameTripleDedups (16 goroutines, start barrier, no sleeps) asserts 1 created / 15 skipped, one unread index row with count 1, one notification object. Fails pre-fix (14 created), green post-fix -race -count=20. Package coverage 97.3% (>=95%). gofmt/vet clean. Docs: Decisions entry in docs/features/06_notifications.md (law 12).
createOne checked hasUnread then Created with a seq-embedded id, so
concurrent same-(user,thread,reason) emissions minted duplicate tray
rows. The triple is now re-checked inside the unread-index CAS loop
(indexClaim); the loser deletes its orphan object and reports skipped.
fanoutOne delegates to createOne (same race shape). Regression test:
16-way concurrent same-triple createOne converges to one row/object.

Docs: Decisions entry in docs/features/06_notifications.md (law 12).
Sign in to join this conversation.
No description provided.