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:
@@ -42,6 +42,9 @@ func attachTheChain(ctx context.Context, inv *inventory.Inventory, server *link.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
inv.UseChain(c)
|
inv.UseChain(c)
|
||||||
|
inv.RequireLayout()
|
||||||
|
// And the builds laid out under their modules, once, before they are read (issue 1121).
|
||||||
|
go layBuildsOutSoon(ctx, inv)
|
||||||
// And the requests the move of delivery 1 found never handed over, asked again once (askAgain), once it is serving.
|
// And the requests the move of delivery 1 found never handed over, asked again once (askAgain), once it is serving.
|
||||||
go askAgainSoon(ctx, inv)
|
go askAgainSoon(ctx, inv)
|
||||||
// And what delivery 1 recorded of what each machine was sent, copied onto the keys of the assignments (CopySentBuilds).
|
// And what delivery 1 recorded of what each machine was sent, copied onto the keys of the assignments (CopySentBuilds).
|
||||||
@@ -130,3 +133,39 @@ func copySentBuildsSoon(ctx context.Context, inv *inventory.Inventory) {
|
|||||||
|
|
||||||
// chainMoveRetry is how long the copy waits before it tries again.
|
// chainMoveRetry is how long the copy waits before it tries again.
|
||||||
const chainMoveRetry = 30 * time.Second
|
const chainMoveRetry = 30 * time.Second
|
||||||
|
|
||||||
|
// kindBuildsNotLaidOut is the condition of the layout of the builds under their modules that has not succeeded: until it
|
||||||
|
// has, no build is read, and a send that needs one is refused with the reason.
|
||||||
|
const kindBuildsNotLaidOut = "builds-not-laid-out"
|
||||||
|
|
||||||
|
// layBuildsOutSoon lays the builds out under their modules as soon as this controller holds the lease (the chain refuses a
|
||||||
|
// write before), and tries again every half minute while it fails, loudly: each failure is printed, and the first raises an
|
||||||
|
// urgent condition until it has worked. It is idempotent (LayBuildsOut).
|
||||||
|
func layBuildsOutSoon(ctx context.Context, inv *inventory.Inventory) {
|
||||||
|
failed := false
|
||||||
|
for {
|
||||||
|
err := inv.LayBuildsOut(ctx, func(format string, args ...any) { fmt.Printf("chain: "+format+"\n", args...) })
|
||||||
|
o := conditions.Observation{Scope: conditions.ScopeBuild, ID: kindBuildsNotLaidOut, Token: kindBuildsNotLaidOut,
|
||||||
|
Kind: kindBuildsNotLaidOut, Severity: conditions.Urgent, Resolver: conditions.ResolverOperator,
|
||||||
|
Source: "chain", Summary: "the builds are not laid out under their modules, so none is read: "}
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("chain: the builds are not laid out under their modules, and none is read until they are: %v\n", err)
|
||||||
|
if keeper := conditionsFrom; keeper != nil && !failed {
|
||||||
|
o.Summary += err.Error()
|
||||||
|
if _, oerr := keeper.Observe(ctx, o); oerr == nil {
|
||||||
|
failed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if keeper := conditionsFrom; keeper != nil && failed {
|
||||||
|
_, _ = keeper.Clear(ctx, o.Key(), "laid out")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(chainMoveRetry):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -412,6 +412,7 @@ func keepSilentKeys(ctx context.Context, c silentClock, want map[silentWait]time
|
|||||||
byTimer()
|
byTimer()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
c = boundedClock{c}
|
||||||
held, err := c.Held(ctx)
|
held, err := c.Held(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
byTimer()
|
byTimer()
|
||||||
@@ -435,6 +436,51 @@ func keepSilentKeys(ctx context.Context, c silentClock, want map[silentWait]time
|
|||||||
return first
|
return first
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// silentCallBound is how long one call to the silent clock may take while the look holds the plans (novox/hq issue 1121).
|
||||||
|
// The vendored key listing reads its watcher without the context, so a consumer lost when the broker reloaded its user
|
||||||
|
// list left the look waiting for good, with the plans and every node held.
|
||||||
|
var silentCallBound = 20 * time.Second
|
||||||
|
|
||||||
|
// boundedClock is a clock whose every call is answered within silentCallBound, or fails: a call that never returns is
|
||||||
|
// left behind on its own goroutine.
|
||||||
|
type boundedClock struct{ silentClock }
|
||||||
|
|
||||||
|
func boundedCall[T any](ctx context.Context, what string, call func(context.Context) (T, error)) (T, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, silentCallBound)
|
||||||
|
defer cancel()
|
||||||
|
type answer struct {
|
||||||
|
v T
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
done := make(chan answer, 1)
|
||||||
|
go func() { v, err := call(ctx); done <- answer{v, err} }()
|
||||||
|
select {
|
||||||
|
case a := <-done:
|
||||||
|
return a.v, a.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
var zero T
|
||||||
|
return zero, fmt.Errorf("%s: no answer from the bus within %s: %w", what, silentCallBound, ctx.Err())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b boundedClock) Arm(ctx context.Context, w silentWait, ttl time.Duration, sent time.Time) error {
|
||||||
|
_, err := boundedCall(ctx, "arming a wait", func(ctx context.Context) (struct{}, error) {
|
||||||
|
return struct{}{}, b.silentClock.Arm(ctx, w, ttl, sent)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b boundedClock) Held(ctx context.Context) (map[silentWait]time.Time, error) {
|
||||||
|
return boundedCall(ctx, "listing the waits", b.silentClock.Held)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b boundedClock) Reported(ctx context.Context, w silentWait) error {
|
||||||
|
_, err := boundedCall(ctx, "dropping a wait", func(ctx context.Context) (struct{}, error) {
|
||||||
|
return struct{}{}, b.silentClock.Reported(ctx, w)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var silentTimers = struct {
|
var silentTimers = struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
timers map[silentWait]time.Time
|
timers map[silentWait]time.Time
|
||||||
|
|||||||
@@ -179,3 +179,32 @@ func TestKeysThatCannotBeReadAreWatchedByTimersAndTheLookFails(t *testing.T) {
|
|||||||
|
|
||||||
// A plan moved by a process the serving controller did not spawn — a verb run at the control node's terminal — is
|
// A plan moved by a process the serving controller did not spawn — a verb run at the control node's terminal — is
|
||||||
// looked at: the controller follows its own plan-moved (issue 974, round 1).
|
// looked at: the controller follows its own plan-moved (issue 974, round 1).
|
||||||
|
|
||||||
|
// A clock whose bus answers nothing, ever: a consumer lost when the broker reloaded its user list, a key listing that
|
||||||
|
// never reaches its end.
|
||||||
|
type hungClock struct{ silentClock }
|
||||||
|
|
||||||
|
func (hungClock) Held(context.Context) (map[silentWait]time.Time, error) {
|
||||||
|
select {} // never answers, whatever the context says (the vendored Keys reads its watcher without it)
|
||||||
|
}
|
||||||
|
|
||||||
|
// issue 1121: the look holds the plans while it keeps the silent keys, so a key listing that never ends held the plans
|
||||||
|
// and every node for good. Every call to the clock has a bound, and the wait is looked at by a timer of its own.
|
||||||
|
func TestAClockThatNeverAnswersDoesNotHoldTheLookForever(t *testing.T) {
|
||||||
|
was := silentCallBound
|
||||||
|
silentCallBound = 50 * time.Millisecond
|
||||||
|
t.Cleanup(func() { silentCallBound = was })
|
||||||
|
w := silentWait{Plan: "plan-hung", Tier: 0, Node: "ace"}
|
||||||
|
sent := time.Now().UTC()
|
||||||
|
t.Cleanup(func() { silentTimers.mu.Lock(); delete(silentTimers.timers, w); silentTimers.mu.Unlock() })
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- keepSilentKeys(t.Context(), hungClock{}, map[silentWait]time.Time{w: sent}, sent) }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a clock that never answered was reported as fine")
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("the look is still waiting for a clock that never answers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,9 @@ type Backend interface {
|
|||||||
Keys(ctx context.Context) ([]string, error)
|
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 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)
|
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 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)
|
History(ctx context.Context, key string) ([]Entry, error)
|
||||||
}
|
}
|
||||||
@@ -58,6 +62,7 @@ type Memory struct {
|
|||||||
values map[string][]Entry
|
values map[string][]Entry
|
||||||
revision uint64
|
revision uint64
|
||||||
watchers []*watcher
|
watchers []*watcher
|
||||||
|
filters map[string]int
|
||||||
// Now is the clock the entries are dated by; nil is time.Now.
|
// Now is the clock the entries are dated by; nil is time.Now.
|
||||||
Now func() time.Time
|
Now func() time.Time
|
||||||
// writes counts the creates and updates taken.
|
// writes counts the creates and updates taken.
|
||||||
@@ -155,7 +160,9 @@ func (m *Memory) write(key string, value []byte) uint64 {
|
|||||||
}
|
}
|
||||||
m.values[key] = h
|
m.values[key] = h
|
||||||
for _, w := range m.watchers {
|
for _, w := range m.watchers {
|
||||||
w.push(clone(e))
|
if w.match(key) {
|
||||||
|
w.push(clone(e))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return e.Revision
|
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.
|
// 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 {
|
type watcher struct {
|
||||||
mu sync.Mutex
|
match func(string) bool
|
||||||
q []Entry
|
mu sync.Mutex
|
||||||
wake chan struct{}
|
q []Entry
|
||||||
|
wake chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *watcher) push(e Entry) {
|
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) {
|
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()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if m.fail != nil {
|
if m.fail != nil {
|
||||||
return nil, m.fail
|
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))
|
keys := make([]string, 0, len(m.values))
|
||||||
for k := range m.values {
|
for k := range m.values {
|
||||||
keys = append(keys, k)
|
keys = append(keys, k)
|
||||||
}
|
}
|
||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
|
if !w.match(k) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if e, held := m.last(k); held {
|
if e, held := m.last(k); held {
|
||||||
w.q = append(w.q, clone(e))
|
w.q = append(w.q, clone(e))
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -38,7 +38,7 @@ func OnTheBus(ctx context.Context, conn *nats.Conn, holds Holds) (*Chain, error)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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).
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-1
@@ -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.
|
// 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) {
|
func (s Bucket[T]) History(ctx context.Context, key string) ([]Kept[T], error) {
|
||||||
all, err := s.b.History(ctx, key)
|
all, err := s.b.History(ctx, key)
|
||||||
@@ -182,10 +236,22 @@ type Chain struct {
|
|||||||
Rollouts Bucket[Rollout]
|
Rollouts Bucket[Rollout]
|
||||||
Assignments Bucket[Assignment]
|
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
|
rollouts Backend
|
||||||
holds Holds
|
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:
|
// 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.
|
// 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} }
|
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 {
|
if holds == nil {
|
||||||
holds = func(context.Context) error { return nil }
|
holds = func(context.Context) error { return nil }
|
||||||
}
|
}
|
||||||
return New(NewMemory(), NewMemory(), NewMemory(), holds)
|
builds := NewMemory()
|
||||||
|
return New(builds, NewMemory(), NewMemory(), holds).WithManifests(builds)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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).
|
// reuse key (novox/hq ADR 0350) and what it published (ADR 0408 §1).
|
||||||
type Build struct {
|
type Build struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
State string `json:"state,omitempty"`
|
State string `json:"state,omitempty"`
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
Ref string `json:"ref,omitempty"`
|
Ref string `json:"ref,omitempty"`
|
||||||
Module string `json:"module,omitempty"`
|
Module string `json:"module,omitempty"`
|
||||||
Commit string `json:"commit,omitempty"`
|
Commit string `json:"commit,omitempty"`
|
||||||
On string `json:"on,omitempty"`
|
On string `json:"on,omitempty"`
|
||||||
Path string `json:"path,omitempty"`
|
Path string `json:"path,omitempty"`
|
||||||
Manifest json.RawMessage `json:"manifest,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"`
|
Against []string `json:"against,omitempty"`
|
||||||
Mirrored []string `json:"mirrored,omitempty"`
|
Mirrored []string `json:"mirrored,omitempty"`
|
||||||
Read []ReadRepository `json:"read,omitempty"`
|
Read []ReadRepository `json:"read,omitempty"`
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ import (
|
|||||||
|
|
||||||
// Build is one attempt, whichever way it went.
|
// Build is one attempt, whichever way it went.
|
||||||
type Build struct {
|
type Build struct {
|
||||||
ID string
|
ID string
|
||||||
Repository string
|
// ManifestDigest names the manifest in the manifests bucket, when a chain keeps it apart from the record (issue 1121).
|
||||||
Ref string
|
ManifestDigest string
|
||||||
|
Repository string
|
||||||
|
Ref string
|
||||||
// Module is empty for a build that failed before it knew what it was building.
|
// Module is empty for a build that failed before it knew what it was building.
|
||||||
Module string
|
Module string
|
||||||
Commit string
|
Commit string
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ func TestABuildsBuildSourceComesBackOverWhatItRead(t *testing.T) {
|
|||||||
if got := read["build-agent"]; len(got) != 1 || got[0].Paths != nil || got[0].Own {
|
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)
|
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 {
|
if err != nil || !found {
|
||||||
t.Fatalf("the build is not on the bus: %v %v", found, err)
|
t.Fatalf("the build is not on the bus: %v %v", found, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,22 @@ type chainState struct {
|
|||||||
// is not saved yet.
|
// is not saved yet.
|
||||||
view *rolloutView
|
view *rolloutView
|
||||||
pending map[string]pendingMerge
|
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
|
// 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) {
|
func (i *Inventory) UseChainOpener(open ChainOpener) {
|
||||||
i.state.mu.Lock()
|
i.state.mu.Lock()
|
||||||
i.state.open = open
|
i.state.open = open
|
||||||
|
i.state.requireLayout = true
|
||||||
i.state.mu.Unlock()
|
i.state.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,19 +78,30 @@ func (i *Inventory) attached() *chain.Chain {
|
|||||||
return i.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) {
|
func (i *Inventory) theChain(ctx context.Context) (*chain.Chain, error) {
|
||||||
i.state.mu.Lock()
|
i.state.mu.Lock()
|
||||||
defer i.state.mu.Unlock()
|
|
||||||
if i.chain == nil && i.state.open != nil {
|
if i.chain == nil && i.state.open != nil {
|
||||||
c, err := i.state.open(ctx)
|
c, err := i.state.open(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
i.state.mu.Unlock()
|
||||||
return nil, fmt.Errorf("%w: %v", ErrNoChain, err)
|
return nil, fmt.Errorf("%w: %v", ErrNoChain, err)
|
||||||
}
|
}
|
||||||
i.chain = c
|
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 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,14 @@ func (i *Inventory) chainRecordBuildRequest(ctx context.Context, c *chain.Chain,
|
|||||||
if a.At.IsZero() {
|
if a.At.IsZero() {
|
||||||
a.At = time.Now().UTC()
|
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++ {
|
for try := 0; try < casTries; try++ {
|
||||||
_, err := c.Builds.Create(ctx, key, askedRecord(a))
|
_, err := c.Builds.Create(ctx, key, askedRecord(a))
|
||||||
if err == nil {
|
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)
|
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) {
|
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 {
|
if err != nil || !found || k.Value.Request == nil {
|
||||||
return BuildRequest{}, false, err
|
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.
|
// 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 {
|
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++ {
|
for try := 0; try < casTries; try++ {
|
||||||
cur, found, err := c.Builds.Get(ctx, key)
|
cur, found, err := c.Builds.Get(ctx, key)
|
||||||
if err != nil || !found || cur.Value.State != chain.BuildAsked || cur.Value.Request == nil {
|
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
|
// 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.
|
// 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) {
|
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 {
|
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)
|
oldest := time.Now().Add(-KeptFor)
|
||||||
var out []RequestOutcome
|
var out []RequestOutcome
|
||||||
for id, k := range all {
|
for _, k := range all {
|
||||||
|
id := strings.TrimPrefix(k.Key, chain.RequestPrefix+".")
|
||||||
r := k.Value.Request
|
r := k.Value.Request
|
||||||
if r == nil || !r.At.After(oldest) {
|
if r == nil || !r.At.After(oldest) {
|
||||||
continue
|
continue
|
||||||
@@ -120,6 +155,11 @@ func (i *Inventory) chainRequests(ctx context.Context, c *chain.Chain) ([]Reques
|
|||||||
return out, nil
|
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
|
// 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.
|
// asked once, and never twice.
|
||||||
func (i *Inventory) RequestsToAskAgain(ctx context.Context) ([]BuildRequest, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
all, err := c.Builds.All(ctx)
|
all, err := requestRecords(ctx, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var out []BuildRequest
|
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 {
|
if k.Value.State == chain.BuildAsked && k.Value.Request != nil && k.Value.Request.Reask == chain.ReaskPending {
|
||||||
out = append(out, fromRequest(id, *k.Value.Request))
|
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 {
|
if err != nil {
|
||||||
return false, err
|
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 {
|
if err != nil || !found || cur.Value.Request == nil || cur.Value.Request.Reask != chain.ReaskPending {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
cur.Value.Request.Reask = chain.ReaskClaimed
|
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) {
|
if errors.Is(err, chain.ErrMoved) {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func TestWhatTheColumnRecordedIsCopiedOntoTheKeysAndOverwritesNothing(t *testing
|
|||||||
inv, c := anInventoryOnTheChain(t)
|
inv, c := anInventoryOnTheChain(t)
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
at := time.Date(2026, 10, 18, 4, 0, 0, 0, time.UTC)
|
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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
put := func(module string, a chain.Assignment) {
|
put := func(module string, a chain.Assignment) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/novox/mesh-controller/internal/catalogue"
|
"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
|
// **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)
|
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" {
|
if err != nil || !found || kept.Value.ReuseKey != "rk-1" {
|
||||||
t.Fatalf("build-1 on the bus: %+v %v %v", kept.Value, found, err)
|
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))
|
t.Fatalf("a build recorded twice has %d values", len(history))
|
||||||
}
|
}
|
||||||
list, err := inv.Builds(ctx, "notes", 10)
|
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 != "" {
|
if again, _, _ := inv.BuildRequestByID(ctx, "build-1"); again.NotAsked != "" {
|
||||||
t.Fatalf("an answered request was marked not asked: %+v", again)
|
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.
|
// asked, marked unknown, answered.
|
||||||
t.Fatalf("the record has %d values", len(history))
|
t.Fatalf("the record has %d values", len(history))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ package inventory
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
|
"github.com/novox/mesh-controller/internal/chain"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A build whose source is unchanged is never a move (novox/hq issue 280).
|
// 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
|
id, commit, fingerprint string
|
||||||
made []byte
|
made []byte
|
||||||
manifest []byte
|
manifest []byte
|
||||||
|
manifestDigest string
|
||||||
failedGate bool
|
failedGate bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +71,15 @@ func (i *Inventory) StandingBuild(ctx context.Context, module, build string) (st
|
|||||||
for k, b := range builds {
|
for k, b := range builds {
|
||||||
if b.id == build {
|
if b.id == build {
|
||||||
s := builds[standing(builds, k)]
|
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
|
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
|
// 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.
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := map[string]map[string]string{}
|
// Read from the commit pointers, which carry each commit's source fingerprint: no record is read (issue 1121).
|
||||||
for m, builds := range all {
|
return i.chainSourceFingerprints(ctx, c)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSourceFingerprints is SourceFingerprints for one module: commit → fingerprint.
|
// BuildSourceFingerprints is SourceFingerprints for one module: commit → fingerprint.
|
||||||
func (i *Inventory) BuildSourceFingerprints(ctx context.Context, module string) (map[string]string, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if all[module] == nil {
|
// This module's commit pointers only (issue 1121).
|
||||||
return map[string]string{}, nil
|
return i.chainBuildSourceFingerprints(ctx, c, module)
|
||||||
}
|
|
||||||
return all[module], nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// heldMade is, per module, what the build standing for its newest successful build made — what Held
|
// heldMade is, per module, what the build standing for its newest successful build made — what Held
|
||||||
// answers (novox/hq issue 280).
|
// answers (novox/hq issue 280).
|
||||||
func (i *Inventory) heldMade(ctx context.Context) (map[string][]Artifact, error) {
|
func (i *Inventory) heldMade(ctx context.Context) (map[string][]Artifact, error) {
|
||||||
all, _, err := i.sourceRows(ctx, "")
|
c, err := i.theChain(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := map[string][]Artifact{}
|
return i.chainHeldMade(ctx, c)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SameSourceCommits is the commits of the builds in a build's run (novox/hq issue 280): the build and
|
// SameSourceCommits is the commits of the builds in a build's run (novox/hq issue 280): the build and
|
||||||
|
|||||||
Reference in New Issue
Block a user