Keep the builds every want names and each composition's provisioner plans; find the replaced controller in the build records (issue 1945)

With no plan left to name a build or a send to keep the plans, a want names what a collection must keep, and the
composition is where the provisioner's plans are composed for the assignments mirror (ADR 0539 clauses 1 and 5);
the assignments no longer name a node's build, so the controller replaced is the newest earlier build of it.
This commit is contained in:
2026-10-24 17:23:55 +02:00
parent a8d63653fb
commit c19d4516ef
9 changed files with 251 additions and 20 deletions
+44 -2
View File
@@ -2,6 +2,7 @@ package inventory
import (
"context"
"fmt"
"slices"
"sort"
"strings"
@@ -47,6 +48,9 @@ const (
KeptByRecentRecord = "recorded-lately"
// KeptByOpenPlan: a plan that has not ended names the build that made it (novox/hq issue 1933).
KeptByOpenPlan = "open-plan"
// KeptByWant: a want names the fingerprint of the build that made it, so a node may be told to run it (novox/hq
// ADR 0539 clause 1): with no plan, the want is what names a build a node runs or moves to.
KeptByWant = "wanted"
)
// KeptLately is how long after a build is recorded its artifacts are kept whatever else the records say (novox/hq
@@ -458,7 +462,8 @@ func keptLately(builds []Build, planned map[string]bool, now time.Time, within t
return out
}
// keptLatelyOrPlanned is keptLately of every build the records hold, (no plan names a build any more, ADR 0539 §17).
// keptLatelyOrPlanned is keptLately of every build the records hold, and the builds of every fingerprint a want names
// (no plan names a build any more, ADR 0539 §17; a want does, clause 1).
func (i *Inventory) keptLatelyOrPlanned(ctx context.Context) (map[string][]string, error) {
c, err := i.theChain(ctx)
if err != nil {
@@ -468,7 +473,44 @@ func (i *Inventory) keptLatelyOrPlanned(ctx context.Context) (map[string][]strin
if err != nil {
return nil, err
}
return keptLately(all, nil, time.Now().UTC(), i.lately()), nil
out := keptLately(all, nil, time.Now().UTC(), i.lately())
if i.wanted == nil {
return out, nil
}
wanted, err := i.wanted(ctx)
if err != nil {
return nil, fmt.Errorf("the fingerprints the wants name: %w", err)
}
for reference, whys := range keptByWants(all, wanted) {
for _, why := range whys {
if !slices.Contains(out[reference], why) {
out[reference] = append(out[reference], why)
}
}
}
return out, nil
}
// keptByWants is, of these builds, the artifacts of each that worked and whose module and fingerprint a want names
// (`<module> <fingerprint>`), each kept as wanted.
func keptByWants(builds []Build, wanted map[string]bool) map[string][]string {
out := map[string][]string{}
for _, b := range builds {
if !b.Worked() || b.SourceFingerprint == "" || !wanted[b.Module+" "+b.SourceFingerprint] {
continue
}
for _, a := range b.Made {
if reference := asRecorded(a.Reference); reference != "" && !slices.Contains(out[reference], KeptByWant) {
out[reference] = append(out[reference], KeptByWant)
}
}
}
return out
}
// SetWanted gives the keep set every fingerprint a want names, as `<module> <fingerprint>` (ADR 0539 clause 1).
func (i *Inventory) SetWanted(wanted func(ctx context.Context) (map[string]bool, error)) {
i.wanted = wanted
}
// digestIn is the `sha256:<hex>` a reference names, empty when it names none.
@@ -0,0 +1,37 @@
package inventory
import (
"strings"
"testing"
"time"
)
// 1945 (dev-r6-controller's finding, for ADR 0539 clause 1): with no plan left to name a build, the builds of every
// fingerprint a want names are kept: a collection never lets go of what a node is told to run. A build of a
// fingerprint no want names, one of another module at that fingerprint, and a failed one keep nothing this way.
func TestTheBuildsOfAWantedFingerprintAreKept(t *testing.T) {
fp := "src2:" + strings.Repeat("a", 64)
other := "src2:" + strings.Repeat("b", 64)
at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
made := func(id, module, fingerprint, reference string) Build {
return Build{ID: id, Module: module, SourceFingerprint: fingerprint, At: at,
Made: []Artifact{{Name: "files", Kind: "archive", Reference: reference}}}
}
const (
wanted = "artifact-store://route-proxy/files/blobs/sha256:a1"
unwanted = "artifact-store://route-proxy/files/blobs/sha256:b2"
elsewise = "artifact-store://mesh-mail/files/blobs/sha256:c3"
)
builds := []Build{made("key-a-1", "route-proxy", fp, wanted), made("key-b-1", "route-proxy", other, unwanted),
made("key-c-1", "mesh-mail", fp, elsewise),
{ID: "failed", Module: "route-proxy", SourceFingerprint: fp, Failed: "no", Made: []Artifact{{Reference: "artifact-store://f@sha256:ff"}}}}
kept := keptByWants(builds, map[string]bool{"route-proxy " + fp: true})
if !has(kept[wanted], KeptByWant) {
t.Errorf("the archive of a wanted fingerprint is not kept: %v", kept)
}
for _, r := range []string{unwanted, elsewise, "artifact-store://f@sha256:ff"} {
if len(kept[r]) > 0 {
t.Errorf("%s is kept as wanted: %v", r, kept[r])
}
}
}
+3
View File
@@ -43,6 +43,9 @@ type Inventory struct {
// keptLately is how long a build's artifacts are kept after it is recorded (KeptLately, novox/hq issue 1933); zero
// is KeptLately, negative none (ForTest: the tests of the keep set's other rules record their builds a moment apart).
keptLately time.Duration
// wanted is every fingerprint a want names, as `<module> <fingerprint>` (novox/hq ADR 0539 clause 1; SetWanted): the
// builds of each are kept, so a collection never lets go of what a node is told to run. Nil names none.
wanted func(ctx context.Context) (map[string]bool, error)
}
// lately is how long a build's artifacts are kept after it is recorded, whatever else the records say.
+12
View File
@@ -408,6 +408,18 @@ func sortedPlanKeys(plans map[string]Provisioning) []string {
// (migration 0100) by module, and carried by the assignments mirror in each handed module's assignment. So the
// client is handed the plan of the build its machine was sent, and a controller started afresh mirrors the same.
// RecordComposedPlans keeps the provisioner's plan of each module a node's composition hands to it, as JSON by module
// (`<module>: plan and required-by`), or none: what SentProvisioningPlans reads for the assignments mirror (design 57 P6).
// With no send left, the composition is where the plans are composed (novox/hq ADR 0539 clause 5).
func (i *Inventory) RecordComposedPlans(ctx context.Context, node string, plans []byte) error {
var kept any
if len(plans) > 0 {
kept = plans
}
_, err := i.store.Pool().Exec(ctx, `update node set sent_provisioning = $2 where name = $1`, node, kept)
return err
}
// SentProvisioningPlans is every plan the machines were last sent, keyed `<node>.<module>`: what
// SetProvisioningPlans is given. A plan of a module only pulled in by another is kept with what required it, and is
// mirrored in a value of its own until a send drops it (issue 765). A plan of a module assigned when it was sent and