Recovery skips orphaned retrying deliveries whose target type changed to a non-retry type #82
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Low-severity edge case surfaced by the independent review of PR #81 (delivery Target-interface refactor).
Background
After #81, restart recovery and the 60s sweep look each orphaned
retryingdelivery's target up in the registry and only reschedule it if the target implements the internalrescheduler(i.e.http/slack). Fire-and-forget targets (database/log) never set statusretrying, so under normal operation nothing is dropped — this is correct and was verified in review.The edge case
If a target's
typeis mutated in the DB from a retry type (http/slack) to a fire-and-forget type (or an unknown type) while it still has an orphanedretryingdelivery, that delivery now stays stuck inretryingforever. The pre-refactor code would eventually re-dispatch it and let the new target type resolve it todelivered/failed.This requires editing a target's type out from under an in-flight retrying delivery, so it does not arise in normal operation, and neither the old nor the new code handles it "correctly" (old: re-dispatches under a type that never produced the retry; new: leaves it stuck). Noted for completeness; it was explicitly not a merge blocker.
Definition of done
Decide the intended behaviour for a
retryingdelivery whose target type no longer supports retries, and make recovery/sweep do that deterministically. Options:failed(with a recorded reason) during recovery, orPick one, implement it, and add a recovery test that seeds a
retryingdelivery whose target type has changed and asserts the chosen outcome.Implementation requirements
Baseline:
main@4f5ecb1.Decision on the open question
The issue asks for a behaviour to be chosen from three options. Choose option 1: terminally mark such orphaned deliveries
failed, with a recorded reason. Reasoning, so the implementer and reviewer both have it:retryingforever, which is exactly the unbounded-state problem the rest of this milestone has been closing.failedis deterministic, has no external side effect, and the event itself remains durably stored in the per-webhook event database — so manual redelivery (already on the roadmap) can recover it deliberately.This is a judgement call the issue explicitly delegated. @sneak, it is easy to reverse if you disagree.
Confirmed state
Two sites in
internal/delivery/engine.godo the same thing:rs, ok := e.targets[target.Type].(rescheduler); if !ok { return }Both silently
return, leaving the delivery inretryingforever. Note this also covers an unknown target type:e.targets[type]returns nil, the assertion fails, and it takes the same path.1. Behaviour
At both sites, when the target does not implement
rescheduler, instead of returning silently:DeliveryResultcapturing why (target type no longer supports retries, naming the current type), andfailedvia the existing status-update path.Do not just set the status — a delivery that ends
failedwith no result row is opaque to anyone reading the event log later.Factor the two sites into one shared helper. They are already near-duplicates, and two copies of a terminal-state transition is how they drift.
2. Distinguish this from a genuine failure
Log at warn, not error — this is an operator-caused state, not a system fault. The message should name the delivery, the target, and the current type, so the operator can see the connection to their own edit.
3. Do not disturb the normal path
Fire-and-forget targets (
database/log) never set statusretryingunder normal operation, so this path should be unreachable for them in practice. The review of #81 verified that and it must stay true: this change must not cause a normally-operatingdatabaseorlogdelivery to be touched by recovery or the sweep.4. Tests
retryingdelivery whose target type has been changed to a non-retry type, run recovery, assert the delivery endsfailedand a result row records the reason.retryingdelivery whose target is stillhttp/slackis still rescheduled normally — the regression guard.5. Docs
README should state that changing a target's type does not migrate in-flight deliveries: any delivery still retrying under the old type is terminally failed and can be redelivered manually.
TODO.mdin the same commit.Definition of done
The issue's own DoD with option 1 chosen, plus items 1-5,
make checkgreen via the repo's own entrypoints,.golangci.ymluntouched, single commit whose title ends with(closes #82), no attribution trailers.Conflict note
PR #95 and PR #100 both modify
internal/delivery/engine.goand are merge-ready but unmerged. Keep this diff tight and confined to the two rescheduler sites and the shared helper, so the eventual rebase is mechanical.Implementation plan
Implementing option 1 per the manager comment (terminally mark
failedwith arecorded reason). Branch
issue-82-orphaned-retryingoffmain@4f5ecb1.Code
One new helper in
internal/delivery/engine.go:It logs at warn naming the delivery, target and current type; records a
DeliveryResultvia the existingrecordResult(attemptcountAttempts+1,success=false, error text naming the current target type and stating thedelivery was terminally failed because that type no longer supports retries);
then marks the delivery
failedvia the existingupdateDeliveryStatus.Both existing sites change from a bare
returnto a call plusreturn:recoverSingleRetry(restart recovery, line ~476)sweepSingleRetry(60s sweep, line ~671)This also covers an unknown/garbage target type, since
e.targets[type]yieldsa nil
Targetand thereschedulerassertion fails identically. Nothing elsein
engine.gois touched, so the rebase over PRs #95/#100 stays mechanical.export_test.gogainsExportSweepWebhookRetriesso the sweep path isreachable from the black-box tests (recovery already has
ExportRecoverWebhookDeliveries).Tests (
engine_integration_test.go)retryingdelivery whose target type is nowlog— assertsstatus
failedand aDeliveryResultrow whoseErrornames the type.ExportSweepWebhookRetries.type = "wat"): same outcome, no panic.retryingdelivery on a still-httptarget isrescheduled normally and is not marked
failed.Plus mutation evidence: revert the two call sites, show each new test fails,
restore.
Docs
README "Recovery paths" gains a note that changing a target's type does not
migrate in-flight deliveries — any delivery still
retryingunder the old typeis terminally failed and can be redelivered manually.
TODO.mdupdated in thesame commit.
Verification
make fmt,make check,script/cibuild, plus the Gitea CI run on the headcommit.
.golangci.ymluntouched.clawbot referenced this issue2026-08-11 14:48:08 +02:00