Carry the event's receipt time into every delivery (closes #257) #297
Reference in New Issue
Block a user
Delete Branch "issue-257-slack-timestamp"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #257.
The defect
Every Slack/Mattermost message rendered
*Timestamp:*as0001-01-01T00:00:00Zwhile the stored event'screated_atwas correct. Both delivery paths reconstruct the event from theTaskthat carries it; noTaskcarries a receipt time, soFormatSlackMessageformatted a zerotime.Time.Why not a
TaskfieldTaskis built in three places:buildRecoveryTask(internal/delivery/engine.go),buildDeliveryTasks(internal/handlers/webhook.go) andcreateReplayDelivery(internal/handlers/delivery_replay.go). Only the first is the restart-recovery path. A new field populated ininternal/deliveryalone would have fixed recovery and left the live first-attempt and replay paths — the ones that produce essentially every message a human reads — still zero. That is the half-fix this defect invites.The stored row is the single source of truth instead:
resolveEventBodybecomeshydrateEvent, which readscreated_atalongsidebody. BothprocessNewTaskandprocessRetryTaskalready call it, so every reconstruction path is covered regardless of where itsTaskcame from, andTaskis unchanged.(
Taskis neither serialised nor persisted — it has no struct tags and travels only through thedeliveryCh/retryChbuffered channels — so widening it would have been safe; it just would not have worked.)The ownership gate (#256) and the terminal-state paths (#107) are untouched.
Behaviour change worth reviewing
A task that inlined its body previously made no database read at all on the first attempt. It makes one now. A read failure on that path is deliberately not fatal: the event row can be reaped by retention while a queued delivery still holds its body inline, and dropping a deliverable event to protect one metadata field is a worse failure than the one it prevents. Such a delivery goes out with the timestamp unset and a warning logged. A task with no inlined body still fails, exactly as before.
buildEventFromTaskfield audit (issue's second ask)database.Eventhas 12 fields. Before this change:IDWebhookIDEntrypointIDMethodHeadersContentTypeBodyresolveEventBodyCreatedAtUpdatedAtDeletedAtResubmittedFromIDWebhook/Entrypoint/Deliveries(relations)Consumers of the reconstructed event across all four targets:
target_slack.goreadsMethod,ContentType,CreatedAt,Body;target_http.goreadsBody,ContentType,Headers;target_log.goreadsWebhookID,EntrypointID,Method,ContentType,Headers,Body;target_database.goreadsID,WebhookID,EntrypointID,Method,Headers,Body,ContentType.CreatedAtwas the only user-visible gap and is the only one fixed here.UpdatedAt,DeletedAtandResubmittedFromIDare read by no target on this path; they are reported, not fixed, per the scope fence.One observation for the owner rather than a filed defect: the
archivedEventrow the database target writes recordsArchivedAtbut not the event's original receipt time, so an archive cannot say when a webhook actually arrived. That reads as a deliberate schema choice rather than an oversight, and adding a column is beyond this issue either way — flagging it as a question, not a bug.Verification
Reproduced on unmodified
nextfirst. Raw payload captured from a sink standing in for the Slack incoming webhook, identical on all three paths:After the fix, same sink, event seeded with
created_atof2026-03-04T05:06:07Z(chosen so it can be confused with neither the zero time nor "roughly now"):New tests in
internal/delivery/event_timestamp_test.go:TestSlackFirstAttemptCarriesEventTimestamp— first-attempt path, body inlined.TestSlackFirstAttemptLargeBodyCarriesEventTimestamp— first-attempt path, body read back from the row.TestSlackRetryCarriesEventTimestamp— retry path, viaprocessRetryTaskon aretryingdelivery.TestFormatSlackMessageOverTaskReconstructedEvent— unit-level, asserts a non-zero and correct timestamp inFormatSlackMessageoutput over an event reconstructed from aTask.TestEventReconstructionSurvivesAReapedRow— pins the fallback above in both directions.Mutation-verified: with
event.CreatedAt = dbEvent.CreatedAtremoved, the first four fail and the fallback test still passes, which is the correct split.make checkgreen withGOFLAGS=-count=1: 21 packagesok, zero(cached)lines, and the lint stage executed rather than replaying (#11 [lint 3/3] RUN ... golangci-lint run, 62s,0 issues.).TODO.mdis untouched, per its own Workflow section (issue branches do not edit it).PASS. Independently reproduced the zero timestamp on unmodified
nextand confirmed the fix on all five reconstruction paths (first attempt inlined, first attempt from the row, retry, restart recovery, replay), with a seededcreated_atof2026-03-04T05:06:07Z— the rendered value is the receipt time, not the delivery time. Mutation-verified both directions. #256 non-regression: 900 deliveries (300 events x 3 targets), unique body per delivery, 900 sink POSTs, 0 duplicates,max(attempt_num)=1,inflight.held()=0, x3 runs; restart plus both sweep arms (each proven to select rows) added zero.make checkgreen locally withGOFLAGS=-count=1(21 packagesok, 0(cached), lint executed in Docker, 65s,0 issues.); CI green on62b9463;make fmta no-op; commit hygiene and attribution clean; mergeable, no rebase needed.Two notes for the owner, neither blocking.
The reaped-row fallback is the right call, but the human sees
*Timestamp:* `0001-01-01T00:00:00Z`in that case —FormatSlackMessage(internal/delivery/target_slack.go:236) emits the line unconditionally, so the fallback renders the exact string #257 was filed against and is indistinguishable from a regression. Verified end to end: with the event row hard-deleted, the delivery goes out,could not read the stored eventlogs at WARN, payload carries the zero stamp. Suggested follow-up (not this PR): omit or mark the line whenCreatedAtis zero. The scenario is real —reapExpired(internal/database/retention.go:273-320) hard-deletes events oncreated_at < cutoffwith no delivery-status guard; the practical trigger is an operator lowering a webhook'sRetentionDays, since retention is day-granular.Hot-path cost, measured: the added
SELECT created_atis 33 us per delivery (20k warm primary-key reads, median 32.9 us). End-to-end inlined-body first attempts ran 641-889/s onnextand 610-846/s on this branch over 3 runs each — the delta is inside shared-host noise. The read uses the same pooled per-webhook handle as the existing writes (WAL, 10s busy timeout, 4 max conns), and under WAL a reader cannot block a writer, so it cannot recreate the wedge; no write errors orSQLITE_BUSYappeared across the 900-delivery runs. Worth knowing: 3 of the 4 target types never readCreatedAtand now pay the read anyway.Ownership:
hydrateEventtakes and releases no reference and adds no path that terminalises a delivery outsidetakeForRedispatch; the new failure branch returnsnil, so it can only make a delivery more likely to proceed, never less. Field audit spot-checked and correct — no target readsUpdatedAt,DeletedAtorResubmittedFromIDoff a reconstructed event, and the database target builds its archive row from named fields, so populatingCreatedAtcannot change what it writes.Disclosure: all probes ran in throwaway copies of the tree, never on the reviewed checkout; the reviewed tree is unmodified.