executor: failed dependency waits keep their per-container event granularity

waitDependencies reports an optional dependency's failure as one
Skipped event per container of the dependency, with the reason — and a
required condition failure as per-container Error events. The plan
walker's generic single Skipped on the wait node's resource ID would
have silently changed that granularity, exactly the kind of
divergence the event contract exists to prevent.

Wait failures now flow through reportWaitFailure, which emits the
historical per-container events (Skipped with reason when optional,
Error otherwise) before handing the error to the walker; the walker
skips its generic event for wait nodes.

Validated design decision for #14081 (event parity contract).

Signed-off-by: Nicolas De Loof <[email protected]>
This commit is contained in:
Nicolas De Loof
2026-08-17 21:46:35 +02:00
parent 05258d85fa
commit 4ea6832f60
2 changed files with 26 additions and 4 deletions
+6 -2
View File
@@ -122,9 +122,13 @@ func (exec *planExecutor) run(ctx context.Context, plan *Plan) error {
if err != nil && node.Operation.Optional && ctx.Err() == nil {
// A best-effort operation reports its failure as a skip and
// does not fail the plan: dependent nodes still run.
// does not fail the plan: dependent nodes still run. Wait
// nodes emit their own per-container Skipped events (see
// reportWaitFailure), so the generic one is theirs to skip.
logrus.Warnf("%s (%s): %v", node.Operation.Cause, node.Operation.ResourceID, err)
events.On(skippedEvent(nodeEventName(node), err.Error()))
if node.Operation.Type != OpWaitCondition {
events.On(skippedEvent(nodeEventName(node), err.Error()))
}
err = nil
}
+20 -2
View File
@@ -228,13 +228,13 @@ func (exec *planExecutor) execWaitCondition(ctx context.Context, op Operation) e
case <-ticker.C:
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("timeout waiting for dependencies")
return exec.reportWaitFailure(op, waitingFor, fmt.Errorf("timeout waiting for dependencies"))
}
return ctx.Err()
}
satisfied, err := exec.checkWaitCondition(ctx, op, waitingFor)
if err != nil {
return err
return exec.reportWaitFailure(op, waitingFor, err)
}
if satisfied {
return nil
@@ -242,6 +242,24 @@ func (exec *planExecutor) execWaitCondition(ctx context.Context, op Operation) e
}
}
// reportWaitFailure emits the per-container events a failed dependency wait
// owes the progress display — Skipped with a reason when the dependency is
// optional, Error otherwise — and returns the error for the walker (which
// skips the generic event for wait nodes, see run()).
func (exec *planExecutor) reportWaitFailure(op Operation, waitingFor Containers, err error) error {
s := exec.compose
if op.Optional {
reason := fmt.Sprintf("optional dependency %q: %v", op.Name, err)
s.events.On(containerReasonEvents(waitingFor, skippedEvent, reason)...)
logrus.Warnf("%s", reason)
} else {
s.events.On(containerEvents(waitingFor, func(id string) api.Resource {
return errorEventf(id, "dependency %s failed to start", op.Name)
})...)
}
return err
}
// checkWaitCondition performs one probe of a WaitCondition operation,
// reporting whether the dependency satisfies the declared condition.
func (exec *planExecutor) checkWaitCondition(ctx context.Context, op Operation, waitingFor Containers) (bool, error) {