fix(likes): tombstone cancelled URIs to prevent like resurrection race (#1338)
`test_cross_user_like` flakes intermittently in the staging integration
suite because of a real race in the like → unlike sequence:
1. user clicks LIKE → DB INSERT row R (atproto_like_uri=NULL),
`pds_create_like(R.id)` enqueued via docket.
2. user clicks UNLIKE before pds_create_like runs. atproto_like_uri
is still NULL so we just DELETE R; no PDS-delete is scheduled
because there's no URI yet.
3. `pds_create_like(R.id)` finally runs:
a. PDS create returns URI X.
b. SELECT R.id → row gone → orphan-cleanup branch fires.
c. `delete_record_by_uri(X)` is scheduled.
4. Jetstream emits the `app.bsky.feed.like` create event for X
BEFORE the matching delete event from (3c) propagates.
5. `ingest_like_create` finds no existing row for (track, user)
→ INSERTS a fresh row with URI X. **the like just resurrected
itself after the user explicitly unliked.**
6. eventually the delete event arrives and `ingest_like_delete`
by URI X clears the resurrected row — but in the gap the user
sees their unlike undone.
Fix: in (3c), tombstone the URI in Redis with a 5-minute TTL BEFORE
issuing the orphan PDS delete. `ingest_like_create` checks the
tombstone and drops the matching create event in (5). The TTL only
needs to cover Jetstream propagation; expiry is harmless because the
matching delete event still arrives shortly after.
Why Redis tombstone over a `cancelled_at` schema column: no migration,
no read-path filtering across ~15 query sites, scoped fix to the two
files actually involved in the race. Local Redis blip falls back to
the existing Jetstream-delete cleanup; user briefly sees the ghost
like but it's cleared seconds later.
Mirrors the existing track-tombstone pattern in `ingest.py` (which
prevents ghost tracks from cursor rewind) — same Redis primitive,
different prefix (`like_cancelled:` vs `plyr:tombstone:`) reflecting
the different concern (write race vs replay race).
Tests:
- tests/test_pds_create_like_tombstone.py — pds_create_like writes
the tombstone in the orphan branch and NOT on the happy path
(which would otherwise stall the user's own like indefinitely).
- tests/test_jetstream.py::TestIngestLikeCreate::test_skips_create_for_cancelled_uri
— ingest_like_create drops the create event when the URI is
tombstoned.
447/447 backend tests pass; ruff + ty clean.
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
authored by