A pending delivery whose target was deleted is stranded forever #293

Open
opened 2026-08-24 04:33:12 +02:00 by clawbot · 0 comments
Collaborator

Found while fixing the retrying half of #107, and deliberately held out of its scope: that issue's definition of done is bounded to deliveries in retrying, and this lives in the sweep dispatch arm that change does not touch.

sendRecoveredDeliveries (internal/delivery/engine.go) resolves each recovered delivery's target through loadTargetMap, which uses the default scope. Deletes are soft, so a deleted target is absent from the map and the delivery is skipped:

e.log.Error("target not found for delivery", ...)
continue

The row stays pending with no result row. Nothing else moves it, so from pendingSweepMinAge (15 minutes) onward the sweep selects it, logs that same line and skips it again, every 60 seconds, for the life of the database. It is the same end state #107 fixes for retrying — a delivery that never reaches a terminal status, with an empty event log — reached from a different status.

Reachable by deleting a target while one of its deliveries is queued or stranded at pending: the delivery row is written before the task is queued, so the window is not just the failed-bookkeeping case.

Done when: a pending delivery whose target row no longer exists is terminally resolved rather than skipped indefinitely, with a DeliveryResult recording why, matching what #107 settled on for the three retrying cases — failDelivery, and missingTargetReason for the deleted-versus-never-existed distinction, both already exist. Mutation-verified.

Two things to be careful of, both load-bearing:

  • The write must go through the retainIdle/release ownership gate, like every other write the recovery paths make. See internal/delivery/inflight.go and #256.
  • Only a target confirmed gone may terminalise anything. loadTargetMap currently reports one error for the whole batch and returns nil on a failed query, so "this target is absent from the map" today conflates "deleted" with "the query failed" — terminalising on the latter would fail every pending delivery of every webhook on one bad read. Distinguishing them is part of the work.

Verify at scale against a healthy database as well: the same arm is what #256 made exact, and re-dispatching a healthy pending delivery is the regression to avoid.

Found while fixing the `retrying` half of https://git.eeqj.de/sneak/webhooker/issues/107, and deliberately held out of its scope: that issue's definition of done is bounded to deliveries in `retrying`, and this lives in the sweep dispatch arm that change does not touch. `sendRecoveredDeliveries` (`internal/delivery/engine.go`) resolves each recovered delivery's target through `loadTargetMap`, which uses the default scope. Deletes are soft, so a deleted target is absent from the map and the delivery is skipped: ``` e.log.Error("target not found for delivery", ...) continue ``` The row stays `pending` with no result row. Nothing else moves it, so from `pendingSweepMinAge` (15 minutes) onward the sweep selects it, logs that same line and skips it again, every 60 seconds, for the life of the database. It is the same end state https://git.eeqj.de/sneak/webhooker/issues/107 fixes for `retrying` — a delivery that never reaches a terminal status, with an empty event log — reached from a different status. Reachable by deleting a target while one of its deliveries is queued or stranded at `pending`: the delivery row is written before the task is queued, so the window is not just the failed-bookkeeping case. **Done when:** a `pending` delivery whose target row no longer exists is terminally resolved rather than skipped indefinitely, with a `DeliveryResult` recording why, matching what https://git.eeqj.de/sneak/webhooker/issues/107 settled on for the three retrying cases — `failDelivery`, and `missingTargetReason` for the deleted-versus-never-existed distinction, both already exist. Mutation-verified. Two things to be careful of, both load-bearing: - The write must go through the `retainIdle`/`release` ownership gate, like every other write the recovery paths make. See `internal/delivery/inflight.go` and https://git.eeqj.de/sneak/webhooker/issues/256. - Only a target confirmed gone may terminalise anything. `loadTargetMap` currently reports one error for the whole batch and returns `nil` on a failed query, so "this target is absent from the map" today conflates "deleted" with "the query failed" — terminalising on the latter would fail every pending delivery of every webhook on one bad read. Distinguishing them is part of the work. Verify at scale against a healthy database as well: the same arm is what https://git.eeqj.de/sneak/webhooker/issues/256 made exact, and re-dispatching a healthy pending delivery is the regression to avoid.
clawbot added this to the 1.0.0 milestone 2026-08-24 04:37:12 +02:00
clawbot removed this from the 1.0.0 milestone 2026-08-24 05:12:55 +02:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#293