From 0dc0cf9c9a6ae0e5680b40ea0a67055e9e21f64e Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 12:55:45 +0100 Subject: [PATCH] DLV-B9: the old reference form's window over sdk/forms, kept in old-form, read and dropped by builder_old_form The window opens while a counted node-engine reads, of some kind, none of the newest form sdk/forms knows, and closes when none does: said once as version.old-form-closed, and a node holding it seven days is old-form-held about it. It steps on a statement's change, the placement, a hand drop and each build key written, never on a timer. A retired node dropped by hand leaves the count of the forms a build key keeps (design 51). internal/versions' own Forms goes: which forms a key keeps is sdk/forms'. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- .../cmd/mesh-delivery-builder-tools/main.go | 22 +- .../cmd/mesh-delivery-builder/main.go | 24 +- .../cmd/mesh-delivery-builder/oldform.go | 139 +++++++++++ .../cmd/mesh-delivery-builder/reads.go | 43 +++- .../cmd/mesh-delivery-builder/reasks.go | 10 +- .../cmd/mesh-delivery-builder/refused_test.go | 38 +++- .../internal/build/stuck.go | 18 ++ .../internal/versions/again.go | 2 + .../internal/versions/oldform.go | 215 ++++++++++-------- .../internal/versions/versions_test.go | 63 ++--- .../mesh-delivery-builder/manifest_test.go | 4 +- modules/mesh-delivery-builder/module.json | 2 + 12 files changed, 441 insertions(+), 139 deletions(-) create mode 100644 modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go index cba63ec1..e82ab670 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder-tools/main.go @@ -34,7 +34,7 @@ type queueAsker func(key string, value []byte) (json.RawMessage, error) // the builder's read, buildAgain writes a reasks value of mode build and waits for the builder's answer. // tags writes a tag-asks value and waits for the builder's answer; ta reads tag-asks. func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, queue, buildAgain queueAsker, since build.LogSince, now func() time.Time, vs versions.Reader, - tags queueAsker, ta, fw versions.Reader) []stdio.Tool { + tags queueAsker, ta, fw, of versions.Reader) []stdio.Tool { str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) } // handAct writes a reasks value of a versions hand act and answers the builder's answer, or its refusal as an error. handAct := func(key string, value []byte, err error) (any, error) { @@ -104,6 +104,16 @@ func Tools(r build.StuckReader, askAgain func(key string, value []byte) error, q Input: map[string]any{"type": "object", "properties": map[string]any{ "repository": map[string]any{"type": "string", "description": "only this repository's asks, /"}}}, Run: func(args map[string]any) (any, error) { return versions.AnswerTagAsks(ta, str(args, "repository")) }}, + {Name: versions.OldFormTool, Description: versions.OldFormDescription, + Input: map[string]any{"type": "object", "properties": map[string]any{ + "drop": map[string]any{"type": "string", "description": "a retired node to drop from the count by hand"}, + "why": map[string]any{"type": "string", "description": "with drop: why, in plain words"}}}, + Run: func(args map[string]any) (any, error) { + if node := str(args, "drop"); node != "" { + return handAct(build.NewOldFormDrop(node, str(args, "why"), stdio.CallerOf(args), now())) + } + return versions.AnswerOldForm(of) + }}, {Name: versions.FloorWaitsTool, Description: versions.FloorWaitsDescription, Input: map[string]any{"type": "object", "properties": map[string]any{}}, Run: func(map[string]any) (any, error) { return versions.AnswerFloorWaits(fw) }}, @@ -378,7 +388,15 @@ func main() { } return e.Value, true, nil }} - tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now, vr, tags, tr, fr) + ofs := stdio.State(versions.OldFormState) + ofr := versions.Reader{Keys: ofs.Keys, Get: func(key string) (json.RawMessage, bool, error) { + e, err := ofs.Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil + }} + tools := Tools(r, put, askQueue(stdio.State(build.QueuesState), 25*time.Second), buildAgain, stdio.Log(delivery.BuilderLog).Since, time.Now, vr, tags, tr, fr, ofr) // What each counted node's engine reads, as the builder keeps it (ADR 0618 §2, ADR 0624 §1): listed, one node asked // again, or one node's key dropped. tools = append(tools, enginereads.Tool(EngineReadsTool, "the builder", enginereads.KeptState(stdio.State("engine-reads")), true, time.Now)) diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go index 0c8b865d..52fd9342 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/main.go @@ -19,6 +19,7 @@ import ( "time" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" + "git.novox.be/novox/mesh-delivery/sdk/forms" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -207,8 +208,29 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro // What each counted node's engine reads (ADR 0618 §2, ADR 0624 §1): asked through the node-engine seat's verb as the // builder's own account, for every node the placement names, and kept in the state engine-reads once it is bound. reads := newEngineReads(calls.CallOn, gate, logger.Printf) + // The old reference form's window over them (DLV-B9), kept in the state old-form once it is bound; builder_old_form + // reads it and drops a retired node by hand. + reads.ctx = context.WithoutCancel(ctx) + reads.window = &oldForm{forms: forms.Current, findings: found, logf: logger.Printf, now: time.Now, + said: func(body versions.OldFormClosedBody) { + if err := emitEvent(context.WithoutCancel(ctx), js, cred, versions.OldFormClosedEvent, "old-form-closed-"+body.At.Format(time.RFC3339Nano), body); err != nil { + logger.Printf("saying %s: %v", versions.OldFormClosedEvent, err) + } + }} + acts.DropOldForm = func(ctx context.Context, node, by, why string, at time.Time) (string, error) { + answer, err := reads.window.drop(ctx, node, by, why, at) + if err == nil { + reads.stepWindow() + } + return answer, err + } + process.AwaitBucket(ctx, js, conn, versions.OldFormBucket, "the old form's window is kept in memory only, and builder_old_form shows none", logger.Printf). + Ready(func(kv jetstream.KeyValue) { + reads.window.bind(context.WithoutCancel(ctx), kv) + reads.stepWindow() + }) gate.OnPlaced = reads.placed - b.Forms = reads.forms + b.Forms = reads.built placementErrs, err := buildrun.FollowPlacement(ctx, js, gate, b.NodesChanged, logger.Printf) if err != nil { return err diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go new file mode 100644 index 00000000..63fa95d2 --- /dev/null +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/oldform.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "sync" + "time" + + "github.com/nats-io/nats.go/jetstream" + + "git.novox.be/novox/mesh-sdk/go/health" + + "git.novox.be/novox/mesh-delivery/sdk/forms" + + "mesh-delivery-builder/internal/versions" +) + +// oldForm keeps the old reference form's window (design 51 *Every build keeps the old reference form*, DLV-B9) in the +// state old-form: stepped on every change of what the counted nodes state, of the placement, on a hand drop and at each +// build key written, never on a timer. A node holding it seven days is the finding old-form-held; its close is said once +// as version.old-form-closed and in the builder's log. +type oldForm struct { + forms forms.ArtifactForms + findings *findings + // said emits version.old-form-closed. + said func(versions.OldFormClosedBody) + logf func(string, ...any) + now func() time.Time + + mu sync.Mutex + w versions.OldFormWindow + kv jetstream.KeyValue + // overdue are the nodes the finding is raised for now. + overdue []string +} + +// bind takes the kept window in, so a restarted builder keeps each node's since and the nodes dropped by hand, and keeps +// every later step in kv. +func (o *oldForm) bind(ctx context.Context, kv jetstream.KeyValue) { + e, err := kv.Get(ctx, versions.OldFormKey) + o.mu.Lock() + defer o.mu.Unlock() + switch { + case errors.Is(err, jetstream.ErrKeyNotFound): + case err != nil: + o.logf("%s.%s cannot be read, so the window starts again: %v", versions.OldFormState, versions.OldFormKey, err) + default: + w, err := versions.DecodeOldFormWindow(e.Value()) + if err != nil { + o.logf("%v; the window starts again", err) + break + } + // Taken in under what was stepped before the state was bound: the kept since and drops are the older truth. + for n, s := range o.w.Since { + if _, ok := w.Since[n]; !ok && w.Open && slices.Contains(w.Holding, n) { + w.Since[n] = s + } + } + for _, d := range o.w.Dropped { + w, _ = w.Drop(d.Node, d.By, d.Why, d.At) + } + o.w = w + } + o.kv = kv + o.keep(ctx) +} + +// dropped is the nodes dropped by hand. +func (o *oldForm) dropped() map[string]bool { + o.mu.Lock() + defer o.mu.Unlock() + return o.w.DroppedSet() +} + +// step moves the window to what counted states. +func (o *oldForm) step(ctx context.Context, counted map[string][]string) { + o.mu.Lock() + defer o.mu.Unlock() + before := o.w + next, closed, overdue := o.w.Step(versions.Holding(o.forms, counted, o.w.DroppedSet()), o.now()) + o.w = next + if !slices.Equal(before.Holding, next.Holding) || before.Open != next.Open || closed { + o.keep(ctx) + } + for _, n := range o.overdue { + if !slices.Contains(overdue, n) { + o.findings.clear(ctx, versions.OldFormHeld, health.ScopeMachine, n) + } + } + for _, n := range overdue { + o.findings.raise(ctx, finding{kind: versions.OldFormHeld, scope: health.ScopeMachine, id: n, + summary: fmt.Sprintf("the node-engine of %s has read only an older reference form since %s, so every build key still carries it", + n, next.Since[n].Format(time.RFC3339)), + headline: "A node holds the old reference form", + explanation: "The builder writes an older reference form into every build key while a counted node's engine reads none of the newest (ADR 0624 §1); this node has held it seven days.", + resolved: "The node's engine states the newest form, the node leaves the placement, or it is dropped as retired.", + needs: "builder_old_form shows the window; a retired node is dropped there by hand; builder_engine_reads asks the node again."}) + } + o.overdue = overdue + if closed { + body := next.Closing() + o.logf("the old reference form's window closed at %s: no counted node-engine reads only an older form (last held by %s; dropped by hand: %d); new build keys leave it out", + body.At.Format(time.RFC3339), strings.Join(body.LastHolding, ", "), len(body.Dropped)) + if o.said != nil { + o.said(body) + } + } +} + +// drop is builder_old_form's drop: the node leaves the count, the window is stepped by the caller. +func (o *oldForm) drop(ctx context.Context, node, by, why string, at time.Time) (string, error) { + o.mu.Lock() + defer o.mu.Unlock() + w, err := o.w.Drop(node, by, why, at) + if err != nil { + return "", err + } + o.w = w + o.keep(ctx) + return node + " dropped from the old form's count by hand; the window is stepped now", nil +} + +// keep writes the window, once the state is bound; until then it is kept here only. Called with mu held. +func (o *oldForm) keep(ctx context.Context) { + if o.kv == nil { + return + } + raw, err := json.Marshal(o.w) + if err == nil { + _, err = o.kv.Put(ctx, versions.OldFormKey, raw) + } + if err != nil { + o.logf("%s.%s cannot be written: %v", versions.OldFormState, versions.OldFormKey, err) + } +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go index 42e844f1..fd426cb4 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reads.go @@ -30,11 +30,30 @@ type engineReads struct { gate *buildrun.ToolchainGate mu sync.Mutex counted []string + // window is the old reference form's window over these statements (DLV-B9); nil keeps none. + window *oldForm + ctx context.Context } // newEngineReads asks through call (process.Conn.CallOn) and keeps each answer in the state once it is bound. func newEngineReads(call enginereads.Caller, gate *buildrun.ToolchainGate, logf func(string, ...any)) *engineReads { - return &engineReads{gate: gate, keeper: &enginereads.Keeper{Call: call, Logf: logf}} + r := &engineReads{gate: gate, keeper: &enginereads.Keeper{Call: call, Logf: logf}} + r.keeper.Changed = r.stepWindow + return r +} + +// stepWindow moves the old form's window to what the counted nodes state now (design 51 *Every build keeps the old +// reference form*): on a statement's change, the placement's and a hand drop, and at each build key written. +func (r *engineReads) stepWindow() { + if r.window == nil { + return + } + ctx := r.ctx + if ctx == nil { + ctx = context.Background() + } + _, counted := r.forms() + r.window.step(ctx, counted) } // bind keeps the statements in kv and hears it: every node's last statement at the start, a person's ask again and drop @@ -78,6 +97,7 @@ func (r *engineReads) placed() { r.keeper.Forget(node) } } + r.stepWindow() } // heard takes an event of the builder's consumer on EVENTS: manifest-reads-changed asks its node again, and answers true. @@ -96,10 +116,29 @@ func (r *engineReads) heard(m jetstream.Msg, logf func(string, ...any)) bool { return true } -// forms is build.Builder.Forms: the counted nodes' statements, and those of the nodes whose engine answered. +// forms is the counted nodes' statements, and those of the nodes whose engine answered; a retired node dropped by hand +// (builder_old_form) counts no more. func (r *engineReads) forms() (present, counted map[string][]string) { r.mu.Lock() nodes := slices.Clone(r.counted) r.mu.Unlock() + if r.window != nil { + dropped := r.window.dropped() + nodes = slices.DeleteFunc(nodes, func(n string) bool { return dropped[n] }) + } return r.keeper.Present(nodes), r.keeper.Reads(nodes) } + +// built is build.Builder.Forms: forms, and the window stepped with them, so a node's seven days are seen at the next +// build key at the latest. +func (r *engineReads) built() (present, counted map[string][]string) { + present, counted = r.forms() + if r.window != nil { + ctx := r.ctx + if ctx == nil { + ctx = context.Background() + } + r.window.step(ctx, counted) + } + return present, counted +} diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go index 61b1c6a5..3e57a46a 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/reasks.go @@ -53,7 +53,7 @@ func reAsked(ctx context.Context, b askAgainer, acts versions.Acts, kv jetstream announcedAgain(ctx, b, kv, e, r, logf) return } - if r.Mode == build.ModePublishAgain || r.Mode == build.ModeFloorAgain { + if r.Mode == build.ModePublishAgain || r.Mode == build.ModeFloorAgain || r.Mode == build.ModeOldFormDrop { // A version's hand act (design 51 *Tools of the builder*) may build again, so it is answered beside the watch. go versionActed(ctx, acts, kv, e, r, logf) return @@ -156,13 +156,19 @@ func appendLog(ctx context.Context, js jetstream.JetStream, cred process.Account return nil } -// versionActed answers a reasks value of builder_publish_again or builder_floor_again, a hand act said in the log. +// versionActed answers a reasks value of builder_publish_again, builder_floor_again or builder_old_form's drop, a hand act said in the log. func versionActed(ctx context.Context, acts versions.Acts, kv jetstream.KeyValue, e jetstream.KeyValueEntry, r build.ReAsk, logf func(string, ...any)) { var err error if r.Mode == build.ModePublishAgain { var ag versions.Again ag, r.Answer, err = acts.PublishAgain(ctx, r.Name, r.FP, r.Why, r.By, r.At) r.Do, r.Version = ag.Do, ag.Entry.Version + } else if r.Mode == build.ModeOldFormDrop { + if acts.DropOldForm == nil { + err = errors.New("this builder keeps no old-form window") + } else { + r.Answer, err = acts.DropOldForm(ctx, r.Name, r.By, r.Why, r.At) + } } else { r.Answer, err = acts.FloorAgain(ctx, r.Name) } diff --git a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go index b5c1dce9..e1aed0bc 100644 --- a/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go +++ b/modules/mesh-delivery-builder/cmd/mesh-delivery-builder/refused_test.go @@ -18,9 +18,10 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" - "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun" "git.novox.be/novox/mesh-delivery/sdk/enginereads" + "git.novox.be/novox/mesh-delivery/sdk/forms" "git.novox.be/novox/mesh-sdk/go/delivery" "mesh-delivery-builder/internal/build" @@ -150,7 +151,7 @@ func (m *memReads) Get(key string) ([]byte, uint64, bool, error) { return v, 1, ok, nil } func (m *memReads) Put(key string, value []byte) (uint64, error) { m.v[key] = value; return 2, nil } -func (m *memReads) Delete(key string) error { delete(m.v, key); return nil } +func (m *memReads) Delete(key string) error { delete(m.v, key); return nil } func (m *memReads) Await(string, uint64, func([]byte) bool, time.Duration) ([]byte, error) { return nil, errors.New("no part answers here") } @@ -327,6 +328,39 @@ func aFailedTrunkBuildIsAFinding(t *testing.T) { if _, ok := st.vals[floorKey]; ok { t.Error("a known floor leaves floor-silent") } + // DLV-B9, the old form's window: one counted node reading only the first form holds it; seven days on it is + // old-form-held about that node; the node stating the newest form closes it, said once, the finding cleared; a + // retired node dropped by hand leaves the count. + now := time.Unix(0, 0) + fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}} + var closings []versions.OldFormClosedBody + window := &oldForm{forms: fs, findings: f, logf: t.Logf, now: func() time.Time { return now }, + said: func(b versions.OldFormClosedBody) { closings = append(closings, b) }} + reads := &engineReads{keeper: &enginereads.Keeper{}, window: window, counted: []string{"a", "b"}} + newest := []string{forms.FormNeed("oci")} + counted := map[string][]string{"a": newest, "b": nil} + window.step(context.Background(), counted) + heldKey := health.FindingKey(health.ScopeMachine, "b", versions.OldFormHeld) + if !window.w.Open || !slices.Equal(window.w.Holding, []string{"b"}) || st.vals[heldKey] != nil { + t.Fatalf("one node lacking the newest form: %+v", window.w) + } + now = now.Add(versions.FindingAfter) + window.step(context.Background(), counted) + if got, err := health.DecodeFinding(st.vals[heldKey]); err != nil || !strings.Contains(got.Summary, "the node-engine of b") { + t.Fatalf("seven days held: the finding = %+v, %v", got, err) + } + counted["b"] = newest + window.step(context.Background(), counted) + window.step(context.Background(), counted) + if window.w.Open || len(closings) != 1 || !slices.Equal(closings[0].LastHolding, []string{"b"}) || st.vals[heldKey] != nil { + t.Errorf("the node back with the newest form: %+v, closings %+v, finding left %v", window.w, closings, st.vals[heldKey] != nil) + } + if _, err := window.drop(context.Background(), "b", "op@shanks", "retired", now); err != nil { + t.Fatal(err) + } + if _, c := reads.forms(); len(c) != 1 || c["b"] != nil { + t.Errorf("a node dropped by hand is still counted: %v", c) + } } // memFindings is a findings bucket in memory. diff --git a/modules/mesh-delivery-builder/internal/build/stuck.go b/modules/mesh-delivery-builder/internal/build/stuck.go index 167db4b6..93dd5703 100644 --- a/modules/mesh-delivery-builder/internal/build/stuck.go +++ b/modules/mesh-delivery-builder/internal/build/stuck.go @@ -101,6 +101,24 @@ func NewFloorAgain(name, why, by string, at time.Time) (string, []byte, error) { return "floor." + versions.EscapeToken(name), data, err } +// ModeOldFormDrop is the Mode of a reasks value of builder_old_form's drop (design 51 *Every build keeps the old +// reference form*): a retired node dropped from the count by hand; Name is the node. +const ModeOldFormDrop = "old-form-drop" + +// NewOldFormDrop is the value builder_old_form writes to drop a retired node: the key `old-form.`, never a build +// key, and the value. +func NewOldFormDrop(node, why, by string, at time.Time) (string, []byte, error) { + node = strings.TrimSpace(node) + if node == "" || strings.ContainsAny(node, ". *>") { + return "", nil, fmt.Errorf("node %q is not a node's name", node) + } + if strings.TrimSpace(why) == "" { + return "", nil, errors.New("say why the node leaves the count (why)") + } + data, err := json.Marshal(ReAsk{V: 1, Why: strings.TrimSpace(why), By: by, At: at.UTC(), Mode: ModeOldFormDrop, Name: node}) + return "old-form." + node, data, err +} + // ModeBuild is the Mode of a reasks value that asks the key to be built again, not its waiting asks to be asked again. const ModeBuild = "build" diff --git a/modules/mesh-delivery-builder/internal/versions/again.go b/modules/mesh-delivery-builder/internal/versions/again.go index 9de9e48b..c4ab1402 100644 --- a/modules/mesh-delivery-builder/internal/versions/again.go +++ b/modules/mesh-delivery-builder/internal/versions/again.go @@ -213,6 +213,8 @@ type Acts struct { Reconsider func(ctx context.Context) // Remove calls a seat's verb remove for one tagged version, reason tag-gone. Remove func(ctx context.Context, seat string, e Entry) error + // DropOldForm drops a retired node from the old form's count by hand (builder_old_form): its answer, or why not. + DropOldForm func(ctx context.Context, node, by, why string, at time.Time) (string, error) } // TagAsked answers one unanswered value of tag-asks. An untag calls remove on each of the branch's tagged versions' diff --git a/modules/mesh-delivery-builder/internal/versions/oldform.go b/modules/mesh-delivery-builder/internal/versions/oldform.go index c05e45f6..0342c01b 100644 --- a/modules/mesh-delivery-builder/internal/versions/oldform.go +++ b/modules/mesh-delivery-builder/internal/versions/oldform.go @@ -1,108 +1,67 @@ package versions import ( + "encoding/json" + "errors" + "fmt" "slices" "sort" + "strings" "time" + + "git.novox.be/novox/mesh-delivery/sdk/forms" + "git.novox.be/novox/mesh-sdk/go/delivery" ) -// The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624, ADR 0618 §2-§4, -// DLV-B9). The builder writes into every build key each reference form a counted node-engine states, the oldest of them -// into the key's single reference and the others beside it; the new form only once every present node-engine states it. -// The window closes when no counted node-engine states the old form alone. What each node states is read through the -// node-engine seat's verb `manifest-reads` at the builder's start and again on the seat's event -// `manifest-reads-changed`, never by a watch of the node-engine's state and never on a timer. +// The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624 §1, ADR 0618 §2-§4, +// DLV-B9). Which forms a build key keeps is mesh-delivery's sdk/forms (Kept, Choose), the counted nodes' statements the +// builder's state engine-reads; this is only the window over them: open while a counted node-engine, present or away, +// reads of some artifact kind none of the newest form the builder knows for it, so an older form stays in new build keys +// for it; closed, said once with `old-form-closed` and a log line, when none does. It moves on what moves a statement +// (the node-engine seat's verb `manifest-reads` and its event `manifest-reads-changed`), the placement, a hand drop and +// each build key written; never on a timer. -// NodeReads is one counted node: what its node-engine states of the reference forms, and whether it is present. Stated -// nil is a node-engine that states nothing, or does not serve `manifest-reads` yet: read as the baseline's forms. -type NodeReads struct { - Node string `json:"node"` - Present bool `json:"present"` - Stated []string `json:"stated,omitempty"` - // Serves is false while the node-engine does not serve `manifest-reads`: it counts as stating the baseline. - Serves bool `json:"serves"` -} +// OldFormState is the builder's state of the window: one key, OldFormKey, an OldFormWindow. builder_old_form reads it. +const ( + OldFormState = "old-form" + OldFormKey = "window" +) -// FormsAnswer is what the builder writes into a build key, and why. -type FormsAnswer struct { - // Single is the form written into the key's single reference: the oldest a counted engine states. - Single string `json:"single"` - // Beside are the other forms written beside it, oldest first. - Beside []string `json:"beside,omitempty"` - // Open is whether the old form's window is open: a counted engine states the oldest form alone. - Open bool `json:"open"` - // Holding are the counted nodes whose engine states the oldest form alone (present or away), sorted. - Holding []string `json:"holding,omitempty"` -} +// OldFormBucket is that state's bucket. +const OldFormBucket = delivery.BuilderModule + "_" + OldFormState -// Forms decides the reference forms of new build keys. forms are every reference form the builder can write, oldest -// first; baseline what an engine stating nothing reads; dropped the nodes a person dropped from the count by hand -// (builder_old_form, a hand act), which then count no more. A node leaves the count when it is retired (by hand here), -// never because it is away (ADR 0624 §1). -func Forms(forms, baseline []string, nodes []NodeReads, dropped map[string]bool) FormsAnswer { - if len(forms) == 0 { - return FormsAnswer{} +// OldFormHeld is the finding of a node holding the window open for FindingAfter, about that node, cleared when the +// window closes or the node no longer holds it. +const OldFormHeld = "old-form-held" + +// Holding are the counted nodes, those dropped by hand left out, whose engine reads, of some artifact kind fs knows, not +// the newest form of that kind: the nodes for which an older form stays in new build keys (forms.ArtifactForms.Kept). A +// node stating nothing reads the first forms (fs.Reads), sorted. +func Holding(fs forms.ArtifactForms, counted map[string][]string, dropped map[string]bool) []string { + newest := map[string]string{} // kind -> newest form + var kinds []string + for _, f := range fs { + for _, k := range f.Kinds { + if _, ok := newest[k]; !ok { + kinds = append(kinds, k) + } + newest[k] = f.Name + } } - oldest, newest := forms[0], forms[len(forms)-1] - type counted struct { - node string - reads []string - } - var cs []counted - everyPresentNewest := true - var holding []string - for _, n := range nodes { - if dropped[n.Node] { + var out []string + for node, stated := range counted { + if dropped[node] { continue } - stated := n.Stated - if !n.Serves || len(stated) == 0 { - stated = baseline - } - var reads []string - for _, f := range forms { - if slices.Contains(stated, f) { - reads = append(reads, f) - } - } - if n.Present && !slices.Contains(reads, newest) { - everyPresentNewest = false - } - if len(reads) == 1 && reads[0] == oldest { - holding = append(holding, n.Node) - } - cs = append(cs, counted{n.Node, reads}) - } - // The new form is written once every present engine states it (ADR 0618 §2); each counted engine then gets the - // newest form it reads among those written, so a form leaves once no counted engine needs it (ADR 0624 §1). An - // engine reading none of them is held by the assigner (ADR 0624 §2), not served by an older form here. - keep := map[string]bool{} - if everyPresentNewest { - keep[newest] = true - } - for _, c := range cs { - for i := len(c.reads) - 1; i >= 0; i-- { - if f := c.reads[i]; f != newest || everyPresentNewest { - keep[f] = true + for _, k := range kinds { + if !fs.Reads(stated, newest[k]) { + out = append(out, node) break } } } - var kept []string - for _, f := range forms { - if keep[f] { - kept = append(kept, f) - } - } - if len(kept) == 0 { - kept = []string{oldest} // nothing counted: the oldest stays, so no engine is cut off - } - sort.Strings(holding) - var beside []string - if len(kept) > 1 { - beside = kept[1:] - } - return FormsAnswer{Single: kept[0], Beside: beside, Open: len(holding) > 0, Holding: holding} + sort.Strings(out) + return out } // OldFormWindow is the window's record: whether it is open, who holds it and since when, and the nodes dropped by @@ -112,12 +71,12 @@ type OldFormWindow struct { Holding []string `json:"holding,omitempty"` Since map[string]time.Time `json:"since,omitempty"` Dropped []Dropped `json:"dropped,omitempty"` - Closed time.Time `json:"closed,omitempty"` + Closed time.Time `json:"closed,omitzero"` // LastHolding are the nodes that held it last, said when it closes. LastHolding []string `json:"last_holding,omitempty"` } -// Dropped is one node dropped from the count by hand: a hand act with who and why. +// Dropped is one retired node dropped from the count by hand (builder_old_form): a hand act with who and why. type Dropped struct { Node string `json:"node"` By string `json:"by"` @@ -125,27 +84,35 @@ type Dropped struct { At time.Time `json:"at"` } +// OldFormClosedBody is the body of OldFormClosedEvent (design 51): when, the nodes that held it last and the nodes dropped +// by hand. +type OldFormClosedBody struct { + At time.Time `json:"at"` + LastHolding []string `json:"last_holding,omitempty"` + Dropped []Dropped `json:"dropped,omitempty"` +} + // FindingAfter is how long a node holds the window open before it is the builder's finding naming the node. const FindingAfter = 7 * 24 * time.Hour -// Step moves the window to what Forms answered at now: it answers the new window, whether it closed in this step (so -// the builder says `old-form-closed` and its build-log line once), and the nodes holding it past FindingAfter (the -// finding, cleared when the window closes). -func (w OldFormWindow) Step(a FormsAnswer, now time.Time) (next OldFormWindow, closed bool, overdue []string) { - next = OldFormWindow{Dropped: w.Dropped, Since: map[string]time.Time{}} - if !a.Open { +// Step moves the window to the nodes holding it at now: it answers the new window, whether it closed in this step (so +// the builder says `old-form-closed` and its log line once), and the nodes holding it past FindingAfter (the finding, +// cleared for every other node). +func (w OldFormWindow) Step(holding []string, now time.Time) (next OldFormWindow, closed bool, overdue []string) { + next = OldFormWindow{Dropped: w.Dropped} + if len(holding) == 0 { + next.Closed, next.LastHolding = w.Closed, w.LastHolding if w.Open { - next.Closed, next.LastHolding = now, w.Holding + next.Closed, next.LastHolding = now.UTC(), w.Holding return next, true, nil } - next.Closed, next.LastHolding = w.Closed, w.LastHolding return next, false, nil } - next.Open, next.Holding = true, a.Holding - for _, n := range a.Holding { + next.Open, next.Holding, next.Since = true, slices.Clone(holding), map[string]time.Time{} + for _, n := range holding { since, had := w.Since[n] if !had { - since = now + since = now.UTC() } next.Since[n] = since if now.Sub(since) >= FindingAfter { @@ -155,7 +122,20 @@ func (w OldFormWindow) Step(a FormsAnswer, now time.Time) (next OldFormWindow, c return next, false, overdue } -// DroppedSet is the dropped nodes as Forms takes them. +// Drop drops a retired node from the count by hand; dropping it again changes nothing. +func (w OldFormWindow) Drop(node, by, why string, at time.Time) (OldFormWindow, error) { + node, why = strings.TrimSpace(node), strings.TrimSpace(why) + if node == "" || why == "" { + return w, errors.New("name the retired node (node) and say why it leaves the count (why)") + } + if w.DroppedSet()[node] { + return w, nil + } + w.Dropped = append(slices.Clone(w.Dropped), Dropped{Node: node, By: by, Why: why, At: at.UTC()}) + return w, nil +} + +// DroppedSet is the dropped nodes, as Holding takes them. func (w OldFormWindow) DroppedSet() map[string]bool { out := map[string]bool{} for _, d := range w.Dropped { @@ -163,3 +143,36 @@ func (w OldFormWindow) DroppedSet() map[string]bool { } return out } + +// Closing is the event's body of a window that closed in a Step. +func (w OldFormWindow) Closing() OldFormClosedBody { + return OldFormClosedBody{At: w.Closed, LastHolding: w.LastHolding, Dropped: w.Dropped} +} + +// DecodeOldFormWindow reads the state's value; none is a closed window no node holds. +func DecodeOldFormWindow(raw []byte) (OldFormWindow, error) { + var w OldFormWindow + if len(raw) == 0 { + return w, nil + } + if err := json.Unmarshal(raw, &w); err != nil { + return OldFormWindow{}, fmt.Errorf("the old-form window is not readable: %w", err) + } + return w, nil +} + +// OldFormDescription is builder_old_form's description. +const OldFormDescription = "The old reference form's window (design 51 *Every build keeps the old reference form*, ADR 0624): whether it is open, " + + "the counted nodes, present or away, whose node-engine reads an older reference form alone and since when (a node holding it seven days " + + "is the builder's finding " + OldFormHeld + "), when it last closed and who held it then, and the retired nodes dropped from the count by " + + "hand. With drop (a node) and why, drops that retired node from the count by hand, a hand act the builder answers; a node still placed " + + "leaves the count by itself only when the placement no longer names it. builder_engine_reads shows what each node's engine states." + +// AnswerOldForm is builder_old_form's read: the window as the state holds it. +func AnswerOldForm(r Reader) (OldFormWindow, error) { + raw, ok, err := r.Get(OldFormKey) + if err != nil || !ok { + return OldFormWindow{}, err + } + return DecodeOldFormWindow(raw) +} diff --git a/modules/mesh-delivery-builder/internal/versions/versions_test.go b/modules/mesh-delivery-builder/internal/versions/versions_test.go index 3c8ad538..9f142678 100644 --- a/modules/mesh-delivery-builder/internal/versions/versions_test.go +++ b/modules/mesh-delivery-builder/internal/versions/versions_test.go @@ -9,6 +9,8 @@ import ( "sync" "testing" "time" + + "git.novox.be/novox/mesh-delivery/sdk/forms" ) // mem is a Store in memory; before runs once before the next Create, to stage a race. @@ -306,49 +308,56 @@ func TestTheBuildersVersions(t *testing.T) { } }) t.Run("old form", func(t *testing.T) { - forms := []string{"ref:artifact-store", "ref:registry"} - base := []string{"ref:artifact-store"} - both := []string{"ref:artifact-store", "ref:registry"} + // One kind, a first form and a newer one (the builder's own forms are all first today, so no window opens). + fs := forms.ArtifactForms{{Name: "registry", First: true, Kinds: []string{"image"}}, {Name: "oci", Kinds: []string{"image"}}} + both := []string{forms.FormNeed("oci")} for _, c := range []struct { name string - nodes []NodeReads + counted map[string][]string dropped map[string]bool - want FormsAnswer + want []string }{ - {"one present node lacking it keeps the old form alone", - []NodeReads{{Node: "a", Present: true, Serves: true, Stated: both}, {Node: "b", Present: true, Serves: true, Stated: base}}, nil, - FormsAnswer{Single: "ref:artifact-store", Open: true, Holding: []string{"b"}}}, - {"a node not serving manifest-reads counts as the baseline", - []NodeReads{{Node: "a", Present: true, Serves: true, Stated: both}, {Node: "b", Present: true, Stated: both}}, nil, - FormsAnswer{Single: "ref:artifact-store", Open: true, Holding: []string{"b"}}}, - {"every present node states it: the new form beside the old, an away old node keeping the old in the single reference", - []NodeReads{{Node: "a", Present: true, Serves: true, Stated: both}, {Node: "c", Serves: true, Stated: base}}, nil, - FormsAnswer{Single: "ref:artifact-store", Beside: []string{"ref:registry"}, Open: true, Holding: []string{"c"}}}, - {"the away node back with the new engine closes the window", - []NodeReads{{Node: "a", Present: true, Serves: true, Stated: both}, {Node: "c", Present: true, Serves: true, Stated: []string{"ref:registry"}}}, nil, - FormsAnswer{Single: "ref:registry"}}, - {"a retired node dropped by hand counts no more", - []NodeReads{{Node: "a", Present: true, Serves: true, Stated: []string{"ref:registry"}}, {Node: "c", Serves: true, Stated: base}}, map[string]bool{"c": true}, - FormsAnswer{Single: "ref:registry"}}, + {"one node lacking the new form holds the old", map[string][]string{"a": both, "b": nil}, nil, []string{"b"}}, + {"an away node counts, stating its last", map[string][]string{"a": both, "c": {"bus-state"}}, nil, []string{"c"}}, + {"every counted node reads the newest: closed", map[string][]string{"a": both, "c": both}, nil, nil}, + {"a retired node dropped by hand counts no more", map[string][]string{"a": both, "c": nil}, map[string]bool{"c": true}, nil}, + {"the builder's own forms: no window", nil, nil, nil}, } { - if got := Forms(forms, base, c.nodes, c.dropped); !reflect.DeepEqual(got, c.want) { - t.Errorf("%s: Forms = %+v, want %+v", c.name, got, c.want) + if got := Holding(fs, c.counted, c.dropped); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: Holding = %v, want %v", c.name, got, c.want) } } + if got := Holding(forms.Current, map[string][]string{"a": nil}, nil); got != nil { + t.Errorf("forms.Current, a node stating nothing: Holding = %v, want none", got) + } t0 := time.Unix(0, 0) - w, closed, overdue := OldFormWindow{}.Step(FormsAnswer{Open: true, Holding: []string{"c"}}, t0) + w, closed, overdue := OldFormWindow{}.Step([]string{"c"}, t0) if closed || len(overdue) != 0 || !w.Open { t.Fatalf("Step = %+v %v %v", w, closed, overdue) } - w, _, overdue = w.Step(FormsAnswer{Open: true, Holding: []string{"c"}}, t0.Add(FindingAfter)) + w, _, overdue = w.Step([]string{"c"}, t0.Add(FindingAfter)) if !reflect.DeepEqual(overdue, []string{"c"}) { t.Errorf("seven days held: overdue %v, want c", overdue) } - w, closed, _ = w.Step(FormsAnswer{}, t0.Add(FindingAfter+time.Hour)) - if !closed || w.Open || !reflect.DeepEqual(w.LastHolding, []string{"c"}) { + w, err := w.Drop("d", "op@shanks", "retired", t0) + if err != nil || !w.DroppedSet()["d"] { + t.Fatalf("Drop = %+v, %v", w, err) + } + if again, _ := w.Drop("d", "op@shanks", "again", t0); len(again.Dropped) != 1 { + t.Errorf("a node dropped twice is kept twice: %+v", again.Dropped) + } + if _, err := w.Drop("e", "op@shanks", "", t0); err == nil { + t.Error("a drop with no why is taken") + } + w, closed, _ = w.Step(nil, t0.Add(FindingAfter+time.Hour)) + if !closed || w.Open || !reflect.DeepEqual(w.LastHolding, []string{"c"}) || w.Closing().Dropped[0].Node != "d" { t.Errorf("closing: %+v closed=%v", w, closed) } - if _, again, _ := w.Step(FormsAnswer{}, t0.Add(FindingAfter+2*time.Hour)); again { + raw, _ := json.Marshal(w) + if back, err := DecodeOldFormWindow(raw); err != nil || !back.Closed.Equal(w.Closed) { + t.Errorf("the window read back = %+v, %v", back, err) + } + if _, again, _ := w.Step(nil, t0.Add(FindingAfter+2*time.Hour)); again { t.Error("a closed window closes again: old-form-closed would be said twice") } }) diff --git a/modules/mesh-delivery-builder/manifest_test.go b/modules/mesh-delivery-builder/manifest_test.go index 9dc2b71a..ee9ee95c 100644 --- a/modules/mesh-delivery-builder/manifest_test.go +++ b/modules/mesh-delivery-builder/manifest_test.go @@ -93,7 +93,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 if m.Module != "mesh-delivery-builder" { t.Errorf("module = %q", m.Module) } - if want := []state{{Name: "builds", History: 5}, {Name: "engine-reads", History: 1}, {Name: "findings", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory + if want := []state{{Name: "builds", History: 5}, {Name: "engine-reads", History: 1}, {Name: "findings", History: 5}, {Name: "floor-waits", History: 1}, {Name: "hearing", History: 1}, {Name: "instances", History: 1}, {Name: "old-form", History: 1}, {Name: "queues", History: 1}, {Name: "reasks", History: 1}, {Name: "tag-asks", History: 1}, {Name: "versions", History: 1}}; !reflect.DeepEqual(m.State, want) { // delivery.BuilderBuildsHistory t.Errorf("state = %+v, want %+v", m.State, want) } // 1887/A4c: the builder chooses no node, so it reads neither presence nor have, invokes no build module's tool and @@ -135,7 +135,7 @@ func TestManifestDeclarations(t *testing.T) { // 1607/AC1 } // 1945: the builder's tools (builder_seat_queue: note 62), served by its tools bundle, and the event it says when it asks an ask again. if want := []string{"builder_announce_again", "builder_ask_again", "builder_build_again", "builder_builds_since", "builder_engine_reads", "builder_floor_again", "builder_floor_waits", - "builder_publish_again", "builder_seat_queue", "builder_stuck", "builder_tag", "builder_tag_asks", "builder_untag", "builder_versions"}; !reflect.DeepEqual(sorted(m.Tools), want) { + "builder_old_form", "builder_publish_again", "builder_seat_queue", "builder_stuck", "builder_tag", "builder_tag_asks", "builder_untag", "builder_versions"}; !reflect.DeepEqual(sorted(m.Tools), want) { t.Errorf("tools = %v, want %v", m.Tools, want) } // Design 51 *Events of the builder*: numbered and old-form-closed on the family version, apart from the builds' ends. diff --git a/modules/mesh-delivery-builder/module.json b/modules/mesh-delivery-builder/module.json index b4f51539..073294d3 100644 --- a/modules/mesh-delivery-builder/module.json +++ b/modules/mesh-delivery-builder/module.json @@ -10,6 +10,7 @@ "builder_engine_reads", "builder_floor_again", "builder_floor_waits", + "builder_old_form", "builder_publish_again", "builder_seat_queue", "builder_stuck", @@ -66,6 +67,7 @@ { "name": "floor-waits", "history": 1 }, { "name": "hearing", "history": 1 }, { "name": "instances", "history": 1 }, + { "name": "old-form", "history": 1 }, { "name": "queues", "history": 1 }, { "name": "reasks", "history": 1 }, { "name": "tag-asks", "history": 1 },