Files
mesh-delivery/modules/mesh-delivery-sdk/steprun/step.go
T
jschoubben d29e1426d0 Asker rename R2: every asker of a build seat asks under its seat's own name, and its step is keyed so
A-163: the asker in `accept.<queue>.<asker>` is the asking seat's name (mesh-pipeline-runner,
mesh-catalog, mesh-wants, mesh-builds), never a nickname: the builder asks as mesh-builds (was
"builder"), the indexer's validation as mesh-catalog (was "indexer"), the assigner's runs as
mesh-wants (was "assigner"), the runner's steps on `accept.check.mesh-pipeline-runner` and
`accept.delivery.mesh-pipeline-runner` (were `accept.check.step` and `accept.delivery.step`, where
the holder read `step` as the asker). An asker naming none (the checker) asks on main's subject
(A-184). A build seat's queues are delivery, check and recheck alone (A-170, A-184): buildstep's
AskKinds holds no step, a holder takes a step from check or delivery, and the grants part makes a
worker per queue, never per kind. A holder keys a named ask's step `<asker>.<node>.<id>` and main's
`<node>.<id>` (§481, §483); readers watch their own name's keys. The composition step's step-progress
names the one caller mesh-wants' definition lists for step, read from the seat list (A-294).

Builds: design 47 G §481, §483, §543, §600, A-163, A-170, A-184, A-248, A-294

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-12 18:37:29 +01:00

224 lines
9.8 KiB
Go

