Keep builds in the builds bucket and move the build rows there at start (issue 1090)

Once chain_moved records the kind, RecordBuild and every build read go through the chain (reuse key and published added); before that the tables are read as before. The serving controller copies the rows at its start, holding the lease: idempotent per key, rows counted against keys, the largest value measured, a refusal (urgent condition, nothing switched) on a count that differs or a value over the bus's largest, and a re-read when the table took a row meanwhile. ForTest attaches the chain in memory with builds moved; ForTestBefore leaves it unmoved. Build requests, gates and the other kinds are still in their tables.
This commit is contained in:
2026-10-18 02:49:51 +02:00
parent 36c4ce605e
commit f89d870308
20 changed files with 1790 additions and 90 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ import (
// OnTheBus opens the chain's three buckets on a connection. The controller asserts them at its start
// (broker.EnsureControllerBuckets), so one older than this has not, and the error says so.
func OnTheBus(ctx context.Context, conn *nats.Conn, holds func() bool) (*Chain, error) {
func OnTheBus(ctx context.Context, conn *nats.Conn, holds Holds) (*Chain, error) {
api, err := jetstream.New(conn)
if err != nil {
return nil, err
@@ -85,7 +85,7 @@ 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)
w, err := b.kv.WatchAll(ctx, jetstream.IgnoreDeletes())
if err != nil {
return nil, err
}
+49 -20
View File
@@ -3,7 +3,10 @@ package chain
import (
"context"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"github.com/novox/mesh-controller/internal/broker"
)
@@ -12,9 +15,13 @@ import (
// controller holds the lease** and writes nothing when it does not (ADR 0408 §2).
type Bucket[T any] struct {
b Backend
holds func() bool
holds Holds
}
// Holds says whether this controller may write now: nil when it holds the lease, and why not when it does not. The
// serving controller answers from its lease, and a command from the holder's (the epoch gate).
type Holds func(ctx context.Context) error
// Kept is a record as the bucket holds it: with its revision, the one a write must find.
type Kept[T any] struct {
Key string
@@ -22,10 +29,22 @@ type Kept[T any] struct {
Revision uint64
}
func (s Bucket[T]) guard(value []byte) error {
if s.holds == nil || !s.holds() {
// validKey is what the bus takes as a key: letters, digits and - _ / = . with no dot at either end.
var validKey = regexp.MustCompile(`^[-/_=.a-zA-Z0-9]+$`)
// ErrBadKey is a key the bus does not take: refused loudly, never rewritten into another.
var ErrBadKey = errors.New("the bus takes no key of that spelling")
func (s Bucket[T]) guard(ctx context.Context, key string, value []byte) error {
if s.holds == nil {
return ErrNotHeld
}
if err := s.holds(ctx); err != nil {
return fmt.Errorf("%w: %v", ErrNotHeld, err)
}
if !validKey.MatchString(key) || strings.HasPrefix(key, ".") || strings.HasSuffix(key, ".") {
return fmt.Errorf("%w: %q", ErrBadKey, key)
}
if len(value) > broker.ChainValueBytes {
return fmt.Errorf("%w: %d bytes, the most is %d", ErrTooLarge, len(value), broker.ChainValueBytes)
}
@@ -57,7 +76,7 @@ func (s Bucket[T]) Create(ctx context.Context, key string, v T) (uint64, error)
if err != nil {
return 0, err
}
if err := s.guard(body); err != nil {
if err := s.guard(ctx, key, body); err != nil {
return 0, err
}
return s.b.Create(ctx, key, body)
@@ -69,29 +88,39 @@ func (s Bucket[T]) Update(ctx context.Context, key string, v T, revision uint64)
if err != nil {
return 0, err
}
if err := s.guard(body); err != nil {
if err := s.guard(ctx, key, body); err != nil {
return 0, err
}
return s.b.Update(ctx, key, body, revision)
}
// All is every record, by key. An error is an error: never an empty bucket.
// All is every record, by key, read in one pass of a watch. An error is an error: never an empty bucket.
func (s Bucket[T]) All(ctx context.Context) (map[string]Kept[T], error) {
keys, err := s.b.Keys(ctx)
ctx, stop := context.WithCancel(ctx)
defer stop()
w, err := s.b.Watch(ctx)
if err != nil {
return nil, err
}
out := make(map[string]Kept[T], len(keys))
for _, key := range keys {
k, ok, err := s.Get(ctx, key)
if err != nil {
return nil, err
}
if ok {
out[key] = k
out := map[string]Kept[T]{}
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("reading the bucket: %w", 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
}
}
return out, nil
}
// History is a key's earlier records and its current one, oldest first.
@@ -125,8 +154,8 @@ type Chain struct {
Assignments Bucket[Assignment]
}
// New is a chain over three backends, written only while holds says this controller holds the lease.
func New(builds, rollouts, assignments Backend, holds func() bool) *Chain {
// New is a chain over three backends, written only while holds says this controller may write. A nil holds writes nothing.
func New(builds, rollouts, assignments Backend, holds Holds) *Chain {
return &Chain{
Builds: Bucket[Build]{builds, holds},
Rollouts: Bucket[Rollout]{rollouts, holds},
@@ -135,9 +164,9 @@ func New(builds, rollouts, assignments Backend, holds func() bool) *Chain {
}
// InMemory is a chain held in this process, for tests, written whenever holds says so; nil always holds.
func InMemory(holds func() bool) *Chain {
func InMemory(holds Holds) *Chain {
if holds == nil {
holds = func() bool { return true }
holds = func(context.Context) error { return nil }
}
return New(NewMemory(), NewMemory(), NewMemory(), holds)
}
+28 -4
View File
@@ -222,14 +222,14 @@ func TestAnAssignmentNamesBuildArtifactCommitAndRollout(t *testing.T) {
// the bucket untouched.
func TestTheWriterWithoutTheLeaseWritesNothing(t *testing.T) {
ctx := context.Background()
held := true
var away error
builds, rollouts, assignments := NewMemory(), NewMemory(), NewMemory()
c := New(builds, rollouts, assignments, func() bool { return held })
c := New(builds, rollouts, assignments, func(context.Context) error { return away })
rev, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"})
if err != nil {
t.Fatal(err)
}
held = false
away = errors.New("another controller holds the lease")
if _, err := c.Builds.Create(ctx, "b2", Build{ID: "b2"}); !errors.Is(err, ErrNotHeld) {
t.Fatalf("a create without the lease answered %v", err)
}
@@ -262,7 +262,7 @@ func TestTheWriterWithoutTheLeaseWritesNothing(t *testing.T) {
func TestABucketThatIsAwayIsAnErrorNotAnEmptyOne(t *testing.T) {
ctx := context.Background()
m := NewMemory()
c := New(m, NewMemory(), NewMemory(), func() bool { return true })
c := New(m, NewMemory(), NewMemory(), func(context.Context) error { return nil })
if _, err := c.Builds.Create(ctx, "b1", Build{ID: "b1"}); err != nil {
t.Fatal(err)
}
@@ -310,3 +310,27 @@ func TestAWatchHandsOverTheValuesThenEachWrite(t *testing.T) {
t.Fatalf("third %+v", e)
}
}
// **A key the bus does not take is refused, never rewritten into another** (ADR 0408 §2): a write names the key loudly.
func TestAKeyTheBusTakesNoSpellingOfIsRefused(t *testing.T) {
ctx := context.Background()
c := InMemory(nil)
for _, key := range []string{"", "build one", "build*", ".hidden", "trailing.", "a>b"} {
if _, err := c.Builds.Create(ctx, key, Build{ID: key}); !errors.Is(err, ErrBadKey) {
t.Errorf("the key %q answered %v", key, err)
}
}
for _, key := range []string{"build-1700000000000000000", "anchor.notes", "a/b_c=d"} {
if _, err := c.Builds.Create(ctx, key, Build{ID: key}); err != nil {
t.Errorf("the key %q answered %v", key, err)
}
}
}
// An empty bucket is read as empty, and the read ends.
func TestAnEmptyBucketIsReadAsEmpty(t *testing.T) {
all, err := InMemory(nil).Assignments.All(context.Background())
if err != nil || len(all) != 0 {
t.Fatalf("%v %v", all, err)
}
}