Merge fix/1121-holds-released: delivery 2 on the builds' new layout (issue 1121)

Delivery 2 deleted the move and chain_moved, so the layout of the builds under their modules is made by the controller's start (LayBuildsOut), recorded by the key layout.done in the builds bucket, retried every half minute with an urgent condition while it fails; the chain on the bus is refused until it exists. Its source readers use the module and pointer reads.
This commit is contained in:
2026-10-18 13:39:24 +02:00
18 changed files with 1744 additions and 172 deletions
+49 -5
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"sort"
"strings"
"sync"
"time"
)
@@ -33,6 +34,9 @@ type Backend interface {
Keys(ctx context.Context) ([]string, error)
// Watch sends every key's current value, then a Caught entry, then each later write, until the context ends.
Watch(ctx context.Context) (<-chan Entry, error)
// WatchFiltered is Watch of the keys a subject pattern matches (`*` one token, `>` the rest), the filter kept by the
// server: a read of one module's builds reads that module's keys and no other (novox/hq issue 1121).
WatchFiltered(ctx context.Context, filter string) (<-chan Entry, error)
// History is a key's earlier values and its current one, oldest first, at most the bucket keeps.
History(ctx context.Context, key string) ([]Entry, error)
}
@@ -58,6 +62,7 @@ type Memory struct {
values map[string][]Entry
revision uint64
watchers []*watcher
filters map[string]int
// Now is the clock the entries are dated by; nil is time.Now.
Now func() time.Time
// writes counts the creates and updates taken.
@@ -155,7 +160,9 @@ func (m *Memory) write(key string, value []byte) uint64 {
}
m.values[key] = h
for _, w := range m.watchers {
w.push(clone(e))
if w.match(key) {
w.push(clone(e))
}
}
return e.Revision
}
@@ -178,9 +185,10 @@ func (m *Memory) Keys(_ context.Context) ([]string, error) {
// watcher is one watch of a Memory: an unbounded queue, so a watch that is slow is behind and never misses a write.
type watcher struct {
mu sync.Mutex
q []Entry
wake chan struct{}
match func(string) bool
mu sync.Mutex
q []Entry
wake chan struct{}
}
func (w *watcher) push(e Entry) {
@@ -205,18 +213,54 @@ func (w *watcher) pop() (Entry, bool) {
}
func (m *Memory) Watch(ctx context.Context) (<-chan Entry, error) {
return m.WatchFiltered(ctx, ">")
}
// SubjectMatches is whether a key matches a subject pattern: `*` stands for one token, `>` for one or more at the end.
func SubjectMatches(pattern, key string) bool {
p, k := strings.Split(pattern, "."), strings.Split(key, ".")
for n, t := range p {
if t == ">" {
return len(k) > n
}
if n >= len(k) || (t != "*" && t != k[n]) {
return false
}
}
return len(p) == len(k)
}
// Filtered counts the filtered watches a Memory took, by filter, for a test that says what a read touched.
func (m *Memory) Filtered() map[string]int {
m.mu.Lock()
defer m.mu.Unlock()
out := map[string]int{}
for k, v := range m.filters {
out[k] = v
}
return out
}
func (m *Memory) WatchFiltered(ctx context.Context, filter string) (<-chan Entry, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.fail != nil {
return nil, m.fail
}
w := &watcher{wake: make(chan struct{}, 1)}
w := &watcher{wake: make(chan struct{}, 1), match: func(k string) bool { return SubjectMatches(filter, k) }}
if m.filters == nil {
m.filters = map[string]int{}
}
m.filters[filter]++
keys := make([]string, 0, len(m.values))
for k := range m.values {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if !w.match(k) {
continue
}
if e, held := m.last(k); held {
w.q = append(w.q, clone(e))
}
+12 -2
View File
@@ -38,7 +38,7 @@ func OnTheBus(ctx context.Context, conn *nats.Conn, holds Holds) (*Chain, error)
if err != nil {
return nil, err
}
return New(builds, rollouts, assignments, holds), nil
return New(builds, rollouts, assignments, holds).WithManifests(builds), nil
}
// busBucket is a Backend on a key-value bucket. It calls no delete and no purge: the type has none to call (ADR 0408 §4).
@@ -85,7 +85,17 @@ func (b busBucket) Keys(ctx context.Context) ([]string, error) {
}
func (b busBucket) Watch(ctx context.Context) (<-chan Entry, error) {
w, err := b.kv.WatchAll(ctx, jetstream.IgnoreDeletes())
return b.WatchFiltered(ctx, ">")
}
func (b busBucket) WatchFiltered(ctx context.Context, filter string) (<-chan Entry, error) {
var w jetstream.KeyWatcher
var err error
if filter == ">" {
w, err = b.kv.WatchAll(ctx, jetstream.IgnoreDeletes())
} else {
w, err = b.kv.WatchFiltered(ctx, []string{filter}, jetstream.IgnoreDeletes())
}
if err != nil {
return nil, err
}
+68 -1
View File
@@ -128,6 +128,60 @@ func (s Bucket[T]) All(ctx context.Context) (map[string]Kept[T], error) {
}
}
// Filtered is every record whose key matches a subject pattern, by key, read in one pass of a watch the server filters.
func (s Bucket[T]) Filtered(ctx context.Context, filter string) (map[string]Kept[T], error) {
ctx, stop := context.WithCancel(ctx)
defer stop()
w, err := s.b.WatchFiltered(ctx, filter)
if err != nil {
return nil, err
}
out := map[string]Kept[T]{}
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("reading %s: %w", filter, ctx.Err())
case e, open := <-w:
if !open {
return nil, errors.New("the bucket stopped answering before it was read whole")
}
if e.Caught {
return out, nil
}
k, err := decode[T](e)
if err != nil {
return nil, err
}
out[e.Key] = k
}
}
}
// FilteredRaw is Filtered for keys that hold bytes of their own (a pointer holds a build's id), by key.
func (s Bucket[T]) FilteredRaw(ctx context.Context, filter string) (map[string]Entry, error) {
ctx, stop := context.WithCancel(ctx)
defer stop()
w, err := s.b.WatchFiltered(ctx, filter)
if err != nil {
return nil, err
}
out := map[string]Entry{}
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("reading %s: %w", filter, ctx.Err())
case e, open := <-w:
if !open {
return nil, errors.New("the bucket stopped answering before it was read whole")
}
if e.Caught {
return out, nil
}
out[e.Key] = e
}
}
}
// History is a key's earlier records and its current one, oldest first.
func (s Bucket[T]) History(ctx context.Context, key string) ([]Kept[T], error) {
all, err := s.b.History(ctx, key)
@@ -182,10 +236,22 @@ type Chain struct {
Rollouts Bucket[Rollout]
Assignments Bucket[Assignment]
// Manifests holds each build's manifest once, under the digest of its bytes (ManifestKey); Kept says it is on the bus. A
// chain without it keeps the manifest in the build's record, as the tests' chains do.
Manifests Bucket[json.RawMessage]
Kept bool
rollouts Backend
holds Holds
}
// WithManifests gives the chain its manifests bucket.
func (c *Chain) WithManifests(b Backend) *Chain {
c.Manifests = Bucket[json.RawMessage]{b: b, holds: c.holds}
c.Kept = true
return c
}
// RolloutsOf is the rollouts bucket typed as the controller's plan, which carries more than the chain's Rollout names:
// the same keys and values, read and written as the plan.
func RolloutsOf[T any](c *Chain) Bucket[T] { return Bucket[T]{c.rollouts, c.holds} }
@@ -206,5 +272,6 @@ func InMemory(holds Holds) *Chain {
if holds == nil {
holds = func(context.Context) error { return nil }
}
return New(NewMemory(), NewMemory(), NewMemory(), holds)
builds := NewMemory()
return New(builds, NewMemory(), NewMemory(), holds).WithManifests(builds)
}
+104
View File
@@ -0,0 +1,104 @@
package chain
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strings"
)
// The builds bucket's keys (novox/hq issue 1121). A build is under its module, so a read of one module is a read of the keys
// `<module>.>` and no other, and a lookup that has what it looks up by is one Get of a pointer key, whose value is a build's id.
//
// <module>.build.<id> a build's record, its outcome (a records-only read is the filter `<module>.build.>`)
// req.<id> a request, and a build that knew no module
// id.<id> the module of the build with that id (the pointer a lookup by id alone reads)
// <module>.newest the newest build of the module, of any state
// <module>.worked the newest successful build of the module
// <module>.held the build standing for the module's newest successful build (issue 280)
// <module>.commit.<sha> the newest successful build from that commit: a CommitPointer
// <module>.source.<key> the newest successful build with that reuse key (ADR 0350 §1), spelled by SpellReuse
// manifest.sha256-<hex> a manifest, once, by the digest of its bytes
//
// **A pointer is a hint** (issue 1121): the record is written first, then each pointer by compare-and-set; a reader Gets the
// record a pointer names and checks it, and when the pointer is missing or wrong reads the module's records, repairs the
// pointers and says so.
//
// No module's name contains a dot. A key is never removed (ADR 0408 §4); the bare `<id>` keys of before the move stay, and are
// never read once the kind `builds-layout` is moved.
const (
RequestPrefix = "req"
IDPrefix = "id"
)
// BuildKey is a build's record under its module; a build that knew none is the request's key.
func BuildKey(module, id string) string {
if module == "" {
return RequestKey(id)
}
return module + ".build." + id
}
func RequestKey(id string) string { return RequestPrefix + "." + id }
func IDKey(id string) string { return IDPrefix + "." + id }
func NewestKey(module string) string { return module + ".newest" }
func WorkedKey(module string) string { return module + ".worked" }
func HeldKey(module string) string { return module + ".held" }
func CommitKey(module, sha string) string { return module + ".commit." + sha }
func SourceKey(module, fp string) string { return module + ".source." + fp }
func ModuleFilter(module string) string { return module + ".build.>" }
// ManifestPrefix is where a build's manifest is kept, in the builds bucket itself: a new bucket needs a grant the bus gets
// only at a later push, and the push needs the builds. No module is named so.
const ManifestPrefix = "manifest"
// ReservedModules are the names that are not a module's: a key under one is not a build's record.
var ReservedModules = []string{ManifestPrefix, RequestPrefix, IDPrefix, LayoutPrefix}
// LayoutPrefix is the name of the key that says the builds are laid out under their modules: LayoutMarkerKey, written once by
// the controller's start after it has laid the old keys out. Nothing reads the builds before it exists.
const LayoutPrefix = "layout"
// LayoutMarkerKey is the key whose existence says the layout is made.
const LayoutMarkerKey = LayoutPrefix + ".done"
// ManifestKey is where a manifest is kept: `manifest.sha256-<hex of its bytes>`.
func ManifestKey(raw []byte) string {
sum := sha256.Sum256(raw)
return ManifestPrefix + ".sha256-" + hex.EncodeToString(sum[:])
}
// CommitPointer is the value of `<module>.commit.<sha>`: the build, and what the surveys of every module's commits read
// without a Get of each build: its source fingerprint and what it would put on a machine.
type CommitPointer struct {
Build string `json:"build"`
Source string `json:"source,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
}
// IsRecordKey is whether a key of the builds bucket is a build's record under a module: `<module>.build.<id>`.
func IsRecordKey(key string) bool {
parts := strings.Split(key, ".")
return len(parts) == 3 && parts[1] == "build" && !slices.Contains(ReservedModules, parts[0])
}
// SpellReuse is a reuse key (`v1:<hex>`) as a part of a key the bus takes: every character it does not take becomes `-`.
func SpellReuse(key string) string {
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' {
return r
}
return '-'
}, key)
}
// ModuleOf is the module a record key is under.
func ModuleOf(key string) string { m, _, _ := strings.Cut(key, "."); return m }
// DecodeKept is a watched entry as a typed record, for a read that took the entries of several kinds and decodes those
// that hold a record.
func DecodeKept[T any](e Entry) (Kept[T], error) { return decode[T](e) }
// AsAnswered is the state of a request record whose outcome has been heard: the record keeps the request and says which
// build answered it, so a read of the requests needs no read of the builds.
const BuildAnswered = "answered"
+38
View File
@@ -0,0 +1,38 @@
package chain
import (
"context"
"testing"
)
func TestSubjectsMatchAsTheBusFiltersThem(t *testing.T) {
for _, c := range []struct {
pattern, key string
want bool
}{
{"alpha.>", "alpha.build-1", true}, {"alpha.>", "alpha.commit.c1", true}, {"alpha.>", "beta.build-1", false},
{"alpha.>", "alpha", false}, {"*.newest", "alpha.newest", true}, {"*.newest", "alpha.commit.newest", false},
{"*", "build-1", true}, {"*", "alpha.build-1", false}, {">", "a.b.c", true}, {"req.*", "req.build-1", true},
} {
if got := SubjectMatches(c.pattern, c.key); got != c.want {
t.Errorf("SubjectMatches(%q, %q) = %v", c.pattern, c.key, got)
}
}
}
func TestOnlyTheKeysOfAFilterAreWatched(t *testing.T) {
ctx := context.Background()
c := InMemory(nil)
for _, k := range []string{"alpha.build.b1", "alpha.build.b2", "beta.build.b3", "alpha.newest"} {
if _, err := c.Builds.CreateRaw(ctx, k, []byte(`{"id":"x"}`)); err != nil {
t.Fatal(err)
}
}
got, err := c.Builds.FilteredRaw(ctx, ModuleFilter("alpha"))
if err != nil || len(got) != 2 {
t.Fatalf("alpha.> gave %v: %v", got, err)
}
if !IsRecordKey("alpha.build.b1") || IsRecordKey("alpha.newest") || IsRecordKey("alpha.commit.c") || IsRecordKey("manifest.build.x") || IsRecordKey("req.b1") || IsRecordKey("id.b1") {
t.Error("IsRecordKey tells a record from a pointer wrongly")
}
}
+11 -9
View File
@@ -87,15 +87,17 @@ type Request struct {
// Build is one attempt, whichever way it went, kept under its id. It names its module, its repository and commit, its
// reuse key (novox/hq ADR 0350) and what it published (ADR 0408 §1).
type Build struct {
ID string `json:"id"`
State string `json:"state,omitempty"`
Repository string `json:"repository"`
Ref string `json:"ref,omitempty"`
Module string `json:"module,omitempty"`
Commit string `json:"commit,omitempty"`
On string `json:"on,omitempty"`
Path string `json:"path,omitempty"`
Manifest json.RawMessage `json:"manifest,omitempty"`
ID string `json:"id"`
State string `json:"state,omitempty"`
Repository string `json:"repository"`
Ref string `json:"ref,omitempty"`
Module string `json:"module,omitempty"`
Commit string `json:"commit,omitempty"`
On string `json:"on,omitempty"`
Path string `json:"path,omitempty"`
Manifest json.RawMessage `json:"manifest,omitempty"`
// ManifestDigest names the manifest in the manifests bucket (ManifestKey); a record that has it keeps no Manifest.
ManifestDigest string `json:"manifest-digest,omitempty"`
Against []string `json:"against,omitempty"`
Mirrored []string `json:"mirrored,omitempty"`
Read []ReadRepository `json:"read,omitempty"`
+5 -3
View File
@@ -17,9 +17,11 @@ import (
// Build is one attempt, whichever way it went.
type Build struct {
ID string
Repository string
Ref string
ID string
// ManifestDigest names the manifest in the manifests bucket, when a chain keeps it apart from the record (issue 1121).
ManifestDigest string
Repository string
Ref string
// Module is empty for a build that failed before it knew what it was building.
Module string
Commit string
+1 -1
View File
@@ -216,7 +216,7 @@ func TestABuildsBuildSourceComesBackOverWhatItRead(t *testing.T) {
if got := read["build-agent"]; len(got) != 1 || got[0].Paths != nil || got[0].Own {
t.Fatalf("a build that said no build source reads as %+v", got)
}
kept, found, err := inv.chain.Builds.Get(ctx, "said")
kept, found, err := inv.chain.Builds.Get(ctx, "route-proxy.build.said")
if err != nil || !found {
t.Fatalf("the build is not on the bus: %v %v", found, err)
}
+32 -4
View File
@@ -29,6 +29,22 @@ type chainState struct {
// is not saved yet.
view *rolloutView
pending map[string]pendingMerge
// requireLayout is set for a chain on the bus: its builds are read only once the controller's start has laid them out
// under their modules (issue 1121), and laid is that seen.
requireLayout, laid bool
}
// RequireLayout makes every read of the chain refuse (ErrNotLaidOut) until the builds are laid out under their modules.
func (i *Inventory) RequireLayout() {
i.state.mu.Lock()
i.state.requireLayout = true
i.state.mu.Unlock()
}
func (i *Inventory) markLaidOut() {
i.state.mu.Lock()
i.state.laid = true
i.state.mu.Unlock()
}
// ChainOpener opens the chain on the bus when a process first needs it: a command that reads and never moved anything has
@@ -39,6 +55,7 @@ type ChainOpener func(ctx context.Context) (*chain.Chain, error)
func (i *Inventory) UseChainOpener(open ChainOpener) {
i.state.mu.Lock()
i.state.open = open
i.state.requireLayout = true
i.state.mu.Unlock()
}
@@ -61,19 +78,30 @@ func (i *Inventory) attached() *chain.Chain {
return i.chain
}
// theChain is the chain this inventory writes and reads, opened if a command has not yet.
// theChain is the chain this inventory writes and reads, opened if a command has not yet. On the bus it is refused until the
// builds are laid out under their modules.
func (i *Inventory) theChain(ctx context.Context) (*chain.Chain, error) {
i.state.mu.Lock()
defer i.state.mu.Unlock()
if i.chain == nil && i.state.open != nil {
c, err := i.state.open(ctx)
if err != nil {
i.state.mu.Unlock()
return nil, fmt.Errorf("%w: %v", ErrNoChain, err)
}
i.chain = c
}
if i.chain == nil {
c, check := i.chain, i.state.requireLayout && !i.state.laid
i.state.mu.Unlock()
if c == nil {
return nil, ErrNoChain
}
return i.chain, nil
if check {
if _, found, err := c.Builds.GetRaw(ctx, chain.LayoutMarkerKey); err != nil {
return nil, err
} else if !found {
return nil, ErrNotLaidOut
}
i.markLaidOut()
}
return c, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,428 @@
package inventory
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"sort"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/novox/mesh-controller/internal/chain"
)
// issue 1121: a build is under its module, so a lookup reads the keys it needs and no others.
type readCounting struct {
chain.Backend
gets, watches atomic.Int32
}
func (r *readCounting) Get(ctx context.Context, key string) (chain.Entry, bool, error) {
r.gets.Add(1)
return r.Backend.Get(ctx, key)
}
func (r *readCounting) Watch(ctx context.Context) (<-chan chain.Entry, error) {
r.watches.Add(1)
return r.Backend.Watch(ctx)
}
func (r *readCounting) WatchFiltered(ctx context.Context, f string) (<-chan chain.Entry, error) {
r.watches.Add(1)
return r.Backend.WatchFiltered(ctx, f)
}
func layoutChain(builds chain.Backend) (*Inventory, *chain.Chain) {
c := chain.New(builds, chain.NewMemory(), chain.NewMemory(), func(context.Context) error { return nil }).
WithManifests(builds)
inv := &Inventory{state: &chainState{}}
inv.UseChain(c)
return inv, c
}
var layoutAt = time.Date(2026, 10, 18, 12, 0, 0, 0, time.UTC)
func layoutBuild(module string, n int, commit, source string) Build {
return Build{ID: fmt.Sprintf("build-%d", n), Repository: "novox/" + module, Module: module, Commit: commit,
SourceFingerprint: source, ReuseKey: "v1:" + source, Made: []Artifact{{Name: module, Kind: "image", Reference: fmt.Sprintf("r/%s@sha256:%d", module, n)}},
Manifest: []byte(fmt.Sprintf(`{"module":%q,"n":%d}`, module, n)), At: layoutAt.Add(time.Duration(n) * time.Minute)}
}
func TestALookupOfOneModuleReadsThatModulesKeysOnly(t *testing.T) {
mem := chain.NewMemory()
inv, c := layoutChain(mem)
ctx := context.Background()
for n := 1; n <= 3; n++ {
for _, m := range []string{"alpha", "beta"} {
if err := inv.chainRecordBuild(ctx, c, layoutBuild(m, n, fmt.Sprintf("c%d", n), fmt.Sprintf("s%d", n))); err != nil {
t.Fatal(err)
}
}
}
got, err := inv.chainBuildList(ctx, c, "alpha", 10)
if err != nil || len(got) != 3 {
t.Fatalf("alpha's builds %+v: %v", got, err)
}
if want := map[string]int{"alpha.build.>": 1}; !reflect.DeepEqual(mem.Filtered(), want) {
t.Fatalf("the list of one module watched %v; want only %v", mem.Filtered(), want)
}
if _, err := inv.chainBuildFingerprints(ctx, c, "alpha"); err != nil {
t.Fatal(err)
}
for f := range mem.Filtered() {
if f != "alpha.build.>" && f != "alpha.commit.*" {
t.Errorf("a lookup of alpha watched %q", f)
}
}
}
func TestReuseBySourceFingerprintAndTheBuildOfACommitAreOneGet(t *testing.T) {
counting := &readCounting{Backend: chain.NewMemory()}
inv, c := layoutChain(counting)
ctx := context.Background()
for n := 1; n <= 20; n++ {
if err := inv.chainRecordBuild(ctx, c, layoutBuild("alpha", n, fmt.Sprintf("c%d", n), fmt.Sprintf("s%d", n))); err != nil {
t.Fatal(err)
}
}
counting.gets.Store(0)
counting.watches.Store(0)
id, err := inv.chainBuildOfSource(ctx, c, "alpha", "v1:s7")
if err != nil || id != "build-7" {
t.Fatalf("source s7 is %q: %v", id, err)
}
if counting.gets.Load() != 2 || counting.watches.Load() != 0 {
t.Fatalf("the reuse lookup made %d gets and %d watches; want 2 (the pointer, the record it names) and 0", counting.gets.Load(), counting.watches.Load())
}
counting.gets.Store(0)
if id, err = inv.chainBuildOf(ctx, c, "alpha", "c19"); err != nil || id != "build-19" {
t.Fatalf("commit c19 is %q: %v", id, err)
}
if counting.gets.Load() != 2 || counting.watches.Load() != 0 {
t.Fatalf("the build of a commit made %d gets and %d watches; want 2 and 0", counting.gets.Load(), counting.watches.Load())
}
}
func TestABuildWrittenIsFoundByCommitSourceIDAndRequest(t *testing.T) {
inv, c := layoutChain(chain.NewMemory())
ctx := context.Background()
ask := BuildRequest{ID: "build-5", Repository: "novox/alpha", Seat: "mesh-build-machine", At: layoutAt}
if err := inv.chainRecordBuildRequest(ctx, c, ask); err != nil {
t.Fatal(err)
}
if _, found, _ := inv.chainBuildByID(ctx, c, "build-5"); found {
t.Fatal("a request nobody answered was read as a build")
}
b := layoutBuild("alpha", 5, "c5", "s5")
if err := inv.chainRecordBuild(ctx, c, b); err != nil {
t.Fatal(err)
}
if id, _ := inv.chainBuildOf(ctx, c, "alpha", "c5"); id != "build-5" {
t.Errorf("by commit: %q", id)
}
if id, _ := inv.chainBuildOfSource(ctx, c, "alpha", "v1:s5"); id != "build-5" {
t.Errorf("by source: %q", id)
}
got, found, err := inv.chainBuildByID(ctx, c, "build-5")
if err != nil || !found || got.Module != "alpha" {
t.Fatalf("by id: %+v %v %v", got, found, err)
}
// The manifest is kept once, apart from the record, and read back whole.
raw, err := inv.chainManifestOf(ctx, c, "build-5")
if err != nil || !json.Valid(raw) || string(raw) != string(canonicalJSON(b.Manifest)) {
t.Fatalf("manifest %q: %v", raw, err)
}
k, _, _ := c.Builds.Get(ctx, "alpha.build.build-5")
if len(k.Value.Manifest) != 0 || k.Value.ManifestDigest == "" {
t.Errorf("the record keeps the manifest itself: %+v", k.Value)
}
if k.Value.Request == nil {
t.Error("the outcome lost the request it answers")
}
reqs, err := inv.chainRequests(ctx, c)
if err != nil || len(reqs) != 1 || !reqs[0].Heard || reqs[0].Module != "alpha" {
t.Fatalf("requests %+v: %v", reqs, err)
}
}
func legacyBuild(n int, module, commit string) chain.Build {
b := toChainBuild(layoutBuild(module, n, commit, "s"+commit))
return b
}
func keysOf(t *testing.T, mem chain.Backend) []string {
t.Helper()
keys, err := mem.Keys(context.Background())
if err != nil {
t.Fatal(err)
}
sort.Strings(keys)
return keys
}
// The layout of the bucket's keys is made once from the keys of before it: run again it changes nothing, the old keys stay,
// and the pointers say the newest.
func TestTheLayoutMigrationIsIdempotentAndKeepsTheOldKeys(t *testing.T) {
mem := chain.NewMemory()
inv, c := layoutChain(mem)
ctx := context.Background()
for n, m := range []string{"alpha", "alpha", "beta", "alpha"} {
old := legacyBuild(n+1, m, fmt.Sprintf("c%d", n+1))
if _, err := c.Builds.Create(ctx, old.ID, old); err != nil {
t.Fatal(err)
}
}
failed := legacyBuild(9, "alpha", "c9")
failed.Failed = "broke"
failed.SourceFingerprint = ""
if _, err := c.Builds.Create(ctx, failed.ID, failed); err != nil {
t.Fatal(err)
}
asked := chain.Build{ID: "build-10", State: chain.BuildAsked, Repository: "novox/alpha", Asked: layoutAt,
Request: &chain.Request{Repository: "novox/alpha", At: layoutAt}}
if _, err := c.Builds.Create(ctx, asked.ID, asked); err != nil {
t.Fatal(err)
}
first, err := inv.layoutBuilds(ctx, c, nil)
if err != nil || first.Written == 0 {
t.Fatalf("first run %+v: %v", first, err)
}
keys := keysOf(t, mem)
second, err := inv.layoutBuilds(ctx, c, nil)
if err != nil || second.Written != 0 {
t.Fatalf("second run %+v: %v", second, err)
}
if again := keysOf(t, mem); !reflect.DeepEqual(keys, again) {
t.Fatalf("keys changed on the second run:\n%v\n%v", keys, again)
}
has := func(k string) bool { return containsKey(keys, k) }
for _, k := range []string{"build-1", "build-9", "alpha.build.build-1", "alpha.build.build-4", "beta.build.build-3", "alpha.build.build-9", "req.build-10",
"id.build-3", "alpha.newest", "alpha.worked", "alpha.held", "alpha.commit.c4", "alpha.source.v1-sc4", "beta.worked"} {
if !has(k) {
t.Errorf("no key %s in %v", k, keys)
}
}
if e, _, _ := mem.Get(ctx, "alpha.newest"); string(e.Value) != "build-9" {
t.Errorf("alpha.newest = %q; want the failed build-9", e.Value)
}
if e, _, _ := mem.Get(ctx, "alpha.worked"); string(e.Value) != "build-4" {
t.Errorf("alpha.worked = %q; want build-4", e.Value)
}
if id, _ := inv.chainBuildOf(ctx, c, "alpha", "c4"); id != "build-4" {
t.Errorf("by commit after the layout: %q", id)
}
// No record is read from an old key.
list, err := inv.chainBuildList(ctx, c, "alpha", 10)
if err != nil || len(list) != 4 {
t.Fatalf("alpha's builds after the layout: %d %v", len(list), err)
}
}
func containsKey(s []string, k string) bool {
for _, x := range s {
if x == k {
return true
}
}
return false
}
// The manifest is kept once under its digest in the same bucket, and the moved record keeps none.
func TestAMovedRecordHoldsNoManifestAndItsManifestIsUnderItsDigest(t *testing.T) {
mem := chain.NewMemory()
inv, c := layoutChain(mem)
ctx := context.Background()
old := legacyBuild(1, "alpha", "c1")
if _, err := c.Builds.Create(ctx, old.ID, old); err != nil {
t.Fatal(err)
}
if _, err := inv.layoutBuilds(ctx, c, nil); err != nil {
t.Fatal(err)
}
k, found, err := c.Builds.Get(ctx, "alpha.build.build-1")
if err != nil || !found || len(k.Value.Manifest) != 0 || k.Value.ManifestDigest == "" {
t.Fatalf("moved record %+v %v %v", k.Value, found, err)
}
if !strings.HasPrefix(k.Value.ManifestDigest, "manifest.sha256-") {
t.Errorf("the digest %q is not a key under manifest.", k.Value.ManifestDigest)
}
e, found, _ := mem.Get(ctx, k.Value.ManifestDigest)
if !found || string(e.Value) != string(canonicalJSON([]byte(old.Manifest))) {
t.Errorf("the manifest under its digest is %q (found %v)", e.Value, found)
}
if got := chain.ManifestKey(e.Value); got != k.Value.ManifestDigest {
t.Errorf("the digest does not name the bytes: %s", got)
}
}
// A pointer is a hint: a record written without its pointers is found by reading the module's records, and the pointers are
// repaired; a pointer that names a record that is not what it was for is not followed.
func TestAMissingOrWrongPointerFallsBackToTheModulesRecordsAndIsRepaired(t *testing.T) {
mem := chain.NewMemory()
inv, c := layoutChain(mem)
ctx := context.Background()
for n := 1; n <= 3; n++ {
if err := inv.chainRecordBuild(ctx, c, layoutBuild("alpha", n, fmt.Sprintf("c%d", n), fmt.Sprintf("s%d", n))); err != nil {
t.Fatal(err)
}
}
// A record whose pointers the writer never made (a crash between the record and its pointers).
lost := toChainBuild(layoutBuild("alpha", 4, "c4", "s4"))
if _, err := c.Builds.Create(ctx, "alpha.build.build-4", lost); err != nil {
t.Fatal(err)
}
if id, err := inv.chainBuildOf(ctx, c, "alpha", "c4"); err != nil || id != "build-4" {
t.Fatalf("the build of c4 is %q: %v", id, err)
}
if e, found, _ := mem.Get(ctx, "alpha.commit.c4"); !found || pointedID(e.Value) != "build-4" {
t.Errorf("the commit pointer was not repaired: %q %v", e.Value, found)
}
if id, _ := inv.chainBuildOfSource(ctx, c, "alpha", "v1:s4"); id != "build-4" {
t.Errorf("the source pointer was not repaired: %q", id)
}
// A pointer to the wrong build is not followed.
e, _, _ := mem.Get(ctx, "alpha.commit.c2")
wrong, _ := json.Marshal(chain.CommitPointer{Build: "build-1"})
if _, err := mem.Update(ctx, "alpha.commit.c2", wrong, e.Revision); err != nil {
t.Fatal(err)
}
if id, err := inv.chainBuildOf(ctx, c, "alpha", "c2"); err != nil || id != "build-2" {
t.Fatalf("with a wrong pointer the build of c2 is %q: %v", id, err)
}
if e, _, _ := mem.Get(ctx, "alpha.commit.c2"); pointedID(e.Value) != "build-2" {
t.Errorf("the wrong pointer stayed: %s", e.Value)
}
// A commit nothing built is none.
if id, err := inv.chainBuildOf(ctx, c, "alpha", "nope"); err != nil || id != "" {
t.Errorf("an unbuilt commit is %q: %v", id, err)
}
}
type touchRecording struct {
chain.Backend
mu sync.Mutex
touched []string
}
func (r *touchRecording) note(s string) { r.mu.Lock(); r.touched = append(r.touched, s); r.mu.Unlock() }
func (r *touchRecording) Get(ctx context.Context, key string) (chain.Entry, bool, error) {
r.note("get " + key)
return r.Backend.Get(ctx, key)
}
func (r *touchRecording) Watch(ctx context.Context) (<-chan chain.Entry, error) {
r.note("watch >")
return r.Backend.Watch(ctx)
}
func (r *touchRecording) WatchFiltered(ctx context.Context, f string) (<-chan chain.Entry, error) {
r.note("watch " + f)
return r.Backend.WatchFiltered(ctx, f)
}
func (r *touchRecording) Keys(ctx context.Context) ([]string, error) {
r.note("keys")
return r.Backend.Keys(ctx)
}
// Once the layout is made, no read touches a key that is a build's id alone, or the bucket whole.
func TestAfterTheLayoutNoReadTouchesABareBuildIDKey(t *testing.T) {
rec := &touchRecording{Backend: chain.NewMemory()}
inv, c := layoutChain(rec)
ctx := context.Background()
old := legacyBuild(1, "alpha", "c1")
if _, err := c.Builds.Create(ctx, old.ID, old); err != nil {
t.Fatal(err)
}
if _, err := inv.layoutBuilds(ctx, c, nil); err != nil {
t.Fatal(err)
}
if err := inv.chainRecordBuild(ctx, c, layoutBuild("alpha", 2, "c2", "s2")); err != nil {
t.Fatal(err)
}
rec.mu.Lock()
rec.touched = nil
rec.mu.Unlock()
_, _ = inv.chainBuildList(ctx, c, "alpha", 5)
_, _ = inv.chainBuildList(ctx, c, "", 5)
_, _, _ = inv.chainBuildByID(ctx, c, "build-1")
_, _, _ = inv.chainBuildByID(ctx, c, "build-2")
_, _ = inv.chainBuildOf(ctx, c, "alpha", "c1")
_, _ = inv.chainBuildFingerprints(ctx, c, "alpha")
_, _ = inv.chainFingerprints(ctx, c)
_, _ = inv.chainManifestOf(ctx, c, "build-1")
_, _, _, _ = inv.chainManifestBuild(ctx, c, "alpha", "c1")
_, _ = chainBuilds(ctx, c)
_, _ = inv.chainRequests(ctx, c)
_, _ = inv.chainBuiltAgainst(ctx, c)
rec.mu.Lock()
defer rec.mu.Unlock()
for _, what := range rec.touched {
verb, key, _ := strings.Cut(what, " ")
if what == "keys" || what == "watch >" || what == "watch *" || (verb == "get" && !strings.Contains(key, ".")) {
t.Errorf("a read touched %q", what)
}
}
}
// A pointer follows the newest build, not the last write: two builds overlap and the older, asked first, finishes last.
func TestAnOlderBuildFinishingLastLeavesThePointersOnTheNewer(t *testing.T) {
mem := chain.NewMemory()
inv, c := layoutChain(mem)
ctx := context.Background()
older := layoutBuild("alpha", 1, "c1", "s1")
older.Asked = layoutAt
older.At = layoutAt.Add(20 * time.Minute) // finished last
newer := layoutBuild("alpha", 2, "c1", "s2")
newer.Asked = layoutAt.Add(5 * time.Minute)
newer.At = layoutAt.Add(10 * time.Minute)
for _, b := range []Build{newer, older} {
if err := inv.chainRecordBuild(ctx, c, b); err != nil {
t.Fatal(err)
}
}
for _, key := range []string{"alpha.newest", "alpha.worked"} {
if e, _, _ := mem.Get(ctx, key); string(e.Value) != "build-2" {
t.Errorf("%s = %q; want the newer build-2", key, e.Value)
}
}
if e, _, _ := mem.Get(ctx, "alpha.commit.c1"); pointedID(e.Value) != "build-2" {
t.Errorf("the commit pointer = %s; want build-2", e.Value)
}
// The older build is still found by what only it has.
if id, _ := inv.chainBuildOfSource(ctx, c, "alpha", "v1:s1"); id != "build-1" {
t.Errorf("the older build's own reuse key finds %q", id)
}
}
// Nothing reads the chain on the bus until the controller's start has laid the builds out and written the marker; the layout
// is done once.
func TestTheChainIsRefusedUntilTheBuildsAreLaidOutAndTheLayoutIsDoneOnce(t *testing.T) {
mem := chain.NewMemory()
inv, c := layoutChain(mem)
inv.RequireLayout()
ctx := context.Background()
old := legacyBuild(1, "alpha", "c1")
if _, err := c.Builds.Create(ctx, old.ID, old); err != nil {
t.Fatal(err)
}
if _, err := inv.theChain(ctx); !errors.Is(err, ErrNotLaidOut) {
t.Fatalf("a read before the layout answered %v", err)
}
if err := inv.LayBuildsOut(ctx, nil); err != nil {
t.Fatal(err)
}
if _, err := inv.theChain(ctx); err != nil {
t.Fatalf("a read after the layout answered %v", err)
}
keys := keysOf(t, mem)
if !containsKey(keys, chain.LayoutMarkerKey) {
t.Fatalf("no marker among %v", keys)
}
if err := inv.LayBuildsOut(ctx, nil); err != nil || !reflect.DeepEqual(keys, keysOf(t, mem)) {
t.Fatalf("the second layout changed the bucket: %v", err)
}
}
+51 -10
View File
@@ -35,7 +35,14 @@ func (i *Inventory) chainRecordBuildRequest(ctx context.Context, c *chain.Chain,
if a.At.IsZero() {
a.At = time.Now().UTC()
}
key := chainBuildKey(a.ID)
key := chain.RequestKey(a.ID)
// An outcome under a module that came first has no request beside it: the request is kept on it, and its own record says
// it was answered (issue 1121).
if ptr, found, err := c.Builds.GetRaw(ctx, chain.IDKey(a.ID)); err != nil {
return err
} else if found && string(ptr.Value) != chain.RequestPrefix {
return i.keepAnsweredRequest(ctx, c, string(ptr.Value), a)
}
for try := 0; try < casTries; try++ {
_, err := c.Builds.Create(ctx, key, askedRecord(a))
if err == nil {
@@ -65,8 +72,35 @@ func (i *Inventory) chainRecordBuildRequest(ctx context.Context, c *chain.Chain,
return fmt.Errorf("build request %s is not kept on the bus: it kept moving under the write", a.ID)
}
// keepAnsweredRequest puts a request on the outcome that answered it before the request was kept, and keeps the request's
// own record as answered.
func (i *Inventory) keepAnsweredRequest(ctx context.Context, c *chain.Chain, module string, a BuildRequest) error {
outKey := chain.BuildKey(module, a.ID)
for try := 0; try < casTries; try++ {
out, found, err := c.Builds.Get(ctx, outKey)
if err != nil || !found {
return err
}
if out.Value.Request == nil {
out.Value.Request = toRequest(a)
if _, err := c.Builds.Update(ctx, outKey, out.Value, out.Revision); errors.Is(err, chain.ErrMoved) {
continue
} else if err != nil {
return fmt.Errorf("build request %s is not kept on the bus: %w", a.ID, err)
}
}
rec := askedRecord(a)
rec.State, rec.Module, rec.Failed, rec.At = chain.BuildAnswered, module, out.Value.Failed, out.Value.At
if _, err := c.Builds.Create(ctx, chain.RequestKey(a.ID), rec); err != nil && !errors.Is(err, chain.ErrExists) {
return fmt.Errorf("build request %s is not kept on the bus: %w", a.ID, err)
}
return nil
}
return fmt.Errorf("build request %s is not kept: it kept moving under the write", a.ID)
}
func (i *Inventory) chainBuildRequestByID(ctx context.Context, c *chain.Chain, id string) (BuildRequest, bool, error) {
k, found, err := c.Builds.Get(ctx, chainBuildKey(id))
k, found, err := c.Builds.Get(ctx, chain.RequestKey(id))
if err != nil || !found || k.Value.Request == nil {
return BuildRequest{}, false, err
}
@@ -75,7 +109,7 @@ func (i *Inventory) chainBuildRequestByID(ctx context.Context, c *chain.Chain, i
// chainMarkRequest changes a request that has no outcome yet, by compare-and-set; one answered, or none kept, is left.
func (i *Inventory) chainMarkRequest(ctx context.Context, c *chain.Chain, id string, mark func(*chain.Request)) error {
key := chainBuildKey(id)
key := chain.RequestKey(id)
for try := 0; try < casTries; try++ {
cur, found, err := c.Builds.Get(ctx, key)
if err != nil || !found || cur.Value.State != chain.BuildAsked || cur.Value.Request == nil {
@@ -94,13 +128,14 @@ func (i *Inventory) chainMarkRequest(ctx context.Context, c *chain.Chain, id str
// chainRequests is every request kept, newest first, each with its outcome where its build was heard. Requests older than
// KeptFor are not listed: the bucket keeps them, and no reader needs them.
func (i *Inventory) chainRequests(ctx context.Context, c *chain.Chain) ([]RequestOutcome, error) {
all, err := c.Builds.All(ctx)
all, err := requestRecords(ctx, c)
if err != nil {
return nil, fmt.Errorf("the builds cannot be read from the bus: %w", err)
return nil, err
}
oldest := time.Now().Add(-KeptFor)
var out []RequestOutcome
for id, k := range all {
for _, k := range all {
id := strings.TrimPrefix(k.Key, chain.RequestPrefix+".")
r := k.Value.Request
if r == nil || !r.At.After(oldest) {
continue
@@ -120,6 +155,11 @@ func (i *Inventory) chainRequests(ctx context.Context, c *chain.Chain) ([]Reques
return out, nil
}
// requestRecords are the records under `req.`: the requests, and the builds that knew no module.
func requestRecords(ctx context.Context, c *chain.Chain) ([]chain.Kept[chain.Build], error) {
return buildsIn(ctx, c, chain.RequestPrefix+".*")
}
// RequestsToAskAgain are the requests the move found never handed over (issue 981 note 7 Q5), not yet asked again: each is
// asked once, and never twice.
func (i *Inventory) RequestsToAskAgain(ctx context.Context) ([]BuildRequest, error) {
@@ -127,12 +167,13 @@ func (i *Inventory) RequestsToAskAgain(ctx context.Context) ([]BuildRequest, err
if err != nil {
return nil, err
}
all, err := c.Builds.All(ctx)
all, err := requestRecords(ctx, c)
if err != nil {
return nil, err
}
var out []BuildRequest
for id, k := range all {
for _, k := range all {
id := strings.TrimPrefix(k.Key, chain.RequestPrefix+".")
if k.Value.State == chain.BuildAsked && k.Value.Request != nil && k.Value.Request.Reask == chain.ReaskPending {
out = append(out, fromRequest(id, *k.Value.Request))
}
@@ -148,12 +189,12 @@ func (i *Inventory) ClaimToAskAgain(ctx context.Context, id string) (bool, error
if err != nil {
return false, err
}
cur, found, err := c.Builds.Get(ctx, chainBuildKey(id))
cur, found, err := c.Builds.Get(ctx, chain.RequestKey(id))
if err != nil || !found || cur.Value.Request == nil || cur.Value.Request.Reask != chain.ReaskPending {
return false, err
}
cur.Value.Request.Reask = chain.ReaskClaimed
if _, err := c.Builds.Update(ctx, chainBuildKey(id), cur.Value, cur.Revision); err != nil {
if _, err := c.Builds.Update(ctx, chain.RequestKey(id), cur.Value, cur.Revision); err != nil {
if errors.Is(err, chain.ErrMoved) {
return false, nil
}
+1 -1
View File
@@ -15,7 +15,7 @@ func TestWhatTheColumnRecordedIsCopiedOntoTheKeysAndOverwritesNothing(t *testing
inv, c := anInventoryOnTheChain(t)
ctx := t.Context()
at := time.Date(2026, 10, 18, 4, 0, 0, 0, time.UTC)
if _, err := c.Builds.Create(ctx, "b-wg", chain.Build{ID: "b-wg", Module: "mesh-wireguard", Commit: "c-wg"}); err != nil {
if err := inv.chainRecordBuild(ctx, c, Build{ID: "b-wg", Module: "mesh-wireguard", Commit: "c-wg"}); err != nil {
t.Fatal(err)
}
put := func(module string, a chain.Assignment) {
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/chain"
)
// **Once builds are on the bus every reader of them reads the bucket** (issue 1090): a build recorded is the key of its
@@ -32,11 +33,11 @@ func TestBuildsOnTheBusAreReadBackByEveryReader(t *testing.T) {
t.Fatal(err)
}
}
kept, found, err := inv.chain.Builds.Get(ctx, "build-1")
kept, found, err := inv.chain.Builds.Get(ctx, chain.BuildKey("notes", "build-1"))
if err != nil || !found || kept.Value.ReuseKey != "rk-1" {
t.Fatalf("build-1 on the bus: %+v %v %v", kept.Value, found, err)
}
if history, _ := inv.chain.Builds.History(ctx, "build-1"); len(history) != 1 {
if history, _ := inv.chain.Builds.History(ctx, chain.BuildKey("notes", "build-1")); len(history) != 1 {
t.Fatalf("a build recorded twice has %d values", len(history))
}
list, err := inv.Builds(ctx, "notes", 10)
@@ -130,7 +131,7 @@ func TestARequestIsTheBuildInStateAskedAndItsOutcomeTakesItsPlace(t *testing.T)
if again, _, _ := inv.BuildRequestByID(ctx, "build-1"); again.NotAsked != "" {
t.Fatalf("an answered request was marked not asked: %+v", again)
}
if history, _ := inv.chain.Builds.History(ctx, "build-1"); len(history) != 3 {
if history, _ := inv.chain.Builds.History(ctx, chain.RequestKey("build-1")); len(history) != 3 {
// asked, marked unknown, answered.
t.Fatalf("the record has %d values", len(history))
}
+20 -36
View File
@@ -2,7 +2,8 @@ package inventory
import (
"context"
"encoding/json"
"github.com/novox/mesh-controller/internal/chain"
)
// A build whose source is unchanged is never a move (novox/hq issue 280).
@@ -26,6 +27,7 @@ type sourceRow struct {
id, commit, fingerprint string
made []byte
manifest []byte
manifestDigest string
failedGate bool
}
@@ -69,6 +71,15 @@ func (i *Inventory) StandingBuild(ctx context.Context, module, build string) (st
for k, b := range builds {
if b.id == build {
s := builds[standing(builds, k)]
if len(s.manifest) == 0 && s.manifestDigest != "" {
// The manifest is kept apart from the record (issue 1121): one Get.
c, err := i.theChain(ctx)
if err != nil {
return "", nil, err
}
raw, err := manifestOfRecord(ctx, c, chain.Build{ID: s.id, ManifestDigest: s.manifestDigest})
return s.id, raw, err
}
return s.id, s.manifest, nil
}
}
@@ -78,59 +89,32 @@ func (i *Inventory) StandingBuild(ctx context.Context, module, build string) (st
// SourceFingerprints is, per module, per commit, the source fingerprint of the newest successful build
// from it that has one: module → commit → fingerprint. A commit whose builds carry none is absent.
func (i *Inventory) SourceFingerprints(ctx context.Context) (map[string]map[string]string, error) {
all, _, err := i.sourceRows(ctx, "")
c, err := i.theChain(ctx)
if err != nil {
return nil, err
}
out := map[string]map[string]string{}
for m, builds := range all {
for _, b := range builds {
if b.fingerprint == "" || b.commit == "" {
continue
}
if out[m] == nil {
out[m] = map[string]string{}
}
if _, seen := out[m][b.commit]; !seen {
out[m][b.commit] = b.fingerprint
}
}
}
return out, nil
// Read from the commit pointers, which carry each commit's source fingerprint: no record is read (issue 1121).
return i.chainSourceFingerprints(ctx, c)
}
// BuildSourceFingerprints is SourceFingerprints for one module: commit → fingerprint.
func (i *Inventory) BuildSourceFingerprints(ctx context.Context, module string) (map[string]string, error) {
all, err := i.SourceFingerprints(ctx)
c, err := i.theChain(ctx)
if err != nil {
return nil, err
}
if all[module] == nil {
return map[string]string{}, nil
}
return all[module], nil
// This module's commit pointers only (issue 1121).
return i.chainBuildSourceFingerprints(ctx, c, module)
}
// heldMade is, per module, what the build standing for its newest successful build made — what Held
// answers (novox/hq issue 280).
func (i *Inventory) heldMade(ctx context.Context) (map[string][]Artifact, error) {
all, _, err := i.sourceRows(ctx, "")
c, err := i.theChain(ctx)
if err != nil {
return nil, err
}
out := map[string][]Artifact{}
for m, builds := range all {
if len(builds) == 0 {
continue
}
var made []Artifact
if err := json.Unmarshal(builds[standing(builds, 0)].made, &made); err != nil {
// Skipped rather than fatal, as Held always did: the build that needs it says it is missing.
continue
}
out[m] = made
}
return out, nil
return i.chainHeldMade(ctx, c)
}
// SameSourceCommits is the commits of the builds in a build's run (novox/hq issue 280): the build and