From 9f9e743b2f7ac5a7a8c03046654cebb0bc7e3aa4 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 19:47:42 +0100 Subject: [PATCH 1/3] The indexer started during its replacement fixes the successor's build; a start that decides nothing says so; a failing head is said once (issue 1921) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-indexer/named_test.go | 13 +++++++++++ .../cmd/mesh-delivery-indexer/ownbuild.go | 11 +++++++++ .../internal/index/candidates.go | 1 + .../internal/index/packages.go | 15 +++++++++++- .../internal/index/pending.go | 8 +++++++ .../internal/index/pending_test.go | 12 ++++++++++ .../internal/index/pending_tools.go | 11 ++++++--- .../internal/index/previews_test.go | 23 +++++++++++++++++++ .../internal/index/verdicts.go | 1 + .../mesh-delivery-indexer/takein/takein.go | 2 ++ 10 files changed, 93 insertions(+), 4 deletions(-) diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go index 8db30456..ed7c21af 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go @@ -59,6 +59,19 @@ func TestTheTrunkIsTheDirectoriesTheForgeListsTrees(t *testing.T) { // 1900/AC3 if fp, err := ownBuildVia(own, "n1")(context.Background()); err != nil || fp != "src2:abc" || asked != "seat:node-engine.composition@n1" { t.Errorf("own build = %q %v asked %q", fp, err, asked) } + // Issue 1921: the start during a replacement. What runs still names the old build, the want (composed by the node itself) + // the successor, which this process is: it fixes the successor. A want that is not self, or is removed, leaves what runs. + for _, row := range []struct{ name, answer, want string }{ + {"replacing: runs old, want new and self", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true}}`, "src2:new"}, + {"a want the node does not compose", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new"}}`, "src2:old"}, + {"a removed want", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true,"removed":true}}`, "src2:old"}, + {"no build runs yet, want self", `{"want":{"fp":"src2:new","self":true}}`, "src2:new"}, + } { + ask := func(string, any) (json.RawMessage, error) { return json.RawMessage(row.answer), nil } + if fp, err := ownBuildVia(ask, "n1")(context.Background()); err != nil || fp != row.want { + t.Errorf("%s: own build = %q %v, want %q", row.name, fp, err, row.want) + } + } none := func(string, any) (json.RawMessage, error) { return json.RawMessage(`{}`), nil } if _, err := ownBuildVia(none, "n1")(context.Background()); err == nil { t.Error("an engine that says nothing runs gave a build") diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go index 79a31448..cf7bb311 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go @@ -25,10 +25,21 @@ func ownBuildVia(call toolCaller, node string) func(ctx context.Context) (string Runs *struct { FP string `json:"fp"` } `json:"runs"` + Want *struct { + FP string `json:"fp"` + Self bool `json:"self"` + Removed bool `json:"removed"` + } `json:"want"` } if err := json.Unmarshal(raw, &a); err != nil { return "", fmt.Errorf("the answer of %s is not a composition: %w", compositionVerb, err) } + // While the engine replaces the indexer, this process is the successor: what runs still names the old build, and the want, + // composed by the node itself (self), names this one. The start fixes that build (quick fix, ADR 0639 §7: nothing is learnt + // later and nothing waits on a timer); a process of the old build that is started again in that window would take it too. + if a.Want != nil && a.Want.Self && !a.Want.Removed && a.Want.FP != "" { + return a.Want.FP, nil + } if a.Runs == nil || a.Runs.FP == "" { return "", errors.New("the engine says no fingerprint of the indexer runs") } diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index b8bfc4df..46f7d37d 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -121,6 +121,7 @@ type Indexer struct { filling atomic.Bool // a FillTaken runs in the background (KickFill) fillAgain atomic.Bool // a kick arrived while it ran atHeads map[string]map[string]delivery.PublishedPackages // a member head's published Go modules, by repository@head (candidatePublished) + failedAt map[string]string // the head of a repository whose packages the forge could not list, by repository: said once, not asked again until the head changes // OwnBuild answers the fingerprint of the build this program runs, from the node-engine's seat; nil knows none. OwnBuild func(ctx context.Context) (string, error) underSeats bool // a dry run inside judge.WithSeats: its judge passes do not hold the seat list (verdicts.go) diff --git a/modules/mesh-delivery-indexer/internal/index/packages.go b/modules/mesh-delivery-indexer/internal/index/packages.go index de1f0e73..b7cb7a6d 100644 --- a/modules/mesh-delivery-indexer/internal/index/packages.go +++ b/modules/mesh-delivery-indexer/internal/index/packages.go @@ -297,6 +297,12 @@ func (ix *Indexer) candidatePublished(ctx context.Context, members map[string]st ix.mu.Lock() got, ok := ix.atHeads[at] ix.mu.Unlock() + ix.mu.Lock() + failed := ix.failedAt[repo] == head + ix.mu.Unlock() + if failed { + continue // said once for this head; the candidate takes the published version until the head changes + } if !ok { var ms []string ms = append(ms, modules[repo]...) @@ -307,10 +313,17 @@ func (ix *Indexer) candidatePublished(ctx context.Context, members map[string]st } var err error if got, err = ix.packagesAt(ctx, repo, head, ms, imports[repo]); err != nil { - ix.logf("%s at the head %s: its packages are not read, so the candidate takes their published version: %v", repo, head, err) + ix.logf("%s at the head %s: its packages are not read, so the candidate takes their published version (said once for this head): %v", repo, head, err) + ix.mu.Lock() + if ix.failedAt == nil { + ix.failedAt = map[string]string{} + } + ix.failedAt[repo] = head + ix.mu.Unlock() continue } ix.mu.Lock() + delete(ix.failedAt, repo) if ix.atHeads == nil || len(ix.atHeads) >= maxHeadsKept { ix.atHeads = map[string]map[string]delivery.PublishedPackages{} } diff --git a/modules/mesh-delivery-indexer/internal/index/pending.go b/modules/mesh-delivery-indexer/internal/index/pending.go index cd34f376..ccc5673f 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending.go +++ b/modules/mesh-delivery-indexer/internal/index/pending.go @@ -559,6 +559,10 @@ func (ix *Indexer) TakeInAtStart(ctx context.Context) (Applied, string, error) { if errors.Is(err, process.ErrNotYet) { return a, "the pending state is not on the bus yet: nothing waits", nil } + if err == nil && len(a.Written) == 0 { + // A start that takes nothing in says so, with the build it learnt (the wait's own build is in the line holdFor says). + ix.logf("take-in: at the start, this program runs build %s and took nothing in: %s", own, words) + } return a, words, err } @@ -978,6 +982,10 @@ func (ix *Indexer) holdFor(ctx context.Context, ready []PendingEntry, names, own for _, old := range saidWaits(ended) { ix.sayTakeIn(ctx, old) } + if said && !changed { + // Nothing to say on the bus, but the start decided nothing and a reader of the journal sees why: the build learnt, the build the wait names. + ix.logf("take-in: %d merges, oldest %s, still name the indexer build %s, this program runs %s: they wait, as already said", len(ready), t.Key, names, own) + } if !said || changed { ix.logf("take-in: %d merges, oldest %s, name the indexer build %s, not %s, which runs: they wait for it", len(ready), t.Key, names, own) if refused != "" { diff --git a/modules/mesh-delivery-indexer/internal/index/pending_test.go b/modules/mesh-delivery-indexer/internal/index/pending_test.go index 7f787eb7..f750a58c 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending_test.go +++ b/modules/mesh-delivery-indexer/internal/index/pending_test.go @@ -281,6 +281,18 @@ func TestTakeIn(t *testing.T) { // 1585/T3-T13 (ADR 0633) n.FixOwnBuild(ctx) return n } + // A start that decides nothing says so (issue 1921): the build it learnt, the build the held wait names, also when the + // wait was said before; and indexer_pending names the known build and since when. + oldBuild, _ := b.ix.OwnBuild(ctx) + oldStart := mk(func(context.Context) (string, error) { return oldBuild, nil }) + b.logs.Reset() + if _, words, err := oldStart.TakeInAtStart(ctx); err != nil || len(b.keys()) != 2 || !strings.Contains(b.logs.String(), "still name the indexer build") || + !strings.Contains(b.logs.String(), "at the start, this program runs build") { + t.Fatalf("a start that holds: %q %v, journal %q: want the build learnt and the build the wait names said", words, err, b.logs.String()) + } + if answer, err := PendingTools(b.pending, b.ix.OwnBuildState, nil, nil)[0].Run(map[string]any{}); err != nil || answer.(takein.PendingAnswer).OwnBuild != oldBuild || answer.(takein.PendingAnswer).OwnBuildSince.IsZero() { + t.Errorf("indexer_pending = %+v %v, want the known build and since", answer, err) + } asked := 0 next := mk(func(context.Context) (string, error) { asked++; return "", errors.New("not yet") }) if r := next.TakeInPendingByHand(ctx, "m.nothing", "lead@shanks", "a test"); r.Stored || !strings.Contains(r.Answer, "nothing waits") { diff --git a/modules/mesh-delivery-indexer/internal/index/pending_tools.go b/modules/mesh-delivery-indexer/internal/index/pending_tools.go index 733cb70a..f0d24c1a 100644 --- a/modules/mesh-delivery-indexer/internal/index/pending_tools.go +++ b/modules/mesh-delivery-indexer/internal/index/pending_tools.go @@ -53,7 +53,7 @@ func PendingTools(pending, ownBuild Store, ask func(key string, value []byte) (T "merges, since when it waits, the modules it holds back (holds), the merges whose message the forge bridge's stream no " + "longer holds (lost: they are not taken in; indexer_drop_pending drops them) and the last take-in event said of it. Empty waits: " + "every merge is taken in. The watchman raises take-in-waits past 60 minutes for a revision, 30 for a group; " + - "indexer_take_in_pending and indexer_drop_pending steer a wait. own-build-unknown, when set, says the indexer cannot learn the build it " + + "indexer_take_in_pending and indexer_drop_pending steer a wait. own-build says which build the indexer knows it runs and own-build-since since when; own-build-unknown, when set, says the indexer cannot learn the build it " + "runs (since when): it fixes it once at its start, so every merge waits (a take-in.pending that says so) until the module is started again.", Input: map[string]any{"type": "object", "properties": map[string]any{}}, Run: func(map[string]any) (any, error) { @@ -69,8 +69,13 @@ func PendingTools(pending, ownBuild Store, ask func(key string, value []byte) (T if ownBuild != nil { if v, _, found, err := ownBuild.Get(context.Background(), OwnBuildKey); err == nil && found { var o OwnBuild - if json.Unmarshal(v, &o) == nil && !o.Known { - answer.OwnBuildUnknown, answer.OwnBuildSince = o.Why, o.Since + if json.Unmarshal(v, &o) == nil { + answer.OwnBuildSince = o.Since + if o.Known { + answer.OwnBuild = o.FP + } else { + answer.OwnBuildUnknown = o.Why + } } } } diff --git a/modules/mesh-delivery-indexer/internal/index/previews_test.go b/modules/mesh-delivery-indexer/internal/index/previews_test.go index 3edcb9ef..63060d55 100644 --- a/modules/mesh-delivery-indexer/internal/index/previews_test.go +++ b/modules/mesh-delivery-indexer/internal/index/previews_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "runtime" "slices" @@ -404,6 +405,28 @@ func TestAPreviewHeardBeforeItsPullRequestGivesTheFingerprintWhenTheCandidateIsW } }) } + // Issue 1921: a head whose packages the forge cannot list is said once and asked once, however many passes read it; the + // entry is dropped when the repository's head changes, and the new head is asked. + t.Run("a failing head is said and asked once, until the head changes", func(t *testing.T) { + ix, _, _ := previewBed(t, false) + var logs strings.Builder + ix.Logf = func(format string, args ...any) { logs.WriteString(fmt.Sprintf(format, args...) + "\n") } + asked := 0 + ix.Files = func(context.Context, string, string, []string) (map[string]map[string]string, error) { + asked++ + return nil, errors.New("the forge does not answer") + } + held := map[string]delivery.Module{"x": {Inputs2: &delivery.KeptInputs2{Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}}} + for _, head := range []string{h1, h1, h2} { + ix.candidatePublished(context.Background(), map[string]string{"novox/mesh-sdk": head}, held) + if head == h1 && (asked != 1 || strings.Count(logs.String(), "its packages are not read") != 1) { + t.Fatalf("after head %s: asked %d, journal %q: want one ask and one line", head, asked, logs.String()) + } + } + if asked != 2 || strings.Count(logs.String(), "its packages are not read") != 2 { + t.Errorf("after a new head: asked %d, journal %q: want it asked and said again", asked, logs.String()) + } + }) } func TestAPreviewNamingNoManifestOfTheModulesDirectoryGivesAReason(t *testing.T) { diff --git a/modules/mesh-delivery-indexer/internal/index/verdicts.go b/modules/mesh-delivery-indexer/internal/index/verdicts.go index 1758f740..7f931a39 100644 --- a/modules/mesh-delivery-indexer/internal/index/verdicts.go +++ b/modules/mesh-delivery-indexer/internal/index/verdicts.go @@ -396,6 +396,7 @@ func (ix *Indexer) FixOwnBuild(ctx context.Context) { ix.logf("judge: the build this program runs is not known (%v), so every merge waits and a manifest with a field it does not know waits and is not refused; it is learnt only at the start, so the indexer is started again", err) } else { st.Known, st.FP = true, fp + ix.logf("judge: this program runs the indexer build %s, fixed here at its start", fp) } ix.ownMu.Lock() ix.ownFP, ix.ownSince, ix.ownWhy = fp, st.Since, st.Why diff --git a/modules/mesh-delivery-indexer/takein/takein.go b/modules/mesh-delivery-indexer/takein/takein.go index af9ba44f..4ad0ca9e 100644 --- a/modules/mesh-delivery-indexer/takein/takein.go +++ b/modules/mesh-delivery-indexer/takein/takein.go @@ -254,6 +254,8 @@ type PendingAnswer struct { // (a take-in.pending with the same words) and the indexer is started again to learn it. Empty when the build is known. OwnBuildUnknown string `json:"own-build-unknown,omitempty"` OwnBuildSince time.Time `json:"own-build-since,omitzero"` + // OwnBuild is the build the running indexer knows it runs, fixed at its start; OwnBuildSince is since when. Empty when unknown. + OwnBuild string `json:"own-build,omitempty"` } // DecodePendingAnswer reads indexer_pending's answer, refusing a wait that is not a revision or a group, or names no From 78d57e1cc6c839f83e40dc06961126dd78504bd6 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 19:49:27 +0100 Subject: [PATCH 2/3] A transient forge failure is asked again; only a directory missing from the tree is cached per head (issue 1921) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-indexer/named.go | 2 +- .../cmd/mesh-delivery-indexer/named_test.go | 10 ++++ .../internal/index/candidates.go | 3 +- .../internal/index/packages.go | 25 +++++++-- .../internal/index/previews_test.go | 53 +++++++++++-------- 5 files changed, 66 insertions(+), 27 deletions(-) diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named.go index 791ca492..56b8ff07 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named.go @@ -102,7 +102,7 @@ func filesVia(call toolCaller) func(ctx context.Context, repository, sha string, } for _, d := range dirs { if _, ok := a.Files[d]; !ok { - return nil, fmt.Errorf("the answer of %s lists no files of %q", commitTreesTool, d) + return nil, fmt.Errorf("the answer of %s lists no files of %q: %w", commitTreesTool, d, index.ErrNotInTree) } } return a.Files, nil diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go index ed7c21af..e8153b5c 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "mesh-delivery-indexer/internal/index" "slices" "strings" "testing" @@ -50,6 +51,15 @@ func TestTheTrunkIsTheDirectoriesTheForgeListsTrees(t *testing.T) { // 1900/AC3 if _, has := gotArgs.(map[string]any)["paths"]; has { t.Error("the trunk asked for paths") } + // A directory the answer lists no files of is a failure of the tree (index.ErrNotInTree); a call that fails is not. + noDir := func(string, any) (json.RawMessage, error) { return json.RawMessage(`{"files":{}}`), nil } + if _, err := filesVia(noDir)(context.Background(), "novox/mesh-sdk", strings.Repeat("a", 40), []string{"go"}); !errors.Is(err, index.ErrNotInTree) { + t.Errorf("a missing directory: %v, want ErrNotInTree", err) + } + down := func(string, any) (json.RawMessage, error) { return nil, errors.New("down") } + if _, err := filesVia(down)(context.Background(), "novox/mesh-sdk", strings.Repeat("a", 40), []string{"go"}); err == nil || errors.Is(err, index.ErrNotInTree) { + t.Errorf("a failed call: %v, want an error that is not ErrNotInTree", err) + } // The build the indexer runs is the engine's word (Runs.FP of its composition verb), asked of its own machine. var asked string own := func(key string, body any) (json.RawMessage, error) { diff --git a/modules/mesh-delivery-indexer/internal/index/candidates.go b/modules/mesh-delivery-indexer/internal/index/candidates.go index 46f7d37d..3e55a248 100644 --- a/modules/mesh-delivery-indexer/internal/index/candidates.go +++ b/modules/mesh-delivery-indexer/internal/index/candidates.go @@ -121,7 +121,8 @@ type Indexer struct { filling atomic.Bool // a FillTaken runs in the background (KickFill) fillAgain atomic.Bool // a kick arrived while it ran atHeads map[string]map[string]delivery.PublishedPackages // a member head's published Go modules, by repository@head (candidatePublished) - failedAt map[string]string // the head of a repository whose packages the forge could not list, by repository: said once, not asked again until the head changes + saidAt map[string]string // the head of a repository whose failed read was said, by repository: said once per head + failedAt map[string]string // the head of a repository whose tree lacks a directory asked, by repository: not asked again until the head changes // OwnBuild answers the fingerprint of the build this program runs, from the node-engine's seat; nil knows none. OwnBuild func(ctx context.Context) (string, error) underSeats bool // a dry run inside judge.WithSeats: its judge passes do not hold the seat list (verdicts.go) diff --git a/modules/mesh-delivery-indexer/internal/index/packages.go b/modules/mesh-delivery-indexer/internal/index/packages.go index b7cb7a6d..bda1e48e 100644 --- a/modules/mesh-delivery-indexer/internal/index/packages.go +++ b/modules/mesh-delivery-indexer/internal/index/packages.go @@ -2,6 +2,7 @@ package index import ( "context" + "errors" "fmt" "maps" "path" @@ -313,17 +314,29 @@ func (ix *Indexer) candidatePublished(ctx context.Context, members map[string]st } var err error if got, err = ix.packagesAt(ctx, repo, head, ms, imports[repo]); err != nil { - ix.logf("%s at the head %s: its packages are not read, so the candidate takes their published version (said once for this head): %v", repo, head, err) + // A failure of the tree itself (the directory is not in the tree at that head) no retry changes: it is asked once per + // head. Any other failure may be transient: it is asked again at the next pass, and said once per head. ix.mu.Lock() - if ix.failedAt == nil { - ix.failedAt = map[string]string{} + said := ix.saidAt[repo] == head + if ix.saidAt == nil { + ix.saidAt = map[string]string{} + } + ix.saidAt[repo] = head + if errors.Is(err, ErrNotInTree) { + if ix.failedAt == nil { + ix.failedAt = map[string]string{} + } + ix.failedAt[repo] = head } - ix.failedAt[repo] = head ix.mu.Unlock() + if !said { + ix.logf("%s at the head %s: its packages are not read, so the candidate takes their published version: %v", repo, head, err) + } continue } ix.mu.Lock() delete(ix.failedAt, repo) + delete(ix.saidAt, repo) if ix.atHeads == nil || len(ix.atHeads) >= maxHeadsKept { ix.atHeads = map[string]map[string]delivery.PublishedPackages{} } @@ -335,5 +348,9 @@ func (ix *Indexer) candidatePublished(ctx context.Context, members map[string]st return out } +// ErrNotInTree is a failure of the tree itself: a directory asked of the forge is not in the tree at that commit. No retry +// changes it, so a head that fails so is asked once (candidatePublished). +var ErrNotInTree = errors.New("the directory is not in the tree at that commit") + // maxHeadsKept bounds the member heads' packages kept between recomputes; past it they are read again. const maxHeadsKept = 64 diff --git a/modules/mesh-delivery-indexer/internal/index/previews_test.go b/modules/mesh-delivery-indexer/internal/index/previews_test.go index 63060d55..1d95b041 100644 --- a/modules/mesh-delivery-indexer/internal/index/previews_test.go +++ b/modules/mesh-delivery-indexer/internal/index/previews_test.go @@ -405,28 +405,39 @@ func TestAPreviewHeardBeforeItsPullRequestGivesTheFingerprintWhenTheCandidateIsW } }) } - // Issue 1921: a head whose packages the forge cannot list is said once and asked once, however many passes read it; the - // entry is dropped when the repository's head changes, and the new head is asked. - t.Run("a failing head is said and asked once, until the head changes", func(t *testing.T) { - ix, _, _ := previewBed(t, false) - var logs strings.Builder - ix.Logf = func(format string, args ...any) { logs.WriteString(fmt.Sprintf(format, args...) + "\n") } - asked := 0 - ix.Files = func(context.Context, string, string, []string) (map[string]map[string]string, error) { - asked++ - return nil, errors.New("the forge does not answer") - } - held := map[string]delivery.Module{"x": {Inputs2: &delivery.KeptInputs2{Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}}} - for _, head := range []string{h1, h1, h2} { - ix.candidatePublished(context.Background(), map[string]string{"novox/mesh-sdk": head}, held) - if head == h1 && (asked != 1 || strings.Count(logs.String(), "its packages are not read") != 1) { - t.Fatalf("after head %s: asked %d, journal %q: want one ask and one line", head, asked, logs.String()) + // Issue 1921: a head whose tree lacks the directory asked is asked once, however many passes read it, until the head changes; + // any other failure (the forge does not answer) is asked again at every pass and said once per head. + for _, row := range []struct { + name string + err error + askedAfterTwo int // the same head read twice + askedAfterNew int // then another head + linesAfterNew int + }{ + {"the directory is not in the tree", fmt.Errorf("listing: %w", ErrNotInTree), 1, 2, 2}, + {"the forge does not answer", errors.New("the forge does not answer"), 2, 3, 2}, + } { + t.Run("a failing head: "+row.name, func(t *testing.T) { + ix, _, _ := previewBed(t, false) + var logs strings.Builder + ix.Logf = func(format string, args ...any) { logs.WriteString(fmt.Sprintf(format, args...) + "\n") } + asked := 0 + ix.Files = func(context.Context, string, string, []string) (map[string]map[string]string, error) { + asked++ + return nil, row.err } - } - if asked != 2 || strings.Count(logs.String(), "its packages are not read") != 2 { - t.Errorf("after a new head: asked %d, journal %q: want it asked and said again", asked, logs.String()) - } - }) + held := map[string]delivery.Module{"x": {Inputs2: &delivery.KeptInputs2{Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}}} + for pass, head := range []string{h1, h1, h2} { + ix.candidatePublished(context.Background(), map[string]string{"novox/mesh-sdk": head}, held) + if pass == 1 && (asked != row.askedAfterTwo || strings.Count(logs.String(), "its packages are not read") != 1) { + t.Fatalf("after two passes over %s: asked %d (want %d), journal %q: want one line", head, asked, row.askedAfterTwo, logs.String()) + } + } + if asked != row.askedAfterNew || strings.Count(logs.String(), "its packages are not read") != row.linesAfterNew { + t.Errorf("asked %d (want %d), journal %q (want %d lines)", asked, row.askedAfterNew, logs.String(), row.linesAfterNew) + } + }) + } } func TestAPreviewNamingNoManifestOfTheModulesDirectoryGivesAReason(t *testing.T) { From e3735da928f4b405624478263eef7ccb32ca2f23 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 5 Nov 2026 19:53:05 +0100 Subject: [PATCH 3/3] The indexer takes the engine's want as its build only while the apply is in progress (issue 1921) Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i --- .../cmd/mesh-delivery-indexer/named_test.go | 3 +++ .../cmd/mesh-delivery-indexer/ownbuild.go | 21 ++++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go index e8153b5c..2c5cfb13 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/named_test.go @@ -74,6 +74,9 @@ func TestTheTrunkIsTheDirectoriesTheForgeListsTrees(t *testing.T) { // 1900/AC3 for _, row := range []struct{ name, answer, want string }{ {"replacing: runs old, want new and self", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true}}`, "src2:new"}, {"a want the node does not compose", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new"}}`, "src2:old"}, + {"replacing: the apply of the want failed or is held", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true},"attempt":{"fp":"src2:new","held":true}}`, "src2:old"}, + {"replacing: an attempt of another build", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true},"attempt":{"fp":"src2:older"}}`, "src2:new"}, + {"the want already runs", `{"runs":{"fp":"src2:new"},"want":{"fp":"src2:new","self":true}}`, "src2:new"}, {"a removed want", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true,"removed":true}}`, "src2:old"}, {"no build runs yet, want self", `{"want":{"fp":"src2:new","self":true}}`, "src2:new"}, } { diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go index cf7bb311..3491b443 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer/ownbuild.go @@ -30,15 +30,26 @@ func ownBuildVia(call toolCaller, node string) func(ctx context.Context) (string Self bool `json:"self"` Removed bool `json:"removed"` } `json:"want"` + // Attempt is the engine's last apply that did not end running (failed, or held), with the build it was of. + Attempt *struct { + FP string `json:"fp"` + } `json:"attempt"` } if err := json.Unmarshal(raw, &a); err != nil { return "", fmt.Errorf("the answer of %s is not a composition: %w", compositionVerb, err) } - // While the engine replaces the indexer, this process is the successor: what runs still names the old build, and the want, - // composed by the node itself (self), names this one. The start fixes that build (quick fix, ADR 0639 §7: nothing is learnt - // later and nothing waits on a timer); a process of the old build that is started again in that window would take it too. - if a.Want != nil && a.Want.Self && !a.Want.Removed && a.Want.FP != "" { - return a.Want.FP, nil + // While the engine's apply of the want is in progress (what runs still names the old build, the want names another, and no + // attempt names that want as failed or held), this process is the successor and fixes the want's build. self is the node's + // standing way of composing the module, not a mark of this process, so it is not enough alone: an old process started again + // while the want names a successor that does not run (its apply failed or is held) takes what runs. The start fixes the + // build once (quick fix, ADR 0639 §7: nothing is learnt later, nothing waits on a timer); the thorough form is the + // engine passing each process the fingerprint it starts it with. + running := "" + if a.Runs != nil { + running = a.Runs.FP + } + if w := a.Want; w != nil && w.Self && !w.Removed && w.FP != "" && w.FP != running && (a.Attempt == nil || a.Attempt.FP != w.FP) { + return w.FP, nil } if a.Runs == nil || a.Runs.FP == "" { return "", errors.New("the engine says no fingerprint of the indexer runs")