Advance lastHitAt on a deduped delivery - #236
Conversation
updateSourceStats was the only writer of sources.lastHitAt, and both dedup paths (pre-insert provider-retry idempotency and the unique-index race loser) skipped it, so a source under a redelivery storm showed a frozen/absent last-hit time while actively receiving traffic. Touch lastHitAt best-effort on both dedup returns without bumping recordCount (the record already counted on its first ingest). Closes #231
Independent code review trailReviewer ran on Opus against Round 1
Round 2
Round 3
Round 4 (settle)
Local gate before push: |
Superseded by main — recommend closingWhile resolving this PR's On Evidence: resolving both conflicted files to main's side ( Keeping this branch's Recommendation: close this PR as superseded. Issue #231 is still open but its requested behavior is already delivered on |
What changed and why
updateSourceStatswas the only writer ofsources.lastHitAt, and it only runs on a fresh, non-deduped ingest. Both dedup paths inserver/api/hooks/[slug].post.tsreturned a 202 without touching stats:onConflictDoNothinglets only one insert land, and the loser resolves to the winner's record.So a source under a provider redelivery storm showed a frozen/absent "last hit" in the UI even though it was actively receiving traffic.
Both dedup returns now call a new best-effort
touchSourceLastHit(sourceId)that advanceslastHitAtwithout incrementingrecordCount— that column counts stored records, and the record already counted on its first ingest. Re-running the full side effects (recordCount bump + "received" event) on every retry would double-count.Implementation decisions
touchSourceLastHitrather than reusingupdateSourceStats: the two writes differ (stats bumpsrecordCount; the dedup touch must not), so they are distinct functions.try/catch: a failed timestamp touch is a stale label, not a lost delivery, so it is swallowed and logged. Thetrywraps the entire body (not just the query promise) so a synchronous throw fromgetDb()/the builder can't surface as a 500 — a 500 here would make the provider redeliver an already-stored delivery, looping the touch into repeated reprocessing.lastHitAt.UPDATE: reviewer raised advancinglastHitAton every request viarecordWebhookHit. Declined — it would mark rejected (400/403) deliveries as hits and couple rate-limiting with activity display, both beyond this issue's scope.Tests
Added to
tests/server/api/hooks/slug.post.test.ts:lastHitAt(exactly one update,{ lastHitAt }payload only, scoped to this source) and never bumprecordCount.recordCount(positive counterpart, so dropping the increment can't go unnoticed).lastHitAt.Where it's viewable
The Sources list UI (
/sources) — each source card's "last hit" / last-activity timestamp now advances on deduped provider redeliveries.Closes #231
Follow-up suggestions
Debounce the dedup lastHitAt touch— gate the new best-effort touch behind a per-source staleness interval so a provider redelivery storm collapses to one write per interval instead of one row-locking UPDATE per duplicate (suggested: P3, effort: S, evidence: server/api/hooks/[slug].post.ts touchSourceLastHit)