// Package steprun is what the build modules share: the runner of a long step that answers a tool call at once
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
// (issue 1473), the fetch of one commit over SSH (issue 1474), and the failure contract of a test level with the parsers
// of go test -json and node --test's TAP that fill it (failure.go, issue 1901; its conformance kit is package conformance).
package steprun
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
buildrun "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildrun"
sdkstep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
"git.novox.be/novox/mesh-sdk/go/kvlimit"
)
// Put writes one key of a state: the steps state.Put's shape (go/state.go).
type Put func(key string, value any) (uint64, error)
// Keep keeps a step's answer that is over what the steps state holds per value as the object named object in the build
// module's outputs store (design 47 G A-290), the object the seat's verb output answers: Puller.KeepOutput, KeepOutputIn.
type Keep func(object string, body []byte) error
// EndAttempts and EndRetry bound how a step's end is written: a write that fails is tried again, since the end
// is the only way the step's reader learns it is over.
var (
EndAttempts = 5
EndRetry = 2 * time.Second
)
var now = time.Now
// Start begins a step of tool on node and answers its id at once: the key <node>.<id> holds state running when
// Start returns. run is then called in a goroutine of its own, under ctx (the process's, not the tool call's: the
// call is cut at 30 s, the step is not). When it returns, the key is written passed with run's result, failed with
// the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and
// the process goes on. If the running write fails, nothing was started and Start answers the error.
func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) {
step, key, err := begin(put, node, tool, sdkstep.NewID(), "")
if err != nil {
return "", err
}
go end(ctx, put, key, step, run)
return step.ID, nil
}
// begin writes the step running under id, owned by this process (Owner).
func begin(put Put, node, tool, id, what string) (sdkstep.Step, string, error) {
return beginRun(put, "", node, tool, id, what, 0)
}
// beginRun is begin for the run-th run of an ask (the bus's delivery count; 0 for a step that is no ask), keyed under the
// asker its subject names, or main's form for none (design 47 G §481, §483).
func beginRun(put Put, asker, node, tool, id, what string, run int) (sdkstep.Step, string, error) {
step := sdkstep.Step{ID: id, Tool: tool, Node: node, State: sdkstep.StepRunning, Started: now().UTC(), What: what, Owner: buildrun.Owner, Run: run}
key, err := sdkstep.StepKeyOf(asker, node, step.ID)
if err != nil {
return sdkstep.Step{}, "", err
}
if err := write(put, key, step); err != nil {
return sdkstep.Step{}, "", fmt.Errorf("steprun: writing the running step %s: %w", key, err)
}
return step, key, nil
}
// end runs run and writes the step's end.
func end(ctx context.Context, put Put, key string, step sdkstep.Step, run func(ctx context.Context) (any, error)) {
result, err := protect(ctx, run)
_, _ = finish(put, nil, key, step, result, err, log.Printf) // finish says a refused end itself
}
// finish writes the step's end from run's result or error: passed, failed or refused (a *Refusal), and answers the end it
// wrote. An end whose value would be over what the steps state holds per value keeps its answer as an object with keep
// (keepOver, design 47 G A-290). A write that fails is tried EndAttempts times. When the whole end is refused every time (a result the state will not take: issue 1930), the
// refusal is logged and a small failed end without the result is written instead, naming the write's error, so the
// step's reader learns it is over and why. finish answers an error only when no end could be written at all, and then
// the step's ask is not acknowledged as done.
func finish(put Put, keep Keep, key string, step sdkstep.Step, result any, err error, logf func(format string, args ...any)) (sdkstep.Step, error) {
step.Ended = now().UTC()
switch ref, drop := (*Refusal)(nil), (*Dropped)(nil); {
case err == nil:
step.State = sdkstep.StepPassed
if result != nil {
raw, merr := json.Marshal(result)
if merr != nil {
step.State, step.Reason = sdkstep.StepFailed, "the result is not JSON: "+merr.Error()
} else {
step.Result = raw
}
}
if ans, ok := runAnswerIn(result); ok && ans.Exit != 0 && step.State == sdkstep.StepPassed {
// A run whose program exited other than 0 ends failed, its answer kept, as its step-ended says (design 47 G A-290).
step.State, step.Reason = sdkstep.StepFailed, exitedWhy(ans)
}
case errors.As(err, &drop):
step.State, step.Reason = sdkstep.StepDropped, err.Error()
case errors.As(err, &ref):
step.State, step.Reason = sdkstep.StepRefused, err.Error()
default:
step.State, step.Reason = sdkstep.StepFailed, err.Error()
step.Passing = sdkstep.IsPassing(err) // it ended on a transport fault: its asker may ask it again (ADR 0664 §3)
// A failed step may answer what it found on the way (a build's out-of-step files): kept on its end.
if result != nil {
if raw, merr := json.Marshal(result); merr == nil {
step.Result = raw
}
}
}
step = keepOver(keep, step, result)
werr := writeEnd(put, key, step)
if werr == nil {
return step, nil
}
if logf != nil {
logf("the end of the step %s (%s) could not be written in %d tries: %v", key, step.State, EndAttempts, werr)
}
ended := step.State
why := fmt.Sprintf("the step ended %s, and its end could not be written: %v", ended, werr)
if step.Reason != "" {
why = fmt.Sprintf("the step ended %s (%s), and its end could not be written: %v", ended, step.Reason, werr)
}
step.State, step.Reason, step.Result, step.OutputObject = sdkstep.StepFailed, why, nil, ""
if ferr := writeEnd(put, key, step); ferr != nil {
if logf != nil {
logf("the failed end of the step %s could not be written either: %v", key, ferr)
}
return step, fmt.Errorf("steprun: no end of the step %s could be written: %w", key, ferr)
}
return step, nil
}
// keepOver is step as its key keeps it (design 47 G A-290): a value within what the steps state holds per value
// (kvlimit.ModuleStateValueBytes less the headers' room) keeps its answer itself; over it, the answer is kept with keep as the
// object sdkstep.OutputObjectName(step.ID), which the key names as its output-object: a run's output, the key keeping the
// run answer without it, else the whole answer, the key's result then empty. A step whose answer cannot be kept ends failed,
// saying why, with no answer, so it never reads passed where its answer is lost.
func keepOver(keep Keep, step sdkstep.Step, result any) sdkstep.Step {
raw, err := sdkstep.EncodeStep(step)
if err != nil || len(step.Result) == 0 || kvlimit.Check("steps", "", len(raw), kvlimit.ModuleStateValueBytes) == nil {
return step
}
object, body, rest := sdkstep.OutputObjectName(step.ID), []byte(step.Result), json.RawMessage(nil)
if ans, ok := runAnswerIn(result); ok {
body, ans.Output = ans.Output, nil
if rest, err = json.Marshal(ans); err != nil {
rest = nil
}
}
lost := func(why string) sdkstep.Step {
if step.Reason != "" {
why = step.Reason + "; " + why
}
step.State, step.Reason, step.Result, step.Passing = sdkstep.StepFailed, why, nil, false
return step
}
if keep == nil {
return lost(fmt.Sprintf("the step's answer makes its value %d bytes, over what the steps state holds, and this holder keeps no object for it", len(raw)))
}
if err := keep(object, body); err != nil {
return lost(fmt.Sprintf("the step's answer makes its value %d bytes, over what the steps state holds, and could not be kept as the object %s: %v", len(raw), object, err))
}
step.Result, step.OutputObject = rest, object
return step
}
// exitedWhy is why a run whose program exited other than 0 failed: its exit status and the first line of its standard error.
func exitedWhy(ans sdkstep.RunAnswer) string {
return fmt.Sprintf("the program exited %d: %s", ans.Exit, ans.Said)
}
// runAnswerIn is the run answer a step handed back, whatever its form (the value or a pointer).
func runAnswerIn(result any) (sdkstep.RunAnswer, bool) {
switch a := result.(type) {
case sdkstep.RunAnswer:
return a, true
case *sdkstep.RunAnswer:
if a != nil {
return *a, true
}
}
return sdkstep.RunAnswer{}, false
}
// again writes the step as given again after a transport fault: the state again with the run's cause, and when the bus gives
// the ask again (ADR 0664 §1). The key is not ended: its asker keeps waiting on it.
func again(put Put, key string, step sdkstep.Step, err error, delay time.Duration) error {
step.State, step.Reason, step.Passing, step.Result, step.OutputObject = sdkstep.StepAgain, err.Error(), true, nil, ""
step.Ended, step.AgainAt = time.Time{}, now().UTC().Add(delay)
return writeEnd(put, key, step)
}
// writeEnd writes the step's end, trying EndAttempts times.
func writeEnd(put Put, key string, step sdkstep.Step) error {
for attempt := 1; ; attempt++ {
werr := write(put, key, step)
if werr == nil || attempt >= EndAttempts {
return werr
}
time.Sleep(EndRetry)
}
}
// protect is run with a panic turned into the error that ends the step.
func protect(ctx context.Context, run func(ctx context.Context) (any, error)) (result any, err error) {
defer func() {
if p := recover(); p != nil {
result, err = nil, fmt.Errorf("the step panicked: %v", p)
}
}()
return run(ctx)
}
func write(put Put, key string, step sdkstep.Step) error {
raw, err := sdkstep.EncodeStep(step)
if err != nil {
return err
}
_, err = put(key, json.RawMessage(raw))
return err
}