Two remaining delivery terminal-state gaps: opaque failure and deletion-orphaned retries #107
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?
Both surfaced by the independent review of PR #104 (#82) and correctly held out of its scope. They are the two remaining paths where a delivery reaches a bad terminal state without the engine recording why, or fails to reach one at all.
#82 fixed the retyped-target case. These are its siblings.
1.
processDeliveryfails an unknown target type with no result rowinternal/delivery/engine.go:783-796. When a delivery's target type is not in the registry,processDeliverymarks the deliveryfailedand records nothing.That is precisely the opacity #82's spec forbade for the recovery and sweep paths — a delivery that ends
failedwith an empty event log is unreadable to anyone trying to work out what happened. #104 fixed the two recovery/sweep sites and deliberately did not touch this one, so the codebase is now inconsistent: two paths explain themselves, one does not.Done when: the unknown-target-type branch in
processDeliveryrecords aDeliveryResultnaming the offending type before marking the deliveryfailed, following the samerecordResult-then-updateDeliveryStatusidiom used by #104 and byinternal/delivery/target_http.go:299-306; with a test asserting the result row exists and names the type, mutation-verified.2. A deleted target leaves a retrying delivery stuck forever
Distinct trigger, same end state as #82. Both
recoverSingleRetryandsweepSingleRetrybegin withloadTarget(d.TargetID); if the target row is gone rather than retyped,loadTargeterrors and both sites log andreturn. The delivery staysretryingfor the lifetime of the database.#82 only reached the case where a target still exists but no longer implements
rescheduler. Deleting a target while one of its deliveries is mid-retry is a more ordinary operator action than editing a target's type, so this is arguably the likelier of the two to happen in practice.Done when: a
retryingdelivery whose target row no longer exists is terminally resolved rather than left hanging — the natural choice is the same treatment #82 settled on (record a result row explaining the target is gone, markfailed), so all three cases behave alike. Cover both the recovery and sweep paths, mutation-verified.Note the interaction with soft deletes: confirm whether target deletion is soft (GORM default scope) or hard here, since a soft-deleted target may still be loadable depending on the query, and the fix differs accordingly. Establish that first.
Why one issue
Both are the same theme — a delivery ending in a state the engine cannot explain — both live in
internal/delivery/engine.go, and both should follow the idiom #104 established. Fixing them together keeps the three terminal paths consistent, which is the actual goal.Definition of done
failedwith an empty event log, and no path can leave one inretryingindefinitely.make checkgreen via the repo's own entrypoints;.golangci.ymluntouched.Plan. Deletes are soft (
BaseModel.DeletedAt), andloadTargetuses the default scope, so a deleted target's row is invisible to the engine — confirmed, and it is what produces both the endlessrecord not foundsweep log and the "never existed" ambiguity.Unknown target type.
processDeliveryrecords aDeliveryResultnaming the type before settlingfailed, via therecordResult-then-updateDeliveryStatusidiom. Both now return errors, so a failed result write goes tobookkeepingFailedand the status is left alone.Deleted target, recovery and sweep.
recoverSingleRetryandsweepSingleRetryterminalise only ongorm.ErrRecordNotFound; any otherloadTargeterror stays a log-and-return, because terminally failing every retrying delivery on a transient main-database fault would be worse than the bug. The reason text is resolved by one narrowUnscoped()lookup used only on this terminal path, so "you deleted this target" and "this id never named a row" read differently. Both go through the existingretainIdle/releasegate, sharing one terminal helper withfailUnretryableRetry.The cached-config retry chain (the half the audit found worse than filed): a scheduled
time.AfterFunccarries aTargetConfigsnapshot and keeps POSTing to a destination the operator removed.processRetryTaskwill check target liveness before dispatching and terminalise instead — the worker already owns the delivery there, so it writes directly as any target does insideDeliver, rather than adding a second ownership path. A lookup that fails for a reason other than not-found proceeds as before, so the new guard cannot itself stall retries. Fresh deliveries are unaffected: their config is read in the request that queues them.Nothing is added to the sweep's dispatch arms, so the #256 ownership gate stays the only thing deciding re-dispatch. Verification includes a several-hundred-delivery no-duplicate run to prove that.
Done in #292 (branch
issue-107-terminal-state-gaps, basenext).make checkgreen.Item 2 turned out to be three failures, not one. Beyond the two stranded-recovery sites the issue names, deleting a target did not stop deliveries to it at all: a scheduled retry is a
time.AfterFunccarrying the target config from when the chain began, and nothing on that path read the target row, so the timer kept firing real POSTs at the removed destination.processRetryTasknow confirms the target exists before attempting.Both halves were reproduced on unmodified
nextwith the real binary and an HTTP sink before anything was changed. Half 2: target deleted at t=5s, two further POSTs confirmed at the sink afterwards, then across a restart one recovery error and a sweep error every 60s indefinitely, delivery stillretrying. Half 1:failedwith zerodelivery_resultsrows.After: zero post-deletion POSTs; the delivery reaches
failedwith a row readingtarget "doomed" (type http) was deleted; the delivery cannot be retried and has been failed terminally; zero recovery and sweep errors across two cycles. A separate run deletes the target while the process is stopped, so restart recovery is the only path that can see it — same outcome. Half 1 now recordsunknown target type "pubsub": this build has no delivery implementation for it, so no attempt was made.Only a target confirmed gone terminalises anything: every new branch is gated on
gorm.ErrRecordNotFound, so an unreadable main database leaves deliveries alone rather than failing all of them. The deleted-versus-never-existed distinction comes from oneUnscoped()lookup confined to these terminal paths; normal target loading stays scoped.Seven guards, each mutation-verified individually. No-duplicate non-regression measured at 250 events across 3 targets: 750 deliveries, 750 sink POSTs, both sweep arms fired, restart added zero.
The equivalent strand for a
pendingdelivery is real but a distinct trigger in a sweep arm this change does not touch; filed as #293.