Close the two remaining delivery terminal-state gaps (closes #107)
All checks were successful
check / check (push) Successful in 3m16s
All checks were successful
check / check (push) Successful in 3m16s
This commit was merged in pull request #292.
This commit is contained in:
@@ -4,6 +4,7 @@ package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -505,6 +506,10 @@ func (e *Engine) processRetryTask(
|
||||
return
|
||||
}
|
||||
|
||||
if e.abandonRetryForMissingTarget(webhookDB, d, task) {
|
||||
return
|
||||
}
|
||||
|
||||
event := buildEventFromTask(task)
|
||||
|
||||
event, err = e.resolveEventBody(
|
||||
@@ -529,6 +534,64 @@ func (e *Engine) processRetryTask(
|
||||
e.processDelivery(ctx, webhookDB, d, task)
|
||||
}
|
||||
|
||||
// abandonRetryForMissingTarget stops a retry chain whose target has
|
||||
// been deleted, and reports whether it did.
|
||||
//
|
||||
// A scheduled retry lives in memory as a time.AfterFunc holding the
|
||||
// target's configuration as it was when the chain began, and nothing
|
||||
// else on this path reads the target row. Without this check a
|
||||
// deletion stops nothing: the timer keeps firing and keeps sending to
|
||||
// the destination the operator removed, for the whole remaining
|
||||
// backoff chain. Terminalising in the recovery and sweep paths alone
|
||||
// is not enough, because those only see the delivery once nothing
|
||||
// holds it in memory — which is to say after a restart.
|
||||
//
|
||||
// The worker already owns this delivery, so the terminal write happens
|
||||
// here directly, exactly as a target's own Deliver fails one. Claiming
|
||||
// it again through the recovery gate would only fail against the
|
||||
// reference the worker itself is holding.
|
||||
//
|
||||
// A lookup that fails for any other reason is not a deletion — it is
|
||||
// the main database being unreadable — and the delivery goes ahead as
|
||||
// it did before. A guard that terminally failed deliveries on a
|
||||
// transient fault would be worse than the bug it fixes.
|
||||
func (e *Engine) abandonRetryForMissingTarget(
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
) bool {
|
||||
_, err := e.loadTarget(task.TargetID)
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
e.log.Warn(
|
||||
"could not confirm the target of a retrying "+
|
||||
"delivery still exists; attempting anyway",
|
||||
"delivery_id", task.DeliveryID,
|
||||
"target_id", task.TargetID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
targetType, reason := e.missingTargetReason(task.TargetID)
|
||||
|
||||
e.log.Warn(
|
||||
"abandoning scheduled retry: target is gone",
|
||||
"webhook_id", task.WebhookID,
|
||||
"delivery_id", task.DeliveryID,
|
||||
"target_id", task.TargetID,
|
||||
"target_type", targetType,
|
||||
)
|
||||
|
||||
e.failDelivery(webhookDB, d, targetType, reason)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *Engine) recoverInFlight(ctx context.Context) {
|
||||
var webhookIDs []string
|
||||
|
||||
@@ -633,6 +696,20 @@ func (e *Engine) recoverSingleRetry(
|
||||
) {
|
||||
target, err := e.loadTarget(d.TargetID)
|
||||
if err != nil {
|
||||
// A target that is merely gone is an operator action with a
|
||||
// terminal answer. Any other failure is the main database
|
||||
// refusing to read, which is transient and must leave the
|
||||
// delivery alone: failing every retrying delivery of every
|
||||
// webhook on one bad read would be a far larger fault than
|
||||
// the strand it is meant to clear.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
e.failMissingTargetRetry(
|
||||
webhookDB, webhookID, d,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
e.log.Error(
|
||||
"failed to load target for retrying "+
|
||||
"delivery recovery",
|
||||
@@ -1028,6 +1105,16 @@ func (e *Engine) sweepSingleRetry(
|
||||
) {
|
||||
target, err := e.loadTarget(d.TargetID)
|
||||
if err != nil {
|
||||
// Deleted is terminal, unreadable is not; see
|
||||
// recoverSingleRetry.
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
e.failMissingTargetRetry(
|
||||
webhookDB, webhookID, d,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
e.log.Error(
|
||||
"retry sweep: failed to load target",
|
||||
"delivery_id", d.ID,
|
||||
@@ -1134,6 +1221,113 @@ func (e *Engine) failUnretryableRetry(
|
||||
target.Type,
|
||||
)
|
||||
|
||||
e.failDelivery(webhookDB, d, target.Type, reason)
|
||||
}
|
||||
|
||||
// failMissingTargetRetry terminally fails an orphaned retrying
|
||||
// delivery whose target row is gone. Both restart recovery and the
|
||||
// periodic sweep call it, so the transition exists once.
|
||||
//
|
||||
// Until it existed both paths logged the failed lookup and returned,
|
||||
// which left the delivery retrying for the life of the database and
|
||||
// the sweep repeating the same error every minute forever. Failing it
|
||||
// with a recorded reason is the treatment the other orphaned-retry
|
||||
// cases already get, so all of them read alike in the event log.
|
||||
//
|
||||
// Logged at warn rather than error: a deleted target is an operator
|
||||
// action, not a system fault.
|
||||
func (e *Engine) failMissingTargetRetry(
|
||||
webhookDB *gorm.DB,
|
||||
webhookID string,
|
||||
d *database.Delivery,
|
||||
) {
|
||||
// Terminal, and reached from the recovery paths, so it takes
|
||||
// ownership like every other write they make.
|
||||
if !e.inflight.retainIdle(d.ID) {
|
||||
return
|
||||
}
|
||||
|
||||
defer e.inflight.release(d.ID)
|
||||
|
||||
targetType, reason := e.missingTargetReason(d.TargetID)
|
||||
|
||||
e.log.Warn(
|
||||
"failing orphaned retrying delivery: "+
|
||||
"its target no longer exists",
|
||||
"webhook_id", webhookID,
|
||||
"delivery_id", d.ID,
|
||||
"target_id", d.TargetID,
|
||||
"target_type", targetType,
|
||||
)
|
||||
|
||||
e.failDelivery(webhookDB, d, targetType, reason)
|
||||
}
|
||||
|
||||
// missingTargetReason describes a target id that no longer resolves,
|
||||
// and returns the type of the deleted row where there still is one.
|
||||
//
|
||||
// The lookup is Unscoped because deletes are soft: the row survives
|
||||
// with deleted_at set, invisible to loadTarget's default scope.
|
||||
// Reading it is what separates "you deleted this target" from "this id
|
||||
// never named a row" — different things to whoever reads the event
|
||||
// log, and only the first is something an operator did. The widened
|
||||
// scope is deliberately confined to this terminal path: the engine's
|
||||
// normal target loading must go on refusing a deleted target, or
|
||||
// deleting one would stop nothing.
|
||||
//
|
||||
// The type comes back so the caller can label the delivery's status
|
||||
// transition with it. Where the row is gone entirely there is no type
|
||||
// to give, and updateDeliveryStatus leaves the counter alone rather
|
||||
// than opening a series named by the empty string.
|
||||
func (e *Engine) missingTargetReason(
|
||||
targetID string,
|
||||
) (database.TargetType, string) {
|
||||
var target database.Target
|
||||
|
||||
err := e.database.DB().Unscoped().
|
||||
First(&target, "id = ?", targetID).Error
|
||||
if err != nil {
|
||||
return "", fmt.Sprintf(
|
||||
"target %s no longer exists; the delivery "+
|
||||
"cannot be retried and has been failed "+
|
||||
"terminally",
|
||||
targetID,
|
||||
)
|
||||
}
|
||||
|
||||
return target.Type, fmt.Sprintf(
|
||||
"target %q (type %s) was deleted; the delivery "+
|
||||
"cannot be retried and has been failed terminally",
|
||||
target.Name, target.Type,
|
||||
)
|
||||
}
|
||||
|
||||
// failDelivery records why a delivery is over and then marks it
|
||||
// failed. The caller must already own the delivery: every call site is
|
||||
// either a worker holding the reference runTask took, or a recovery
|
||||
// path that took one through retainIdle.
|
||||
//
|
||||
// The result row is written first and a failure to write it stops the
|
||||
// transition, which is what keeps a delivery from ending failed with
|
||||
// an empty event log — the state that leaves an operator with nothing
|
||||
// but a server log line to work out what happened. A delivery whose
|
||||
// reason could not be recorded stays in the non-terminal state it
|
||||
// already holds, where the sweep will find it again; see
|
||||
// bookkeepingFailed.
|
||||
//
|
||||
// The target type is a parameter rather than read off d because the
|
||||
// orphaned-retry callers deliberately hold a delivery loaded without
|
||||
// its Target relation: populating d.Target would make GORM's
|
||||
// SaveBeforeAssociations upsert the whole target row — plaintext
|
||||
// config, which for a slack target is the credential — into the
|
||||
// per-webhook event database. See
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/206.
|
||||
func (e *Engine) failDelivery(
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
targetType database.TargetType,
|
||||
reason string,
|
||||
) {
|
||||
err := e.recordResult(
|
||||
webhookDB,
|
||||
d,
|
||||
@@ -1150,14 +1344,8 @@ func (e *Engine) failUnretryableRetry(
|
||||
return
|
||||
}
|
||||
|
||||
// The type is passed rather than assigned onto d: the delivery
|
||||
// is loaded here without its target relation, and populating
|
||||
// d.Target would make GORM's SaveBeforeAssociations upsert the
|
||||
// whole target row — plaintext config, which for a slack target
|
||||
// is the credential — into the per-webhook event database. See
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/206.
|
||||
e.settleStatus(
|
||||
webhookDB, d, target.Type,
|
||||
webhookDB, d, targetType,
|
||||
database.DeliveryStatusFailed,
|
||||
)
|
||||
}
|
||||
@@ -1178,9 +1366,19 @@ func (e *Engine) processDelivery(
|
||||
"type", d.Target.Type,
|
||||
)
|
||||
|
||||
e.settleStatus(
|
||||
// The reason is recorded, not just logged. This branch used
|
||||
// to fail the delivery with no DeliveryResult at all, which
|
||||
// showed in the event log as "failed, no attempts recorded
|
||||
// yet" and left one server log line as the only account of
|
||||
// why anywhere.
|
||||
e.failDelivery(
|
||||
webhookDB, d, d.Target.Type,
|
||||
database.DeliveryStatusFailed,
|
||||
fmt.Sprintf(
|
||||
"unknown target type %q: this build has no "+
|
||||
"delivery implementation for it, so no "+
|
||||
"attempt was made",
|
||||
d.Target.Type,
|
||||
),
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user