Close the two remaining delivery terminal-state gaps (closes #107) #292
Reference in New Issue
Block a user
Delete Branch "issue-107-terminal-state-gaps"
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 #107.
What was wrong
Opaque failure. An unknown target type marked the delivery
failedand wrote noDeliveryResult. The event log readfailed / 0 attempts / No attempts recorded yet, with one server log line as the only account of why anywhere.Deletion-orphaned retries — three distinct failures, not one. Deletes are soft (
BaseModel.DeletedAt) andloadTargetuses the default scope, so the row is invisible to the engine:recoverSingleRetryandsweepSingleRetrylogged the failed lookup and returned, leaving the deliveryretryingfor the life of the database while the sweep repeatedrecord not foundevery 60 seconds.time.AfterFuncholding the target's config from when the chain began, and nothing on that path read the target row, so the timer kept firing real POSTs at the destination the operator had removed. Terminalising in recovery and the sweep alone only catches this after a restart.What changed —
internal/delivery/engine.goonlyprocessDeliveryrecords a result naming the offending type before failing the delivery.recoverSingleRetryandsweepSingleRetryterminalise a retrying delivery whose target row is gone, through the sharedfailMissingTargetRetry.processRetryTaskconfirms the target still exists before attempting, and abandons the chain when it does not.Unscoped()lookup, confined to these terminal paths, supplies the reason text and the deleted row's type. The engine's normal target loading stays scoped — widening it would mean deleting a target stopped nothing. Same narrow scoping as #211.recordResult-then-updateDeliveryStatusis factored into onefailDelivery, shared with the existingfailUnretryableRetry, so the three terminal paths behave identically. Both now return errors: a failed result write goes tobookkeepingFailedand the status is left alone.Two decisions worth reviewing
Only a target confirmed gone stops anything. Every new branch is gated on
errors.Is(err, gorm.ErrRecordNotFound). Any other lookup failure is the main database being unreadable — transient — and every path leaves the delivery exactly as it was. A guard that terminally failed every retrying delivery of every webhook on one bad read would be a larger fault than the strand it clears. Two tests pin this in both directions.#256's ownership gate stays the only thing deciding re-dispatch. Terminal writes reached from recovery go through
retainIdle/releaselike every other write those paths make. The retry path writes directly, exactly as a target's ownDeliverdoes, because the worker already holds that delivery — claiming it again through the recovery gate would only fail against the worker's own reference. Nothing was added to either sweep dispatch arm.Test fixtures
Retry fixtures that drove
processRetryTaskfor a target id with no row in the main database now create one. That state is not reachable in service — the handler reads the target to build the task in the first place — so those fixtures were modelling something the engine can now legitimately refuse.Verification
make checkgreen withGOFLAGS=-count=1, on the rebased branch. Lint ran in Docker and executed (0 issues, 52.2s, not cached).Both halves reproduced on unmodified
nextfirst, with the built binary, a real HTTP sink and the web UI.Half 2, before — target deleted at
1787536937.375, sink POSTs at:then across a restart:
status
retrying, 5 attempts, never terminalising.Half 2, after: 0 post-deletion POSTs; the delivery reaches
failedat the next scheduled retry with attempt 4 recordingtarget "doomed" (type http) was deleted; the delivery cannot be retried and has been failed terminally; after restart, 0 recovery errors and 0 sweep errors across two sweep cycles.A second live run deletes the target while the process is stopped, so no in-memory chain exists and restart recovery is the only thing that can see it: 0 errors, terminalised with the same reason, 0 POSTs after restart.
Half 1, before:
failed,delivery_resultsrows 0. After: 1 row,unknown target type "pubsub": this build has no delivery implementation for it, so no attempt was made.No #256 regression, measured rather than argued: 250 events across 3 targets on a healthy database, no reader. 750 deliveries, 750 sink POSTs, 750 result rows; both sweep arms ran against those 750 rows (confirmed by their queries in the log, one tick at +60s); restart added 0 further requests.
Each of the seven new guards was mutation-verified individually — reason row, recovery terminalisation, sweep terminalisation, retry-chain liveness check, the
errors.Isdiscrimination on the retry and recovery paths,Unscoped(), and the ownership release — each failing only its own tests.TODO.mduntouched,.golangci.ymluntouched,internal/database,internal/configandinternal/handlersuntouched.Deliberately not fixed here
A pending delivery whose target was deleted strands the same way:
sendRecoveredDeliverieslogstarget not found for deliveryand skips it on every sweep. Distinct trigger, distinct status, and it lives in the sweep arm this change deliberately does not touch. Filed separately as #293.918533d897to3f429f9a4fPASS. Independently verified by execution: both halves reproduced on unmodified
nextand closed on this head; the seven changed fixtures still fail when the behaviour they guard regresses; #256's no-duplicate guarantee holds at 750/750 with 0 added by restart;make checkgreen (21 packages, 0(cached), lint0 issuesin Docker, 1m20s); CI green on3f429f9; mergeable, no trailers, no scope creep.Disclosures and anomalies (none blocking):
/tmp/review-292-work, driven only throughmake test; the reviewed tree was never modified.TestProcessRetryTask_LargeBody_FetchFromDBasserts only the final delivery status and never that the body reached the sink — it survives a mutation that removes the event-body fetch entirely. Verified identical on unmodifiednext, so this is pre-existing and not caused by the fixture change here. Worth a separate issue.missingTargetReasondiscards the error from itsUnscoped()lookup, so a lookup that fails for a reason other than not-found yields the "no longer exists" wording instead of "was deleted". Reason text only; the terminal decision is still made on the scopedErrRecordNotFound.pendingdelivery whose target was deleted still strands atpendingand still logstarget not found for deliveryevery 60s indefinitely (3 cycles observed). No post-deletion POSTs in that case, so the delivering-to-a-removed-destination half is closed for both statuses.