diff --git a/cmd/mesh-controller/declaration_sweep.go b/cmd/mesh-controller/declaration_sweep.go index a9b710a8..0f9430cf 100644 --- a/cmd/mesh-controller/declaration_sweep.go +++ b/cmd/mesh-controller/declaration_sweep.go @@ -116,8 +116,10 @@ func namedByCompositions(node string, values [][]byte) (map[string]bool, error) return named, nil } -// keptCompositionsOf are the values of every composition in a node's bucket `composed-`: what the controller keeps there -// are each module's five newest fingerprints and the node's own (ADR 0539 §4). A node with no bucket has none. +// keptCompositionsOf are the values of every composition in a node's bucket `composed-` a node may still apply: +// each key's newest revision (each module's five newest fingerprints and the node's own, ADR 0539 §4), and any older +// revision of it whose digest a want of the node names, since the node applies exactly the composition its want names +// (clause 1) and a want may still name a revision a newer one replaced (the review's C1). A node with no bucket has none. func keptCompositionsOf(ctx context.Context, js jetstream.JetStream, node string) ([][]byte, error) { bucket, err := sdkdelivery.ComposedBucketFor(node) if err != nil { @@ -137,16 +139,76 @@ func keptCompositionsOf(ctx context.Context, js jetstream.JetStream, node string if err != nil { return nil, fmt.Errorf("the compositions of %s: %w", node, err) } + wanted, err := digestsWantedOn(ctx, js, node) + if err != nil { + return nil, err + } var out [][]byte for _, key := range keys { - e, err := kv.Get(ctx, key) + history, err := kv.History(ctx, key) if errors.Is(err, jetstream.ErrKeyNotFound) { continue } if err != nil { return nil, fmt.Errorf("the composition %s: %w", key, err) } - out = append(out, e.Value()) + out = append(out, keptRevisions(history, wanted)...) + } + return out, nil +} + +// keptRevisions are, of one key's history oldest first, the newest value when the key is not deleted, and every older +// value whose composition's digest is wanted. +func keptRevisions(history []jetstream.KeyValueEntry, wanted map[string]bool) [][]byte { + var out [][]byte + for i, e := range history { + if e.Operation() != jetstream.KeyValuePut { + continue + } + if i == len(history)-1 { + out = append(out, e.Value()) + continue + } + c, err := sdkdelivery.DecodeComposed(e.Value()) + if err != nil { + continue + } + if d, err := sdkdelivery.ComposedDigestOf(c); err == nil && wanted[d] { + out = append(out, e.Value()) + } + } + return out +} + +// digestsWantedOn is the digest of every composition a want of the node names, its own want's among them; none when +// the assigner's bucket is not there yet. +func digestsWantedOn(ctx context.Context, js jetstream.JetStream, node string) (map[string]bool, error) { + kv, err := js.KeyValue(ctx, sdkdelivery.AssignerWantsBucket) + if errors.Is(err, jetstream.ErrBucketNotFound) { + return map[string]bool{}, nil + } + if err != nil { + return nil, fmt.Errorf("the wants of %s: %w", node, err) + } + lister, err := kv.ListKeys(ctx, jetstream.MetaOnly()) + if err != nil { + return nil, fmt.Errorf("the wants of %s: %w", node, err) + } + out := map[string]bool{} + for key := range lister.Keys() { + e, err := kv.Get(ctx, key) + if err != nil { + continue + } + if n, _, err := sdkdelivery.ParseWantKey(key); err == nil && n == node { + if w, err := sdkdelivery.DecodeWant(e.Value()); err == nil && w.Composed != "" { + out[w.Composed] = true + } + } else if n, err := sdkdelivery.ParseWantNodeKey(key); err == nil && n == node { + if w, err := sdkdelivery.DecodeNodeWant(e.Value()); err == nil { + out[w.Composed] = true + } + } } return out, nil } diff --git a/cmd/mesh-controller/declaration_sweep_wanted_test.go b/cmd/mesh-controller/declaration_sweep_wanted_test.go new file mode 100644 index 00000000..c2434fc2 --- /dev/null +++ b/cmd/mesh-controller/declaration_sweep_wanted_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + sdkdelivery "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/nats-io/nats.go/jetstream" +) + +// 1945 (the review's C1): the sweep keeps the files of a key's newest composition and of every older revision a want +// of the node still names, so a node told to apply a replaced revision finds its files; an older revision no want names, +// and a deleted key, keep nothing. +func TestTheSweepKeepsWhatAWantStillNames(t *testing.T) { + revision := func(content string, rev uint64, op jetstream.KeyValueOp) fakeComposedEntry { + c := sdkdelivery.Composed{Node: "ace", Module: "m", FP: sdkdelivery.SourcePrefix2 + strings.Repeat("a", 64), + Resources: []json.RawMessage{json.RawMessage(`{"id":"m.f","type":"file","module":"m","path":"/etc/m","content":"` + content + `"}`)}} + raw, err := sdkdelivery.EncodeComposed(c) + if err != nil { + t.Fatal(err) + } + return fakeComposedEntry{key: "k", value: raw, rev: rev, op: op} + } + digestOfEntry := func(e fakeComposedEntry) string { + c, _ := sdkdelivery.DecodeComposed(e.value) + d, _ := sdkdelivery.ComposedDigestOf(c) + return d + } + one, two, three := revision("one", 1, jetstream.KeyValuePut), revision("two", 2, jetstream.KeyValuePut), revision("three", 3, jetstream.KeyValuePut) + history := []jetstream.KeyValueEntry{one, two, three} + kept := keptRevisions(history, map[string]bool{digestOfEntry(one): true}) + if len(kept) != 2 || string(kept[0]) != string(one.value) || string(kept[1]) != string(three.value) { + t.Errorf("kept %d revisions, want the wanted first and the newest", len(kept)) + } + if kept := keptRevisions(history, map[string]bool{}); len(kept) != 1 { + t.Errorf("with no want, kept %d revisions, want the newest alone", len(kept)) + } + deleted := []jetstream.KeyValueEntry{one, fakeComposedEntry{key: "k", rev: 2, op: jetstream.KeyValueDelete}} + if kept := keptRevisions(deleted, map[string]bool{}); len(kept) != 0 { + t.Errorf("a deleted key kept %d revisions", len(kept)) + } +}