diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 298ed73..165a9d6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -261,9 +261,9 @@ to fail over to: a read overlapping a want fails. reads as unknown, never raw) / marks (`OTMK`) / column object framing (the trailer-dictionary bytes column; the leading layout still read) and key layout, the attribute hash+binary encoding (the SeriesID pre-image), symbol table (`OTSY`), the bucket index (format - v7: entries with their term-scoped block sets, split claims and writing term, tombstones, wants, - per-writer flush watermarks and the block high-water mark — readable back to v1, not writable by - older nodes), WAL record framing + v8: entries with their term-scoped block sets, split claims, writing term and rollup layout, + tombstones, wants, per-writer flush watermarks, the block high-water mark and the lineage catalog — + readable back to v1, not writable by older nodes), WAL record framing (additive record types), record-key footer (`OTKY`), the metric part column layout (`[series:int128, ts:int64, value:float64]` + optional `sf:float64`), and the profile symbol-store sidecar (`OTSP`, version 2: a compressed body with its raw length; version 1 still diff --git a/backend/ARCH.md b/backend/ARCH.md index cb1446e..4eab50d 100644 --- a/backend/ARCH.md +++ b/backend/ARCH.md @@ -206,6 +206,44 @@ a demonstration. short of reading the rows says how. A part covering only some of a group's claim therefore overlaps every member while replacing none; no single-part rule resolves it, and repair refuses to publish one beside the other (`engine/ARCH.md`, "Repair"). + **A group's claim outlives its members in the lineage catalog** (`Index.Catalog`, format v8). A + merge that consumes a whole group folds the claim into its output's blocks, and a group split + again leaves the outer claim on no entry at all — yet a want for an inner member can be answered + by a successor of the outer ancestry only through that claim. So every commit records the claims + its entries and wants carry (`RecordLineage`), and `Index.Lineage` — the catalog plus the claims + still on entries and wants — is what every identity question is answered by: `Satisfying`, + `Discharging`, `TrimWants`, `TrimHoles` (so `Revokes`), joint coverage (`Covered`, which realizes + any group the lineage records, transitively), `Subsumed` (every group the lineage records that the + parts complete, not only the groups some entry still carries) and `Missing` (the members of every + group derived from a want's rows that no later group consumed, so a member split again is asked for + through its own split). A joint answer names any part descending through a recorded split, not + only one carrying a claim: a merge that consumed a whole inner group folded its claim away. The shared repair pass carries the engine's catalog through every index it builds, down to + commit admission (`internal/repair`). A claim is a fact about rows wherever it was recorded, so + lineages merge freely: a rebase unions the rival's catalog, a replica installing a peer's index + keeps its own, and `cluster/partsync` answers a want with the wanting node's lineage beside the + peer's (`SatisfyingWith`), since the peer may never have seen either split. Only a committed + claim is recorded: a group run a lost CAS attempt allocated is handed out again, maybe to other + parts. `MaxLineage` (= `MaxRemovals`) is a target, not a cap: `TrimCatalog` ages the lowest groups + out first but never one the ancestry of an outstanding want, a hole or a live entry passes through, + since dropping it could turn a want a successor still answers into absence and then a hole. A live + entry reaches its whole ancestry, not only its own claim: once it is lost, its want carries only that + claim, and an outer ancestor's successor answers it only through the rest. The walk shares one + visited set across the whole part set, so each group is followed once: trimming a catalog 200 over + the target with 600 live parts over 4200 claims takes 2.7ms, against 1.7ms following only wants and + holes. One closure per part costs 6.6ms there, and one closure over the union of every part's blocks + costs 390ms, since growing an interval of thousands of runs rebuilds it on each step. Only + unreachable claims go; a catalog every claim of which is reached stays above the target, and the + engine warns once. + **Every identity question over a lineage goes through one `Relater`** (and `Index.Relations` over + an index), which indexes the claims' block runs and computes each closure — what a set of blocks + holds, derives from, or completes — by a work queue that visits only the claims a newly gained run + meets, and remembers it. A batch then pays for each part's closure once: a commit's `TrimWants`, + a repair pass, partsync's answers to one peer. The naive fixed point rescans the whole lineage until + nothing changes, per candidate per want, which a nested catalog near the target turns into seconds + under the state lock: `TrimWants` of 256 nested wants over a 4000-claim catalog and 100 live parts + took 6.7s that way and takes 10ms this way. + **An entry carries its part's rollup layout** (`Entry.Rollup`, format v8; nil is unknown), so a + writer checks a rival's layout without opening the rival's part (`engine/ARCH.md`, "Merge"). **Allocation is the shard owner's alone, and scoped to its tenure**: `Index.NextBlock(term)` is one above `AllocatedBlocks`, the persisted high-water mark, and above every block the live entries, wants and group runs still name — within `term`, starting at `(term, 1)` for a tenure the index has @@ -290,8 +328,9 @@ a demonstration. - **A hole is revocable.** The commit is not cross-replica atomic (unlike ClickHouse's `createEmptyPartInsteadOfLost`, which checks non-existence on every replica in one transaction), so an owner may acknowledge a loss while a peer still holds the data. `Revokes` therefore replaces - a hole with any data-bearing entry at the same prefix or any successor containing its blocks, and - `TrimHoles` runs on every commit — so a repair fetch, a rival's adopted entry and a merge all + a hole with any data-bearing entry at the same prefix or any successor containing its blocks, + `TrimHoles` also with a complete split group jointly covering it (the answer `Satisfying` gives the + hole's want), and `TrimHoles` runs on every commit — so a repair fetch, a rival's adopted entry and a merge all revoke it as a side effect. `NextBlock` counts a hole's interval, because the part it stands for may yet come back and two parts must never claim one identity. - **`LostParts` is monotone and cluster-visible.** It is carried forward across commits and raised @@ -299,12 +338,17 @@ a demonstration. is a fact, not a level: a per-node gauge that a restart or a successful repair clears erases the only record that a range of a shard was ever acknowledged as gone. It follows ClickHouse's `/lost_part_count`. -- **Format v7 is a hard read break.** `Decode` rejects any version above the one it knows, so - reading is backward compatible (v1–v6 still decode, unset fields ordering below everything a - writer produces) but **writing is not**: a node on pre-v7 code fails on the first v7 index it +- **Format v8 is a hard read break.** `Decode` rejects any version above the one it knows, so + reading is backward compatible (v1–v7 still decode, unset fields ordering below everything a + writer produces) but **writing is not**: a node on pre-v8 code fails on the first v8 index it reads. Every node that reads a given index must be upgraded together. The path differs by deployment and both matter — on a shared backend every node reads the same index object, and in `PrivateBackend` mode `cluster/partsync` reads its peers' indexes. + **A v7 index knows no layout and no catalog.** Every entry's layout is unknown until a writer + opens the part and its next commit records the manifest's; the first v8 commit catalogs the claims + its entries and wants still carry. A layout rides behind a flag bit in the entry's flag word, so + an unknown one costs nothing; the catalog is canonical like the rest — sorted, deduplicated, + bounded — and `Decode` rejects any other form. **A v6 index is term 0 throughout**: its blocks, its entries' and wants' writing terms and its high-water mark. A clustered tenure then allocates from `(term, 1)` above all of it, and a writer with no cluster continues term 0's numbering where v6 left it. An interval's lowest block number diff --git a/backend/bucketindex/bench_layout_test.go b/backend/bucketindex/bench_layout_test.go new file mode 100644 index 0000000..9ab6611 --- /dev/null +++ b/backend/bucketindex/bench_layout_test.go @@ -0,0 +1,55 @@ +package bucketindex_test + +import ( + "testing" + + "github.com/oteldb/storage/backend/bucketindex" +) + +// benchIndexWithLineage is [benchIndex] as a metric shard's index is: every part records its rollup +// layout, and the catalog holds a long history of split groups. +func benchIndexWithLineage() *bucketindex.Index { + ix := benchIndex() + + for i := range ix.Entries { + ix.Entries[i].Rollup = &bucketindex.Rollup{Tiers: []bucketindex.RollupTier{ + {Before: 1 << 50, Interval: 60e9, Agg: 1}, + {Before: 1 << 49, Interval: 3600e9, Agg: 1}, + }} + } + + for i := range uint64(256) { + ix.Catalog = append(ix.Catalog, bucketindex.Claim{ + Blocks: bucketindex.Blocks(4*i+1, 4*i+2), Group: bucketindex.Blocks(4096+2*i, 4097+2*i), + }) + } + + ix.Catalog = bucketindex.MergeLineage(ix.Catalog) + + return ix +} + +func BenchmarkIndexEncodeWithLineage(b *testing.B) { + ix := benchIndexWithLineage() + buf := ix.AppendBinary(nil) + + b.SetBytes(int64(len(buf))) + b.ReportAllocs() + + for b.Loop() { + buf = ix.AppendBinary(buf[:0]) + } +} + +func BenchmarkIndexDecodeWithLineage(b *testing.B) { + data := benchIndexWithLineage().AppendBinary(nil) + + b.SetBytes(int64(len(data))) + b.ReportAllocs() + + for b.Loop() { + if _, err := bucketindex.Decode(data); err != nil { + b.Fatal(err) + } + } +} diff --git a/backend/bucketindex/bench_lineage_test.go b/backend/bucketindex/bench_lineage_test.go new file mode 100644 index 0000000..ae67380 --- /dev/null +++ b/backend/bucketindex/bench_lineage_test.go @@ -0,0 +1,176 @@ +package bucketindex_test + +import ( + "fmt" + "slices" + "testing" + + "github.com/oteldb/storage/backend/bucketindex" +) + +const ( + benchChains = 500 + benchDepth = 8 +) + +// nestedLineage is a catalog near [bucketindex.MaxLineage] of deeply nested groups: benchChains +// ancestors, each split benchDepth times, every split taking the previous split's second member. It +// returns the catalog, the innermost member of each chain, and each chain's ancestor block. +func nestedLineage() (catalog []bucketindex.Claim, inner []bucketindex.Want, ancestors []uint64) { + next := uint64(1) + take := func() uint64 { next++; return next - 1 } + + for c := range benchChains { + anc := take() + ancestors = append(ancestors, anc) + consumed := bucketindex.Blocks(anc) + + var member uint64 + + for d := range benchDepth { + a, b := take(), take() + claim := bucketindex.Claim{Blocks: consumed, Group: bucketindex.Blocks(a, b)} + catalog = append(catalog, claim) + consumed, member = bucketindex.Blocks(b), b + + if d == benchDepth-1 { + inner = append(inner, bucketindex.Want{ + Prefix: fmt.Sprintf("inner-%04d", c), Blocks: bucketindex.Blocks(member), + Claim: claim, Level: uint32(d + 1), + }) + } + } + } + + return bucketindex.MergeLineage(catalog), inner, ancestors +} + +// nestedLive is a shard's live set over the nested lineage: 100 parts, each a high-level successor +// of five chains' ancestors — so it answers those chains' inner wants only through the whole chain. +func nestedLive(ancestors []uint64) []bucketindex.Entry { + live := make([]bucketindex.Entry, 0, 100) + + for i := range 100 { + live = append(live, bucketindex.Entry{ + Prefix: fmt.Sprintf("succ-%03d", i), + Blocks: bucketindex.Blocks(ancestors[5*i : 5*i+5]...), + Level: benchDepth + 5, + }) + } + + return live +} + +func BenchmarkTrimWantsNestedLineage(b *testing.B) { + catalog, inner, ancestors := nestedLineage() + live := nestedLive(ancestors) + + for _, n := range []int{1, 16, 256} { + b.Run(fmt.Sprintf("wants=%d", n), func(b *testing.B) { + wants := inner[:n] + b.ReportAllocs() + + for b.Loop() { + bucketindex.TrimWants(append([]bucketindex.Want(nil), wants...), live, catalog...) + } + }) + } +} + +// BenchmarkSatisfyingWithNestedLineage is a peer answering a batch of repair wants with the wanting +// node's lineage, as partsync does: the relations built once per peer, then asked per want. +func BenchmarkSatisfyingWithNestedLineage(b *testing.B) { + catalog, inner, ancestors := nestedLineage() + peer := &bucketindex.Index{Entries: nestedLive(ancestors)} + + for _, n := range []int{1, 16} { + b.Run(fmt.Sprintf("wants=%d", n), func(b *testing.B) { + b.ReportAllocs() + + for b.Loop() { + rel := peer.Relations(catalog...) + for i := range n { + rel.Satisfying(inner[i]) + } + } + }) + } +} + +// nestedCompletion is, for the first n chains of [nestedLineage], the parts completing each chain's +// outermost group: the first member of every split and both innermost members, so every group of +// those chains is complete, the outer ones only through the ones nested in them. +func nestedCompletion(n int) []bucketindex.Entry { + var out []bucketindex.Entry + + for c := range n { + anc := 1 + uint64(c)*(1+2*benchDepth) + consumed := bucketindex.Blocks(anc) + + for d := range benchDepth { + a := anc + 1 + 2*uint64(d) + claim := bucketindex.Claim{Blocks: consumed, Group: bucketindex.Blocks(a, a+1)} + consumed = bucketindex.Blocks(a + 1) + + members := []uint64{a} + if d == benchDepth-1 { + members = append(members, a+1) + } + + for _, m := range members { + out = append(out, bucketindex.Entry{ + Prefix: fmt.Sprintf("c%04d-%d", c, m), Blocks: bucketindex.Blocks(m), Claim: claim, Level: uint32(d + 1), + }) + } + } + } + + return out +} + +// BenchmarkSubsumedNestedLineage is a repair commit's pruning: the parts completing n nested chains +// judged against the live set over the whole catalog. +func BenchmarkSubsumedNestedLineage(b *testing.B) { + catalog, _, ancestors := nestedLineage() + live := nestedLive(ancestors) + + for _, n := range []int{1, 16, 256} { + b.Run(fmt.Sprintf("chains=%d", n), func(b *testing.B) { + added := nestedCompletion(n) + all := append(append([]bucketindex.Entry(nil), live...), added...) + b.ReportAllocs() + + for b.Loop() { + bucketindex.LineageOf(all).With(catalog...).Relater().Subsumed(live, added) + } + }) + } +} + +// BenchmarkRecordLineageSaturated is a commit's catalog upkeep past the target: the nested catalog +// with 200 newer groups nothing reaches, and a live set of every chain's innermost member beside the +// successors, so trimming walks each live part's whole ancestry. +func BenchmarkRecordLineageSaturated(b *testing.B) { + catalog, inner, ancestors := nestedLineage() + + for i := range uint64(200) { + base := uint64(1 << 20) + catalog = append(catalog, bucketindex.Claim{ + Blocks: bucketindex.Blocks(base + 2*i), Group: bucketindex.Blocks(base + 2*i + 1), + }) + } + + catalog = bucketindex.MergeLineage(catalog) + live := nestedLive(ancestors) + + for i := range inner { + live = append(live, inner[i].Entry()) + } + + b.ReportAllocs() + + for b.Loop() { + ix := &bucketindex.Index{Entries: live, Catalog: slices.Clone(catalog)} + ix.RecordLineage() + } +} diff --git a/backend/bucketindex/bench_test.go b/backend/bucketindex/bench_test.go index 543b30f..9bd1433 100644 --- a/backend/bucketindex/bench_test.go +++ b/backend/bucketindex/bench_test.go @@ -1,9 +1,11 @@ package bucketindex_test import ( + "context" "fmt" "testing" + "github.com/oteldb/storage/backend" "github.com/oteldb/storage/backend/bucketindex" ) @@ -64,6 +66,28 @@ func BenchmarkIndexDecode(b *testing.B) { } } +// BenchmarkIndexLoad is the read a load and every rebase pay: a versioned read of the stored index +// and its decode. +func BenchmarkIndexLoad(b *testing.B) { + ctx := context.Background() + be := backend.Memory() + key := "default/metrics/" + bucketindex.Object + + ix := benchIndex() + if _, err := ix.Save(ctx, be, key, backend.VersionAbsent); err != nil { + b.Fatal(err) + } + + b.SetBytes(int64(len(ix.AppendBinary(nil)))) + b.ReportAllocs() + + for b.Loop() { + if _, _, err := bucketindex.LoadVersioned(ctx, be, key); err != nil { + b.Fatal(err) + } + } +} + // BenchmarkTrimWants is the identity relation a commit runs per want over the live set. func BenchmarkTrimWants(b *testing.B) { ix := benchIndex() diff --git a/backend/bucketindex/block.go b/backend/bucketindex/block.go index 8712a86..932681d 100644 --- a/backend/bucketindex/block.go +++ b/backend/bucketindex/block.go @@ -398,58 +398,9 @@ func (ix *Index) NextBlock(term uint64) Block { } // Covered is the set of blocks the index's data-bearing parts hold between them, including the -// ancestor blocks of every split group whose members are all present. It is what decides a want is -// met when no single part contains it. -func (ix *Index) Covered() Interval { return ix.covered(Entry.Data) } - -func (ix *Index) covered(admit func(Entry) bool) Interval { - var ( - runs = make([]Gap, 0, len(ix.Entries)) - claims []Claim - ) - - for i := range ix.Entries { - e := &ix.Entries[i] - if !admit(*e) { - continue - } - - if e.Blocks.Valid() { - runs = e.Blocks.appendRuns(runs) - } - - if e.Claim.Valid() { - claims = append(claims, e.Claim) - } - } - - held := fromRuns(runs) - - // A group whose members were themselves split resolves only once the inner group has, so the - // pass repeats while it keeps realizing claims. Each round retires at least one claim, so it - // terminates in at most len(claims) rounds. - for len(claims) > 0 { - rest := claims[:0] - - for _, c := range claims { - if held.Contains(c.Group) { - held = held.Union(c.Blocks) - - continue - } - - rest = append(rest, c) - } - - if len(rest) == len(claims) { - break - } - - claims = rest - } - - return held -} +// ancestor blocks of every split group — any the index's lineage records — whose members are all +// present. It is what decides a want is met when no single part contains it. +func (ix *Index) Covered() Interval { return ix.Relations().Covered() } // Satisfying returns a part in this index whose data answers w, if any: the part itself if the // index still has it, otherwise the largest part containing every block w covers — or, when no @@ -465,111 +416,28 @@ func (ix *Index) covered(admit func(Entry) bool) Interval { // // "Largest" is widest interval first, then highest level, then prefix, so the answer does not // depend on index order and a caller fetches the fewest objects for the most data. -func (ix *Index) Satisfying(w Want) (Entry, bool) { return ix.satisfying(w, Entry.Data) } - -// Discharging returns the entry that ends w as an obligation: a part satisfying it, or the hole -// committed in its place. It is what decides a want is no longer outstanding. -func (ix *Index) Discharging(w Want) (Entry, bool) { - return ix.satisfying(w, func(Entry) bool { return true }) -} - -func (ix *Index) satisfying(w Want, admit func(Entry) bool) (Entry, bool) { - var ( - best Entry - found bool - ) - - owed := w.Entry() - - for i := range ix.Entries { - e := ix.Entries[i] - if !admit(e) { - continue - } - - if e.Prefix == w.Prefix { - return e, true - } - - if !e.Blocks.Contains(w.Blocks) && !e.Supersedes(owed) { - continue - } +func (ix *Index) Satisfying(w Want) (Entry, bool) { return ix.Relations().Satisfying(w) } - if !found || betterSuccessor(e, best) { - best, found = e, true - } - } - - if found { - return best, true - } - - return ix.jointlySatisfying(w, admit) +// SatisfyingWith is [Index.Satisfying] relating identities by extra lineage as well as this index's +// own: the catalog of the index that owes w, which is what relates a member of a group split again +// to a successor of the outer ancestry when the index answering w never saw either split. +func (ix *Index) SatisfyingWith(w Want, extra Lineage) (Entry, bool) { + return ix.Relations(extra...).Satisfying(w) } -// jointlySatisfying answers a want no single part contains: it holds only where the whole index -// covers w's blocks, and then names the best member of the split group that supplies them. -func (ix *Index) jointlySatisfying(w Want, admit func(Entry) bool) (Entry, bool) { - if !w.Blocks.Valid() || !ix.covered(admit).Contains(w.Blocks) { - return Entry{}, false - } - - var ( - best Entry - found bool - ) - - for i := range ix.Entries { - e := ix.Entries[i] - if !admit(e) || !e.Claim.Blocks.Contains(w.Blocks) { - continue - } - - if !found || betterSuccessor(e, best) { - best, found = e, true - } - } - - return best, found -} +// Discharging returns the entry that ends w as an obligation: a part satisfying it, or the hole +// committed in its place. It is what decides a want is no longer outstanding. +func (ix *Index) Discharging(w Want) (Entry, bool) { return ix.Relations().Discharging(w) } -// Missing returns the blocks of the split groups this index would need to answer w and does not -// hold: the members of every group whose claim covers w but whose own blocks are not all present. +// Missing returns the blocks this index would need to answer w and does not hold: the members of +// every split group derived from w's rows, followed through every group the lineage records, that no +// later group consumed. A member split again is not asked for; the members of its split are. // // It is what makes a group repairable one member at a time. A want naming a pre-split part is // answered by no single peer entry, so repair asks for the group's blocks instead, and the peer -// resolves each of those against its own index by ordinary containment. -func (ix *Index) Missing(w Want) []Block { - if !w.Blocks.Valid() { - return nil - } - - held := ix.covered(Entry.Data) - if held.Contains(w.Blocks) { - return nil - } - - var out []Block - - for i := range ix.Entries { - c := ix.Entries[i].Claim - if !ix.Entries[i].Data() || !c.Valid() || !c.Blocks.Contains(w.Blocks) { - continue - } - - c.Group.Each(func(b Block) bool { - if !held.Contains(Interval{Min: b, Max: b}) && !slices.Contains(out, b) { - out = append(out, b) - } - - return true - }) - } - - slices.SortFunc(out, Block.Compare) - - return out -} +// resolves each of those against its own index — through containment, or its own lineage for a +// member it split again. +func (ix *Index) Missing(w Want) []Block { return ix.Relations().Missing(w) } func betterSuccessor(a, b Entry) bool { switch { @@ -582,40 +450,6 @@ func betterSuccessor(a, b Entry) bool { } } -// groupClaim is one split group as an index sees it: what it jointly covers, the members that must -// all be present for that to hold, and the lowest level any present member sits at. -type groupClaim struct { - blocks Interval - group Interval - level uint32 -} - -// groupsOf collects the distinct split groups the data-bearing entries carry. A group's own block -// run is allocated once per shard, so its bounds identify it. -func groupsOf(entries []Entry) []groupClaim { - var out []groupClaim - - for i := range entries { - c := entries[i].Claim - if !entries[i].Data() || !c.Valid() { - continue - } - - k := slices.IndexFunc(out, func(g groupClaim) bool { - return g.group.Min == c.Group.Min && g.group.Max == c.Group.Max - }) - if k < 0 { - out = append(out, groupClaim{blocks: c.Blocks, group: c.Group, level: entries[i].Level}) - - continue - } - - out[k].level = min(out[k].level, entries[i].Level) - } - - return out -} - // Complete reports whether every member of the split group e belongs to is present among entries, // so the ancestry it claims is covered as if one part held it. It is false for an entry carrying no // claim: there is no group to complete. @@ -633,33 +467,57 @@ func Subsumed(live, added []Entry) map[string]struct{} { } // Subsumed returns the prefixes among live whose rows are wholly inside the parts added: one added -// part holds them at a higher level, or a split group the addition completes claims them. +// part holds them at a higher level, or a split group the lineage records, completed by live and +// added together, claims them below the level of the parts completing it. The group need not be on +// any entry: an outer group whose members were all split again is realized through the inner ones. // // Either direction left out keeps two representations of one set of rows live: a part holding a // group's whole ancestry beside its members, or the ancestors beside the group a repair completes. func (l Lineage) Subsumed(live, added []Entry) map[string]struct{} { + return l.Relater().Subsumed(live, added) +} + +// Subsumed is [Lineage.Subsumed] over r's lineage. +func (r *Relater) Subsumed(live, added []Entry) map[string]struct{} { out := make(map[string]struct{}) for i := range added { for j := range live { - if live[j].Prefix != added[i].Prefix && l.Subsumes(added[i], live[j]) { + if live[j].Prefix != added[i].Prefix && r.Subsumes(added[i], live[j]) { out[live[j].Prefix] = struct{}{} } } } all := slices.Concat(live, added) - held := (&Index{Entries: all}).Covered() - for _, g := range groupsOf(all) { - if !held.Contains(g.group) { + var runs []Gap + + for i := range all { + if all[i].Data() && all[i].Blocks.Valid() { + runs = all[i].Blocks.appendRuns(runs) + } + } + + _, complete := r.realize(fromRuns(runs)) + + for i, c := range r.l { + if !complete[i] { + continue + } + + claimed := r.holds(c.Blocks) + if !slices.ContainsFunc(live, func(e Entry) bool { return claimed.Contains(e.Blocks) }) { continue } - claimed := l.holds(g.blocks) + level, ok := r.level(c.Group, all) + if !ok { + continue + } for j := range live { - if claimed.Contains(live[j].Blocks) && live[j].Level < g.level { + if claimed.Contains(live[j].Blocks) && live[j].Level < level { out[live[j].Prefix] = struct{}{} } } diff --git a/backend/bucketindex/bucketindex.go b/backend/bucketindex/bucketindex.go index 7dee7b5..65f8d1f 100644 --- a/backend/bucketindex/bucketindex.go +++ b/backend/bucketindex/bucketindex.go @@ -47,6 +47,11 @@ type Entry struct { // resolves them to the later tenure (see [Lineage.Subsumes]). Zero for a writer with no cluster, // and for every part an index written before format v7 names. Added in format v7. Term uint64 + // Rollup is the downsampling layout the part's rows have had applied, as its manifest records + // it; nil is unknown: a part whose manifest carries none, or an entry written before format v8, + // until the part is opened and its next commit fills it in. It is carried here so a writer can + // check a rival's layout against its own without opening the rival's part. Added in format v8. + Rollup *Rollup // Hole marks this entry as an acknowledged loss rather than a part: the writer owed a repair // for these blocks, no owner could supply them, and it committed this in their place so the // obligation stops blocking reads. It names no objects and holds no rows. @@ -95,6 +100,10 @@ type Index struct { // because the live set shrinks and identity must not: see [Index.NextBlock]. Added in format v6, // as a term-0 number; a [Block] since format v7. AllocatedBlocks Block + // Catalog is the lineage of every split group this shard's writers committed, newest + // [MaxLineage] kept, sorted by group — see [Index.RecordLineage], which is the whole of why it + // outlives the entries that carried each claim. Added in format v8. + Catalog []Claim } // Add inserts e, replacing any existing entry with the same prefix, keeping the index sorted. @@ -141,6 +150,8 @@ func (ix *Index) Overlapping(start, end int64) []Entry { const ( magic0, magic1 = 'B', 'I' + // v8 adds each entry's rollup layout, behind a flag bit, and appends the lineage catalog. + // // v7 scopes every block to the ownership term that allocated it (a [Block] is a term and a // number), and adds the writing tenure's term to entries and wants. A v6 block is read as term 0. // @@ -158,7 +169,7 @@ const ( // Reading is backward compatible; writing is not. [Decode] rejects any version above this one, // so a node on pre-v5 code cannot read an index this one writes: every node that reads a given // index must be upgraded together. See backend/ARCH.md for the blast radius per deployment. - version = 7 + version = 8 ) // AppendBinary appends the versioned binary encoding of the index to dst (append-style for @@ -177,6 +188,7 @@ func (ix *Index) AppendBinary(dst []byte) []byte { dst = binary.AppendUvarint(dst, entryFlags(*e)) dst = appendClaim(dst, e.Claim) dst = binary.AppendUvarint(dst, e.Term) + dst = appendRollup(dst, e.Rollup) } dst = binary.AppendUvarint(dst, ix.FlushedEpoch) @@ -221,6 +233,12 @@ func (ix *Index) AppendBinary(dst []byte) []byte { dst = binary.AppendUvarint(dst, ix.AllocatedBlocks.Term) dst = binary.AppendUvarint(dst, ix.AllocatedBlocks.N) + dst = binary.AppendUvarint(dst, uint64(len(ix.Catalog))) + for _, c := range ix.Catalog { + dst = appendInterval(dst, c.Blocks) + dst = appendInterval(dst, c.Group) + } + return dst } @@ -334,13 +352,57 @@ func decodeTail(ix *Index, buf []byte, ver uint8) error { } } - if ix.AllocatedBlocks.N, _, ok = readUvarint(rest); !ok { + if ix.AllocatedBlocks.N, rest, ok = readUvarint(rest); !ok { return errors.Wrap(ErrCorrupt, "bad allocated blocks") } + if ver < 8 { + return nil + } + + if ix.Catalog, ok = readCatalog(rest); !ok { + return errors.Wrap(ErrCorrupt, "bad lineage catalog") + } + return nil } +// readCatalog parses the v8+ lineage catalog. It is canonical like everything else — every claim +// valid, strictly ascending in [MergeLineage]'s order — so encode∘decode stays the identity. +func readCatalog(buf []byte) ([]Claim, bool) { + n, buf, ok := readUvarint(buf) + if !ok || n > uint64(len(buf)) { + return nil, false + } + + if n == 0 { + return nil, true + } + + out := make([]Claim, 0, n) + for range n { + var c Claim + + if c.Blocks, buf, ok = readInterval(buf, version); !ok { + return nil, false + } + + if c.Group, buf, ok = readInterval(buf, version); !ok || !c.Valid() { + return nil, false + } + + out = append(out, c) + } + + for i := 1; i < len(out); i++ { + if compareClaims(out[i-1], out[i]) >= 0 { + return nil, false + } + } + + return out, true +} + // decodeEntries parses the part list, bounding the count by what the buffer could hold as the // removal and writer counts are. func decodeEntries(buf []byte, ver uint8) ([]Entry, []byte, error) { @@ -396,16 +458,25 @@ func decodeEntries(buf []byte, ver uint8) ([]Entry, []byte, error) { return out, buf, nil } -// entryFlagHole is bit 0 of an entry's flag word, [Entry.Hole]. Every other bit is reserved, and -// [decodeBlockIdentity] rejects them: an unknown bit must never read as a data-bearing part. -const entryFlagHole = 1 +// entryFlagHole is bit 0 of an entry's flag word, [Entry.Hole], and entryFlagRollup bit 1, a layout +// following the entry (format v8). Every other bit is reserved, and [decodeBlockIdentity] rejects +// them: an unknown bit must never read as a data-bearing part. +const ( + entryFlagHole = 1 + entryFlagRollup = 2 +) func entryFlags(e Entry) uint64 { + var flags uint64 if e.Hole { - return entryFlagHole + flags |= entryFlagHole } - return 0 + if e.Rollup != nil { + flags |= entryFlagRollup + } + + return flags } func decodeBlockIdentity(buf []byte, ver uint8, e *Entry) ([]byte, bool) { @@ -422,7 +493,12 @@ func decodeBlockIdentity(buf []byte, ver uint8, e *Entry) ([]byte, bool) { e.Level = uint32(level) flags, buf, ok := readUvarint(buf) - if !ok || flags & ^uint64(entryFlagHole) != 0 { + known := uint64(entryFlagHole) + if ver >= 8 { + known |= entryFlagRollup + } + + if !ok || flags&^known != 0 { return nil, false } @@ -440,6 +516,12 @@ func decodeBlockIdentity(buf []byte, ver uint8, e *Entry) ([]byte, bool) { } } + if flags&entryFlagRollup != 0 { + if e.Rollup, buf, ok = readRollup(buf); !ok { + return nil, false + } + } + return buf, true } diff --git a/backend/bucketindex/bucketindex_test.go b/backend/bucketindex/bucketindex_test.go index 7e007b0..be4ce92 100644 --- a/backend/bucketindex/bucketindex_test.go +++ b/backend/bucketindex/bucketindex_test.go @@ -80,12 +80,12 @@ func TestGoldenEncoding(t *testing.T) { FlushedEpoch: 3, Generation: bucketindex.Generation{Term: 4, Counter: 5}, } - // magic 'B','I', version 7, count 1, len 1, 'a', zigzag(1)=2, zigzag(2)=4, unset blocks 0, - // level 0, flags 0, no claim 0, writer term 0, flushedEpoch 3, generation term 4, generation - // counter 5, removal count 0, writer-epoch count 0, want count 0, lost parts 0, allocated - // blocks' term 0 and number 0. + // magic 'B','I', version 8, count 1, len 1, 'a', zigzag(1)=2, zigzag(2)=4, unset blocks 0, + // level 0, flags 0 (no layout follows), no claim 0, writer term 0, flushedEpoch 3, generation + // term 4, generation counter 5, removal count 0, writer-epoch count 0, want count 0, lost parts + // 0, allocated blocks' term 0 and number 0, empty catalog 0. assert.Equal(t, - []byte{'B', 'I', 7, 1, 1, 'a', 2, 4, 0, 0, 0, 0, 0, 3, 4, 5, 0, 0, 0, 0, 0, 0}, + []byte{'B', 'I', 8, 1, 1, 'a', 2, 4, 0, 0, 0, 0, 0, 3, 4, 5, 0, 0, 0, 0, 0, 0, 0}, ix.AppendBinary(nil)) } @@ -212,6 +212,11 @@ func FuzzDecode(f *testing.F) { }, }).AppendBinary(nil)) f.Add(fullIndex().AppendBinary(nil)) + f.Add([]byte{ + 'B', 'I', 7, 1, 1, 'a', 2, 4, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 5, 2, 0, + 2, 0, 1, 6, 1, 0, 7, 0, 1, 2, 0, 4, 0, 3, 3, 4, 5, 1, 1, 'r', 6, 7, 1, 1, 'w', 8, 9, 10, + 1, 1, 'x', 5, 2, 0, 5, 0, 1, 26, 28, 11, 12, 0, 2, 15, 2, 16, + }) f.Add([]byte{ 'B', 'I', 6, 1, 1, 'a', 2, 4, 1, 4, 1, 2, 3, 2, 0, 1, 6, 7, 0, 1, 4, 0, 3, 4, 5, 1, 1, 'r', 6, 7, 1, 1, 'w', 8, 9, 10, 1, 1, 'x', 5, 5, 0, 1, 26, 28, 11, 12, 0, 15, 16, @@ -230,5 +235,6 @@ func FuzzDecode(f *testing.F) { assert.Equal(t, ix.Epochs, again.Epochs) assert.Equal(t, ix.Wanted, again.Wanted) assert.Equal(t, ix.AllocatedBlocks, again.AllocatedBlocks) + assert.Equal(t, ix.Catalog, again.Catalog) }) } diff --git a/backend/bucketindex/catalog.go b/backend/bucketindex/catalog.go new file mode 100644 index 0000000..7c45f26 --- /dev/null +++ b/backend/bucketindex/catalog.go @@ -0,0 +1,217 @@ +package bucketindex + +import "slices" + +// MaxLineage is how many split groups' claims the lineage catalog ([Index.Catalog]) keeps before it +// ages the oldest out, as [MaxRemovals] bounds the tombstones: no writer can know when every replica +// has seen a group, so it is remembered for a bounded number of groups instead. +// +// It is a target, not a hard cap. A claim that some outstanding want, hole or live entry still +// reaches through its ancestry is never aged out ([Index.TrimCatalog]): dropping it could turn a want +// a successor still answers into definitive absence, and three of those into a hole over rows that +// exist. Only unreachable claims go, so the catalog outgrows the target only by lineage something +// still depends on — which the live part set and the outstanding wants bound — and a split is rare +// enough, one per straddling merge, that 4096 groups is a long history. +const MaxLineage = MaxRemovals + +// RecordLineage adds to the catalog every split group's claim the index's entries and wants carry, +// and trims it ([Index.TrimCatalog]). A writer calls it on every index it commits, so a group's claim +// outlives the members that carry it: a merge that consumes a whole group folds the claim into its +// output's blocks, and a group split again leaves the outer claim on no entry at all, while a want for +// an inner member still needs it to be answered by a successor of the outer ancestry. +// +// Only a committed claim may be recorded. One allocated by a commit attempt that lost its CAS names a +// block run the retry hands out again, maybe to other parts, and recorded it would say those parts +// hold rows they do not. It reports how many claims the catalog holds above [MaxLineage] because they +// are still reachable. +func (ix *Index) RecordLineage() int { + claims := slices.Clone(ix.Catalog) + + for i := range ix.Entries { + claims = append(claims, ix.Entries[i].Claim) + } + + for i := range ix.Wanted { + claims = append(claims, ix.Wanted[i].Claim) + } + + ix.Catalog = MergeLineage(claims) + + return ix.TrimCatalog() +} + +// TrimCatalog ages the oldest claims out of a catalog above [MaxLineage], but never one the index +// still reaches: a claim the ancestry of some outstanding want, hole or live entry passes through. +// It reports how many claims remain above [MaxLineage]. +func (ix *Index) TrimCatalog() int { + over := len(ix.Catalog) - MaxLineage + if over <= 0 { + return 0 + } + + reached := ix.reachedClaims() + + kept := ix.Catalog[:0] + for _, c := range ix.Catalog { + _, keep := reached[c.key()] + if over > 0 && !keep { + over-- + + continue + } + + kept = append(kept, c) + } + + ix.Catalog = kept + + return max(0, len(ix.Catalog)-MaxLineage) +} + +// reachedClaims is the catalog's claims the index still depends on: the whole upward ancestry of every +// want, hole and live entry — each group whose members its blocks meet, and again from that group's +// ancestors. A live entry needs its whole ancestry, not only its own claim: once it is lost, its want +// carries only that claim, and a successor of an outer ancestor answers it only through the rest. +func (ix *Index) reachedClaims() map[claimKey]struct{} { + l := ix.Lineage() + r := l.Relater() + reached := make([]bool, len(l)) + + for i := range ix.Wanted { + r.reachAncestry(reached, ix.Wanted[i].Blocks) + } + + for i := range ix.Entries { + r.reachAncestry(reached, ix.Entries[i].Blocks) + } + + out := make(map[claimKey]struct{}) + + for i, ok := range reached { + if ok { + out[l[i].key()] = struct{}{} + } + } + + for i := range ix.Entries { + if c := ix.Entries[i].Claim; c.Valid() { + out[c.key()] = struct{}{} + } + } + + for i := range ix.Wanted { + if c := ix.Wanted[i].Claim; c.Valid() { + out[c.key()] = struct{}{} + } + } + + return out +} + +// MergeLineage is the catalog holding every valid claim of the given ones, in its one canonical form: +// sorted by group, one claim per group. A group's run is allocated once per shard, so its bounds +// identify it, and a second claim on the same run is the same group recorded twice. It does not trim: +// only a writer with the whole index ([Index.TrimCatalog]) knows which claims are still reached. +func MergeLineage(catalogs ...[]Claim) []Claim { + claims := slices.DeleteFunc(slices.Concat(catalogs...), func(c Claim) bool { return !c.Valid() }) + slices.SortStableFunc(claims, compareClaims) + claims = slices.CompactFunc(claims, func(a, b Claim) bool { return compareClaims(a, b) == 0 }) + + if len(claims) == 0 { + return nil + } + + return claims +} + +// claimKey is the identity [MergeLineage] deduplicates by. +type claimKey struct{ group, blocks [2]Block } + +func (c Claim) key() claimKey { + return claimKey{group: [2]Block{c.Group.Min, c.Group.Max}, blocks: [2]Block{c.Blocks.Min, c.Blocks.Max}} +} + +// compareClaims orders a catalog: by group, and by ancestry within one, which only a corrupt or +// hostile input has. +func compareClaims(a, b Claim) int { + if c := a.Group.Min.Compare(b.Group.Min); c != 0 { + return c + } + + if c := a.Group.Max.Compare(b.Group.Max); c != 0 { + return c + } + + if c := a.Blocks.Min.Compare(b.Blocks.Min); c != 0 { + return c + } + + return a.Blocks.Max.Compare(b.Blocks.Max) +} + +// With is l and every valid claim of claims it lacks, without writing into l's backing array and +// without the [MaxLineage] target a stored catalog has: it relates identities, it is not persisted. +func (l Lineage) With(claims ...Claim) Lineage { + if len(claims) == 0 { + return l + } + + known := make(map[claimKey]struct{}, len(l)+len(claims)) + for _, c := range l { + known[c.key()] = struct{}{} + } + + out := l + + for _, c := range claims { + if !c.Valid() { + continue + } + + if _, ok := known[c.key()]; ok { + continue + } + + known[c.key()] = struct{}{} + + if len(out) == len(l) { + out = slices.Clip(out) + } + + out = append(out, c) + } + + return out +} + +// Lineage is every claim the index relates identities by: its catalog, and the claims its entries +// and wants carry that no commit has recorded yet. +func (ix *Index) Lineage() Lineage { + l := Lineage(slices.Clone(ix.Catalog)) + sorted := len(l) + + add := func(c Claim) { + if !c.Valid() { + return + } + + // The catalog is sorted, so a claim it holds is found by search; the few claims entries and + // wants add beyond it are checked one by one. + if _, found := slices.BinarySearchFunc(l[:sorted], c, compareClaims); found || + slices.ContainsFunc(l[sorted:], c.Equal) { + return + } + + l = append(l, c) + } + + for i := range ix.Entries { + add(ix.Entries[i].Claim) + } + + for i := range ix.Wanted { + add(ix.Wanted[i].Claim) + } + + return l +} diff --git a/backend/bucketindex/catalog_test.go b/backend/bucketindex/catalog_test.go new file mode 100644 index 0000000..d101125 --- /dev/null +++ b/backend/bucketindex/catalog_test.go @@ -0,0 +1,302 @@ +package bucketindex_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oteldb/storage/backend/bucketindex" +) + +// nested is the #725 shape: {1,2} split into the outer group {10..12}, member 12 split again with +// block 7 into the inner group {20..22}. +var ( + outerClaim = bucketindex.Claim{Blocks: bucketindex.Blocks(1, 2), Group: bucketindex.Blocks(10, 11, 12)} + innerClaim = bucketindex.Claim{Blocks: bucketindex.Blocks(7, 12), Group: bucketindex.Blocks(20, 21, 22)} + innerWant = bucketindex.Want{Prefix: "inner-20", Blocks: bucketindex.Blocks(20), Claim: innerClaim, Level: 2} + successor = bucketindex.Entry{Prefix: "succ", Blocks: bucketindex.Blocks(1, 2, 7), Level: 3} +) + +func TestRecordLineageKeepsRetiredGroups(t *testing.T) { + t.Parallel() + + ix := &bucketindex.Index{} + ix.Add(bucketindex.Entry{Prefix: "m10", Blocks: bucketindex.Blocks(10), Claim: outerClaim, Level: 1}) + ix.RecordLineage() + require.True(t, ix.Remove("m10")) + + ix.RecordWant(innerWant) + ix.RecordLineage() + + assert.Equal(t, []bucketindex.Claim{outerClaim, innerClaim}, ix.Catalog, "sorted by group, the retired one kept") + + ix.RecordLineage() + assert.Len(t, ix.Catalog, 2, "recording again adds nothing") +} + +// newerGroups is n unrelated split groups above every block the nested shape uses. +func newerGroups(n int) []bucketindex.Claim { + out := make([]bucketindex.Claim, 0, n) + for i := range uint64(n) { + out = append(out, bucketindex.Claim{ + Blocks: bucketindex.Blocks(1000 + 2*i), Group: bucketindex.Blocks(1001 + 2*i), + }) + } + + return out +} + +func TestTrimCatalogAgesOutUnreachableGroups(t *testing.T) { + t.Parallel() + + ix := &bucketindex.Index{Catalog: bucketindex.MergeLineage(newerGroups(bucketindex.MaxLineage + 10))} + + assert.Zero(t, ix.TrimCatalog()) + require.Len(t, ix.Catalog, bucketindex.MaxLineage) + assert.Equal(t, bucketindex.Blocks(1021), ix.Catalog[0].Group, "the oldest groups age out first") + assert.Nil(t, bucketindex.MergeLineage(nil, []bucketindex.Claim{{}}), "an unset claim is not a group") +} + +// TestTrimCatalogKeepsReachedAncestry is the saturation case: an old nested want whose outer claim +// lives only in the catalog, and more than MaxLineage newer groups after it. The outer claim is the +// oldest, and still kept, since the want reaches it; newer groups nothing reaches go instead. +func TestTrimCatalogKeepsReachedAncestry(t *testing.T) { + t.Parallel() + + ix := &bucketindex.Index{Catalog: bucketindex.MergeLineage([]bucketindex.Claim{outerClaim})} + ix.RecordWant(innerWant) + ix.Catalog = bucketindex.MergeLineage(ix.Catalog, newerGroups(bucketindex.MaxLineage+10)) + + assert.Zero(t, ix.RecordLineage()) + require.Len(t, ix.Catalog, bucketindex.MaxLineage) + assert.True(t, ix.Catalog[0].Equal(outerClaim), "the want still reaches the oldest group") + assert.True(t, ix.Catalog[1].Equal(innerClaim), "and its own") + + peer := &bucketindex.Index{Entries: []bucketindex.Entry{successor}} + _, ok := peer.SatisfyingWith(innerWant, ix.Lineage()) + assert.True(t, ok, "so the successor still answers it") + + ix.SatisfyWant(innerWant.Prefix) + ix.Catalog = bucketindex.MergeLineage(ix.Catalog, newerGroups(bucketindex.MaxLineage+20)) + assert.Zero(t, ix.RecordLineage()) + assert.False(t, ix.Catalog[0].Equal(outerClaim), "once nothing reaches it the old group ages out") +} + +// TestTrimCatalogOutgrowsTheTargetOnlyByReachedClaims: when every claim is reached, none is aged out, +// and the catalog reports how far it stands above the target. +func TestTrimCatalogOutgrowsTheTargetOnlyByReachedClaims(t *testing.T) { + t.Parallel() + + ix := &bucketindex.Index{} + for i, c := range newerGroups(bucketindex.MaxLineage + 3) { + ix.Add(bucketindex.Entry{Prefix: fmt.Sprintf("m%05d", i), Blocks: c.Group, Claim: c, Level: 1}) + } + + assert.Equal(t, 3, ix.RecordLineage()) + assert.Len(t, ix.Catalog, bucketindex.MaxLineage+3) +} + +// TestJointCoverageThroughRetiredOuterClaim: every member of the outer group {10,11} over {1} was +// split again, into {20,21} and {30,31}. Only the complete inner groups remain, and only the catalog +// still says {10,11} holds {1}. A want for {1} is answered jointly by an inner member, and a peer short +// of one inner member is asked for exactly that one. +func TestJointCoverageThroughRetiredOuterClaim(t *testing.T) { + t.Parallel() + + outer := bucketindex.Claim{Blocks: bucketindex.Blocks(1), Group: bucketindex.Blocks(10, 11)} + innerA := bucketindex.Claim{Blocks: bucketindex.Blocks(10), Group: bucketindex.Blocks(20, 21)} + innerB := bucketindex.Claim{Blocks: bucketindex.Blocks(11), Group: bucketindex.Blocks(30, 31)} + want := bucketindex.Want{Prefix: "orig", Blocks: bucketindex.Blocks(1)} + + member := func(block uint64, c bucketindex.Claim) bucketindex.Entry { + return bucketindex.Entry{ + Prefix: "m" + string(rune('a'+block%26)), Blocks: bucketindex.Blocks(block), Claim: c, Level: 2, + } + } + + peer := &bucketindex.Index{Entries: []bucketindex.Entry{ + member(20, innerA), member(21, innerA), member(30, innerB), member(31, innerB), + }} + + _, ok := peer.Satisfying(want) + require.False(t, ok, "without the outer claim nothing relates the inner groups to {1}") + + got, ok := peer.SatisfyingWith(want, bucketindex.Lineage{outer}) + require.True(t, ok, "the inner groups jointly cover {10,11}, which the outer claim says holds {1}") + assert.True(t, got.Claim.Equal(innerA) || got.Claim.Equal(innerB), "answered by an inner member") + + short := &bucketindex.Index{Entries: peer.Entries[:3], Catalog: []bucketindex.Claim{outer}} + assert.Equal(t, []bucketindex.Block{{N: 31}}, short.Missing(want), "only the absent inner member is asked for") + + local := &bucketindex.Index{Catalog: []bucketindex.Claim{outer}} + assert.Equal(t, []bucketindex.Block{{N: 10}, {N: 11}}, local.Missing(want), + "knowing only the outer split, repair asks for its members, and a peer answers each by its own lineage") +} + +// TestSatisfyingThroughTheCatalog is the nested false hole at the index level: the want carries only +// the inner claim, and a successor of the outer ancestry answers it only through the catalog of the +// index that owes it. +func TestSatisfyingThroughTheCatalog(t *testing.T) { + t.Parallel() + + peer := &bucketindex.Index{Entries: []bucketindex.Entry{successor}} + + _, ok := peer.Satisfying(innerWant) + require.False(t, ok, "nothing the peer or the want carries relates 20 to {1,2}") + + got, ok := peer.SatisfyingWith(innerWant, bucketindex.Lineage{outerClaim}) + require.True(t, ok) + assert.Equal(t, "succ", got.Prefix) + + own := &bucketindex.Index{Entries: []bucketindex.Entry{successor}, Catalog: []bucketindex.Claim{outerClaim}} + _, ok = own.Satisfying(innerWant) + assert.True(t, ok, "an index's own catalog relates them too") + + assert.Empty(t, bucketindex.TrimWants([]bucketindex.Want{innerWant}, own.Entries, outerClaim)) + assert.Len(t, bucketindex.TrimWants([]bucketindex.Want{innerWant}, own.Entries), 1) + + hole := innerWant.Entry() + hole.Hole = true + assert.Empty(t, bucketindex.TrimHoles([]bucketindex.Entry{hole}, own.Entries, outerClaim), + "the successor revokes the hole through the catalog") + assert.Len(t, bucketindex.TrimHoles([]bucketindex.Entry{hole}, own.Entries), 1) + + low := successor + low.Level = 1 + _, ok = (&bucketindex.Index{Entries: []bucketindex.Entry{low}}).SatisfyingWith(innerWant, bucketindex.Lineage{outerClaim}) + assert.False(t, ok, "a successor must still sit above the member it answers for") +} + +func TestLineageWithDoesNotAlias(t *testing.T) { + t.Parallel() + + base := make(bucketindex.Lineage, 1, 4) + base[0] = outerClaim + + a := base.With(innerClaim) + b := base.With(bucketindex.Claim{Blocks: bucketindex.Blocks(3), Group: bucketindex.Blocks(30)}) + + assert.Len(t, base, 1) + assert.True(t, a[1].Equal(innerClaim), "a second extension does not overwrite the first") + assert.Len(t, b, 2) + assert.Equal(t, base, base.With(outerClaim, bucketindex.Claim{}), "a known or unset claim adds nothing") +} + +// TestSubsumedByCatalogOnlyGroup is {1,2} split into {10,11}, each member split again, so the outer +// claim is on no entry. The four inner fragments hold every row of {1,2}: a lower copy of block 1 +// beside them is subsumed, and neither the fragments nor a successor above them are. +func TestSubsumedByCatalogOnlyGroup(t *testing.T) { + t.Parallel() + + outer := bucketindex.Claim{Blocks: bucketindex.Blocks(1, 2), Group: bucketindex.Blocks(10, 11)} + innerA := bucketindex.Claim{Blocks: bucketindex.Blocks(10), Group: bucketindex.Blocks(20, 21)} + innerB := bucketindex.Claim{Blocks: bucketindex.Blocks(11), Group: bucketindex.Blocks(22, 23)} + + var fragments []bucketindex.Entry + for b := uint64(20); b <= 23; b++ { + c := innerA + if b >= 22 { + c = innerB + } + + fragments = append(fragments, bucketindex.Entry{ + Prefix: fmt.Sprintf("f%d", b), Blocks: bucketindex.Blocks(b), Claim: c, Level: 3, + }) + } + + ancestor := bucketindex.Entry{Prefix: "anc", Blocks: bucketindex.Blocks(1)} + above := bucketindex.Entry{Prefix: "above", Blocks: bucketindex.Blocks(1, 7), Level: 4} + live := []bucketindex.Entry{ancestor, above} + + assert.Empty(t, bucketindex.LineageOf(fragments).Subsumed(live, fragments), + "without the outer claim nothing relates the fragments to block 1") + + got := bucketindex.LineageOf(fragments).With(outer).Subsumed(live, fragments) + assert.Equal(t, map[string]struct{}{"anc": {}}, got) + + short := fragments[:3] + assert.Empty(t, bucketindex.LineageOf(short).With(outer).Subsumed(live, short), + "an incomplete inner group leaves the outer one incomplete") +} + +// TestJointlySatisfyingByMergedFragments is the outer group completed by parts that carry no claim: +// each inner group was merged whole, folding its claim into the output's blocks. Only the catalog +// still relates them to {1}, and the want is answered by one of them. +func TestJointlySatisfyingByMergedFragments(t *testing.T) { + t.Parallel() + + outer := bucketindex.Claim{Blocks: bucketindex.Blocks(1), Group: bucketindex.Blocks(10, 11)} + innerA := bucketindex.Claim{Blocks: bucketindex.Blocks(10), Group: bucketindex.Blocks(20, 21)} + innerB := bucketindex.Claim{Blocks: bucketindex.Blocks(11), Group: bucketindex.Blocks(30, 31)} + want := bucketindex.Want{Prefix: "orig", Blocks: bucketindex.Blocks(1)} + + peer := &bucketindex.Index{ + Entries: []bucketindex.Entry{ + {Prefix: "ma", Blocks: bucketindex.Blocks(10, 20, 21), Level: 3}, + {Prefix: "mb", Blocks: bucketindex.Blocks(11, 30, 31), Level: 3}, + {Prefix: "other", Blocks: bucketindex.Blocks(5), Level: 3}, + }, + Catalog: []bucketindex.Claim{outer, innerA, innerB}, + } + + got, ok := peer.Satisfying(want) + require.True(t, ok, "the merged inner groups jointly cover {10,11}, which the outer claim says holds {1}") + assert.Contains(t, []string{"ma", "mb"}, got.Prefix, "answered by a part descending from the split") + + got, ok = peer.Discharging(want) + require.True(t, ok) + assert.Contains(t, []string{"ma", "mb"}, got.Prefix) +} + +// TestTrimHolesRevokedByCompleteGroup is a hole for {1,2} whose rows came back only as a split: no +// one part holds them, but the complete group does, which is what [bucketindex.Index.Satisfying] +// already answers the hole's want with. So the hole is revoked, through an entry-carried group and +// through one recorded only in the catalog, and not by an incomplete one. +func TestTrimHolesRevokedByCompleteGroup(t *testing.T) { + t.Parallel() + + outer := bucketindex.Claim{Blocks: bucketindex.Blocks(1, 2), Group: bucketindex.Blocks(10, 11)} + innerA := bucketindex.Claim{Blocks: bucketindex.Blocks(10), Group: bucketindex.Blocks(20, 21)} + innerB := bucketindex.Claim{Blocks: bucketindex.Blocks(11), Group: bucketindex.Blocks(22, 23)} + hole := bucketindex.Entry{Prefix: "lost", Blocks: bucketindex.Blocks(1, 2), Level: 1, Hole: true} + + member := func(b uint64, c bucketindex.Claim, level uint32) bucketindex.Entry { + return bucketindex.Entry{Prefix: fmt.Sprintf("m%d", b), Blocks: bucketindex.Blocks(b), Claim: c, Level: level} + } + + members := []bucketindex.Entry{member(10, outer, 2), member(11, outer, 2)} + fragments := []bucketindex.Entry{member(20, innerA, 3), member(21, innerA, 3), member(22, innerB, 3), member(23, innerB, 3)} + + assert.Empty(t, bucketindex.TrimHoles([]bucketindex.Entry{hole}, members)) + assert.Len(t, bucketindex.TrimHoles([]bucketindex.Entry{hole}, members[:1]), 1) + assert.Empty(t, bucketindex.TrimHoles([]bucketindex.Entry{hole}, fragments, outer)) + assert.Len(t, bucketindex.TrimHoles([]bucketindex.Entry{hole}, fragments), 1, + "without the outer claim nothing relates the fragments to {1,2}") +} + +// TestTrimCatalogKeepsLiveEntryAncestry is the saturation case for a live nested member: it carries +// only the inner claim, and the outer one lives only in the catalog, with more than MaxLineage newer +// groups after it. The outer claim is kept, since the live member's ancestry passes through it, so +// once the member is lost its want is still answered by a successor of the outer ancestry. +func TestTrimCatalogKeepsLiveEntryAncestry(t *testing.T) { + t.Parallel() + + member := innerWant.Entry() + + ix := &bucketindex.Index{Catalog: bucketindex.MergeLineage([]bucketindex.Claim{outerClaim})} + ix.Add(member) + ix.Catalog = bucketindex.MergeLineage(ix.Catalog, newerGroups(bucketindex.MaxLineage+10)) + + assert.Zero(t, ix.RecordLineage()) + require.Len(t, ix.Catalog, bucketindex.MaxLineage) + assert.True(t, ix.Catalog[0].Equal(outerClaim), "the live member still reaches the oldest group") + + require.True(t, ix.Remove(member.Prefix)) + ix.RecordWant(bucketindex.WantOf(member, bucketindex.Generation{})) + + peer := &bucketindex.Index{Entries: []bucketindex.Entry{successor}} + _, ok := peer.SatisfyingWith(bucketindex.WantOf(member, bucketindex.Generation{}), ix.Lineage()) + assert.True(t, ok, "so a successor of the outer ancestry answers the lost member") +} diff --git a/backend/bucketindex/format_test.go b/backend/bucketindex/format_test.go index 979e1f8..5b4544a 100644 --- a/backend/bucketindex/format_test.go +++ b/backend/bucketindex/format_test.go @@ -22,6 +22,7 @@ func fullIndex() *bucketindex.Index { Blocks: bucketindex.Range(1, 6, 7), Group: bucketindex.Range(2, 1, 4), }, + Rollup: &bucketindex.Rollup{Tiers: []bucketindex.RollupTier{{Before: 5, Interval: 3, Agg: 2}}}, }, }, FlushedEpoch: 3, @@ -43,23 +44,27 @@ func fullIndex() *bucketindex.Index { }, LostParts: 15, AllocatedBlocks: bucketindex.Block{Term: 2, N: 16}, + Catalog: []bucketindex.Claim{ + {Blocks: bucketindex.Range(1, 6, 7), Group: bucketindex.Range(2, 1, 4)}, + }, } } -// TestGoldenV7 pins the v7 byte layout: an accidental reordering or a dropped field breaks here +// TestGoldenV8 pins the v8 byte layout: an accidental reordering or a dropped field breaks here // before it breaks a deployment. -func TestGoldenV7(t *testing.T) { +func TestGoldenV8(t *testing.T) { t.Parallel() want := []byte{ - 'B', 'I', 7, + 'B', 'I', 8, // one entry: prefix, zigzag times, 1, 1, 'a', 2, 4, // blocks {1:1, 1:4, 2:1}: lowest number 1, its term 1, top term +1, top number 1, and two // gaps, 1:2..1:3 and 1:5..2:0 — the rest of term 1's number space, 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 5, 2, 0, - // level 2, flags, a claim on term 1's [6,7] held by term 2's group [1,4], the writer's term, - 2, 0, 1, 6, 1, 0, 7, 0, 1, 2, 0, 4, 0, 3, + // level 2, flags (a layout follows), a claim on term 1's [6,7] held by term 2's group [1,4], + // the writer's term, and a layout of one tier: zigzag before 5, zigzag interval 3, agg 2, + 2, 2, 1, 6, 1, 0, 7, 0, 1, 2, 0, 4, 0, 3, 1, 10, 6, 2, 3, // flushed epoch 4, 5, // generation 1, 1, 'r', 6, 7, // one removal @@ -68,11 +73,12 @@ func TestGoldenV7(t *testing.T) { 1, 1, 'x', 5, 2, 0, 5, 0, 1, 26, 28, 11, 12, 0, 2, 15, // lost parts 2, 16, // allocated blocks: term, number + 1, 6, 1, 0, 7, 0, 1, 2, 0, 4, 0, // the catalog: one claim, its blocks then its group } assert.Equal(t, want, fullIndex().AppendBinary(nil)) } -func TestV7RoundTrip(t *testing.T) { +func TestV8RoundTrip(t *testing.T) { t.Parallel() in := fullIndex() @@ -81,6 +87,35 @@ func TestV7RoundTrip(t *testing.T) { assert.Equal(t, in, out) } +// TestDecodeV7Golden pins the migration from v7: no entry's layout is known and the catalog is +// empty, until the first v8 commit fills both in. +func TestDecodeV7Golden(t *testing.T) { + t.Parallel() + + got, err := bucketindex.Decode([]byte{ + 'B', 'I', 7, + 1, 1, 'a', 2, 4, + 1, 1, 1, 1, 2, 1, 2, 1, 3, 1, 5, 2, 0, + 2, 0, 1, 6, 1, 0, 7, 0, 1, 2, 0, 4, 0, 3, + 3, + 4, 5, + 1, 1, 'r', 6, 7, + 1, 1, 'w', 8, 9, 10, + 1, 1, 'x', 5, 2, 0, 5, 0, 1, 26, 28, 11, 12, 0, 2, + 15, + 2, 16, + }) + require.NoError(t, err) + + want := fullIndex() + want.Entries[0].Rollup = nil + want.Catalog = nil + assert.Equal(t, want, got) + + got.RecordLineage() + assert.Equal(t, fullIndex().Catalog, got.Catalog, "the first v8 commit records the claims the entries carry") +} + // TestDecodeV6Golden pins the migration from v6: every block is term 0, and so are the writers' // terms and the high-water mark. func TestDecodeV6Golden(t *testing.T) { @@ -102,6 +137,7 @@ func TestDecodeV6Golden(t *testing.T) { require.NoError(t, err) want := fullIndex() + want.Entries[0].Rollup, want.Catalog = nil, nil want.Entries[0].Term, want.Wanted[0].Term = 0, 0 want.Entries[0].Blocks = bucketindex.Blocks(1, 4) want.Entries[0].Claim = bucketindex.Claim{Blocks: bucketindex.Range(0, 6, 7), Group: bucketindex.Range(0, 1, 4)} @@ -178,6 +214,7 @@ func TestDecodeAllVersions(t *testing.T) { 5: {'B', 'I', 5, 1, 1, 'a', 2, 4, 0, 0, 0, 0, 3, 4, 5, 0, 0, 0, 0}, 6: {'B', 'I', 6, 1, 1, 'a', 2, 4, 0, 0, 0, 0, 3, 4, 5, 0, 0, 0, 0, 0}, 7: {'B', 'I', 7, 1, 1, 'a', 2, 4, 0, 0, 0, 0, 0, 3, 4, 5, 0, 0, 0, 0, 0, 0}, + 8: {'B', 'I', 8, 1, 1, 'a', 2, 4, 0, 0, 0, 0, 0, 3, 4, 5, 0, 0, 0, 0, 0, 0, 0}, } for ver, data := range cases { t.Run(fmt.Sprintf("v%d", ver), func(t *testing.T) { @@ -192,7 +229,7 @@ func TestDecodeAllVersions(t *testing.T) { }) } - _, err := bucketindex.Decode([]byte{'B', 'I', 8, 0}) + _, err := bucketindex.Decode([]byte{'B', 'I', 9, 0}) require.ErrorIs(t, err, bucketindex.ErrCorrupt, "a version this reader does not know is rejected") } @@ -221,6 +258,53 @@ func TestDecodeRejectsCorruptV6(t *testing.T) { } } +// TestDecodeRejectsCorruptV8 covers the fields v8 added: the entry's layout behind its flag bit, and +// the lineage catalog, which has to be in its one canonical order. +func TestDecodeRejectsCorruptV8(t *testing.T) { + t.Parallel() + + head := []byte{'B', 'I', 8, 1, 1, 'a', 2, 4, 0, 0} + tail := []byte{3, 4, 5, 0, 0, 0, 0, 0, 0, 0} + entry := func(flags byte, rest ...byte) []byte { + out := append(append([]byte(nil), head...), flags, 0, 0) + + return append(append(out, rest...), tail...) + } + claim := func(lo, group byte) []byte { return []byte{lo, 0, 0, lo, 0, group, 0, 0, group, 0} } + + cases := map[string][]byte{ + "unknown flag": entry(4), + "missing layout": {'B', 'I', 8, 1, 1, 'a', 2, 4, 0, 0, 2, 0, 0}, + "layout count huge": entry(2, 200), + "missing tier": {'B', 'I', 8, 1, 1, 'a', 2, 4, 0, 0, 2, 0, 0, 1, 2}, + "agg overflows": entry(2, 1, 2, 2, 0x80, 0x02), + "missing catalog": {'B', 'I', 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}, + "catalog count huge": {'B', 'I', 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 200}, + "catalog claim unset": append([]byte{'B', 'I', 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1}, 0, 0), + "catalog unsorted": append(append([]byte{'B', 'I', 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, claim(1, 9)...), + claim(1, 5)...), + "catalog duplicate": append(append([]byte{'B', 'I', 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, claim(1, 5)...), + claim(1, 5)...), + } + for name, data := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + _, err := bucketindex.Decode(data) + require.ErrorIs(t, err, bucketindex.ErrCorrupt) + }) + } + + ok := append([]byte{'B', 'I', 8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, claim(1, 5)...) + ok = append(ok, claim(1, 9)...) + ix, err := bucketindex.Decode(ok) + require.NoError(t, err, "the sorted form of the same catalog decodes") + assert.Len(t, ix.Catalog, 2) + + layout, err := bucketindex.Decode(entry(2, 0)) + require.NoError(t, err) + assert.Equal(t, &bucketindex.Rollup{}, layout.Entries[0].Rollup, "a known raw layout is not unknown") +} + // TestDecodeRejectsCorruptV7 covers the fields v7 added: the block terms, the writers' terms and // the high-water mark's term. func TestDecodeRejectsCorruptV7(t *testing.T) { @@ -302,9 +386,16 @@ func randomIndex(rnd *rand.Rand) *bucketindex.Index { Claim: randomClaim(rnd), Level: uint32(rnd.IntN(4)), Term: rnd.Uint64N(5), + Rollup: randomRollup(rnd), }) } + for range rnd.IntN(4) { + if c := randomClaim(rnd); c.Valid() { + ix.Catalog = bucketindex.MergeLineage(ix.Catalog, []bucketindex.Claim{c}) + } + } + for i := range rnd.IntN(4) { ix.Tombstone(bucketindex.Removal{ Prefix: fmt.Sprintf("dead-%02d", i), @@ -330,6 +421,25 @@ func randomIndex(rnd *rand.Rand) *bucketindex.Index { return ix } +// randomRollup covers the three states a layout has: unknown, raw, and tiered. +func randomRollup(rnd *rand.Rand) *bucketindex.Rollup { + switch rnd.IntN(3) { + case 0: + return nil + case 1: + return &bucketindex.Rollup{} + default: + r := &bucketindex.Rollup{} + for range rnd.IntN(3) + 1 { + r.Tiers = append(r.Tiers, bucketindex.RollupTier{ + Before: rnd.Int64() - math.MaxInt32, Interval: rnd.Int64N(1 << 40), Agg: uint8(rnd.UintN(256)), + }) + } + + return r + } +} + func randomClaim(rnd *rand.Rand) bucketindex.Claim { if rnd.IntN(3) != 0 { return bucketindex.Claim{} @@ -402,12 +512,33 @@ func FuzzRoundTrip(f *testing.F) { wanted = bucketindex.Interval{} } + // The same bytes as a layout, and as the claims of a catalog: ordered and deduplicated as + // Decode requires, the canonical form a writer produces. + var rollup *bucketindex.Rollup + if level%3 != 0 { + rollup = &bucketindex.Rollup{} + for i := 0; i+1 < len(blocks) && level%3 == 2; i += 2 { + rollup.Tiers = append(rollup.Tiers, bucketindex.RollupTier{ + Before: int64(blocks[i]) - 128, Interval: int64(blocks[i+1]), Agg: blocks[i] ^ blocks[i+1], + }) + } + } + + var catalog []bucketindex.Claim + for _, b := range blocks { + catalog = bucketindex.MergeLineage(catalog, []bucketindex.Claim{{ + Blocks: bucketindex.TermBlocks(term, uint64(b)+1), + Group: bucketindex.TermBlocks(term+1, uint64(b)+1), + }}) + } + in := &bucketindex.Index{ Entries: []bucketindex.Entry{ - {Prefix: "p", MinTime: -1, MaxTime: 1, Blocks: own, Level: level, Term: term}, + {Prefix: "p", MinTime: -1, MaxTime: 1, Blocks: own, Level: level, Term: term, Rollup: rollup}, }, Wanted: []bucketindex.Want{{Prefix: "w", Blocks: wanted, Term: allocated}}, AllocatedBlocks: bucketindex.Block{Term: term, N: allocated}, + Catalog: catalog, } out, err := bucketindex.Decode(in.AppendBinary(nil)) diff --git a/backend/bucketindex/hole.go b/backend/bucketindex/hole.go index 4650982..e9612d2 100644 --- a/backend/bucketindex/hole.go +++ b/backend/bucketindex/hole.go @@ -1,5 +1,7 @@ package bucketindex +import "slices" + // WantOutcome says what an attempt to repair a [Want] concluded when it brought no part back. The // distinction is the whole of the safety argument for acknowledging a loss: "no owner has the // data" is evidence, and "I did not manage to ask every owner" never is. @@ -59,18 +61,27 @@ func (ix *Index) Holes() []Entry { // ClickHouse, which checks non-existence on every replica in one transaction, an owner here can // acknowledge a loss while a peer still holds the data. So a hole must never block the real part: // it is replaced by it. -func Revokes(e, h Entry) bool { +func Revokes(e, h Entry) bool { return revokes(e, h, Lineage{h.Claim}.Relater()) } + +func revokes(e, h Entry, r *Relater) bool { if !e.Data() || !h.Hole { return false } - return e.Prefix == h.Prefix || e.Supersedes(h) + return e.Prefix == h.Prefix || r.Subsumes(e, h) } -// TrimHoles drops the holes that live revokes, returning what remains. It runs on every commit, so -// any path that brings the data back — a repair fetch, a peer's entry adopted under CAS, a merge — -// revokes the hole as a side effect of committing the part. -func TrimHoles(holes, live []Entry) []Entry { +// TrimHoles drops the holes that live revokes, related by the claims live carries and the index's +// catalog, returning what remains. It runs on every commit, so any path that brings the data back — +// a repair fetch, a peer's entry adopted under CAS, a merge — revokes the hole as a side effect of +// committing the part. A hole whose rows came back only as a split is revoked once the group is +// complete, as [Index.Satisfying] answers the hole's want. +func TrimHoles(holes, live []Entry, catalog ...Claim) []Entry { + if len(holes) == 0 { + return holes + } + + rel := (&Index{Entries: slices.Concat(live, holes), Catalog: catalog}).Relations() out := holes[:0] for j := range holes { @@ -78,13 +89,17 @@ func TrimHoles(holes, live []Entry) []Entry { revoked := false for i := range live { - if Revokes(live[i], *h) { + if revokes(live[i], *h, rel.r) { revoked = true break } } + if !revoked && h.Hole { + _, revoked = rel.jointlySatisfying(WantOf(*h, Generation{}), Entry.Data, 0) + } + if !revoked { out = append(out, *h) } diff --git a/backend/bucketindex/lineage.go b/backend/bucketindex/lineage.go index b5dca08..713f73c 100644 --- a/backend/bucketindex/lineage.go +++ b/backend/bucketindex/lineage.go @@ -25,13 +25,8 @@ func LineageOf(entries []Entry) Lineage { // // Two parts with one identity written by different tenures — two owners that merged the same inputs // either side of a handoff — hold the same rows, and the later tenure's copy subsumes the earlier. -func (l Lineage) Subsumes(e, o Entry) bool { - if e.Term > o.Term && e.sameIdentity(o) { - return true - } - - return e.Level > o.Level && l.holds(e.Blocks).Contains(o.Blocks) -} +// A caller relating many pairs over one lineage uses a [Relater] instead. +func (l Lineage) Subsumes(e, o Entry) bool { return l.Relater().Subsumes(e, o) } func (e Entry) sameIdentity(o Entry) bool { return e.Level == o.Level && e.Blocks.Valid() && e.Blocks.Equal(o.Blocks) && e.Claim.Equal(o.Claim) @@ -43,66 +38,8 @@ func (e Entry) sameIdentity(o Entry) bool { // with itself. A split partitions the rows it consumed, so two parts descending from one ancestor // through different members of the same group are disjoint; through different groups — two lineages // that split the same ancestor apart differently — nothing says how the rows fell, and they may -// overlap. -func (l Lineage) Overlaps(a, b Entry) bool { - upA, viaA := l.ancestry(a.Blocks) - upB, viaB := l.ancestry(b.Blocks) - - if meets(a.Blocks, upB) || meets(b.Blocks, upA) { - return true - } - - common := intersect(upA, upB) - if !common.Valid() { - return false - } - - for i, c := range l { - if viaA[i] != viaB[i] && meets(c.Blocks, common) { - return true - } - } - - return false -} - -// holds is every block whose rows iv includes: iv, and the members of each group whose claimed -// ancestry it covers, repeated so a group split again resolves through the outer one. -func (l Lineage) holds(iv Interval) Interval { - used := make([]bool, len(l)) - - for changed := true; changed; { - changed = false - - for i, c := range l { - if !used[i] && iv.Contains(c.Blocks) { - used[i], changed = true, true - iv = iv.Union(c.Group) - } - } - } - - return iv -} - -// ancestry is iv with every block its rows derive from, and which groups that descent passes -// through. -func (l Lineage) ancestry(iv Interval) (Interval, []bool) { - via := make([]bool, len(l)) - - for changed := true; changed; { - changed = false - - for i, c := range l { - if !via[i] && meets(iv, c.Group) { - via[i], changed = true, true - iv = iv.Union(c.Blocks) - } - } - } - - return iv, via -} +// overlap. A caller relating many pairs over one lineage uses a [Relater] instead. +func (l Lineage) Overlaps(a, b Entry) bool { return l.Relater().Overlaps(a, b) } func meets(a, b Interval) bool { return intersect(a, b).Valid() } diff --git a/backend/bucketindex/relater.go b/backend/bucketindex/relater.go new file mode 100644 index 0000000..477c8cb --- /dev/null +++ b/backend/bucketindex/relater.go @@ -0,0 +1,325 @@ +package bucketindex + +import ( + "cmp" + "slices" +) + +// Relater answers identity questions over one lineage, computing each closure by a work queue over +// an index of the claims' block runs and remembering it, so a batch — every outstanding want of a +// commit against every live entry, every candidate a repair judges — pays for each closure once. +// +// A closure is followed only along the claims that can extend it: the ones whose runs meet a run the +// closure just gained. The naive fixed point rescans every claim until nothing changes, which a deeply +// nested lineage ordered against the scan turns into one pass per level, per candidate, per want. +type Relater struct { + l Lineage + blocks runIndex + groups runIndex + + holdsOf map[string][]closure + upOf map[string][]closure + realized map[string][]closure + key []byte +} + +type closure struct { + iv Interval + out Interval + via []bool +} + +// Relater returns a relater over l. +func (l Lineage) Relater() *Relater { + r := &Relater{ + l: l, + holdsOf: make(map[string][]closure), + upOf: make(map[string][]closure), + realized: make(map[string][]closure), + } + + for i, c := range l { + r.blocks.add(c.Blocks, i) + r.groups.add(c.Group, i) + } + + r.blocks.build() + r.groups.build() + + return r +} + +// Lineage is the lineage r relates by. +func (r *Relater) Lineage() Lineage { return r.l } + +// Subsumes is [Lineage.Subsumes] over r's lineage. +func (r *Relater) Subsumes(e, o Entry) bool { + if e.Term > o.Term && e.sameIdentity(o) { + return true + } + + return e.Level > o.Level && r.holds(e.Blocks).Contains(o.Blocks) +} + +// Overlaps is [Lineage.Overlaps] over r's lineage. +func (r *Relater) Overlaps(a, b Entry) bool { + upA, viaA := r.ancestry(a.Blocks) + upB, viaB := r.ancestry(b.Blocks) + + if meets(a.Blocks, upB) || meets(b.Blocks, upA) { + return true + } + + common := intersect(upA, upB) + if !common.Valid() { + return false + } + + for i, c := range r.l { + if viaA[i] != viaB[i] && meets(c.Blocks, common) { + return true + } + } + + return false +} + +// Ancestors returns the claims iv's rows derive through: each group iv's blocks belong to, and +// again each group the members of that one's ancestry belong to. +func (r *Relater) Ancestors(iv Interval) []Claim { + _, via := r.ancestry(iv) + + var out []Claim + + for i, ok := range via { + if ok { + out = append(out, r.l[i]) + } + } + + return out +} + +// holds is every block whose rows iv includes: iv, and the members of each group whose claimed +// ancestry it covers, repeated so a group split again resolves through the outer one. +func (r *Relater) holds(iv Interval) Interval { + if len(r.l) == 0 { + return iv + } + + if c, ok := r.lookup(r.holdsOf, iv); ok { + return c.out + } + + out, _ := r.close(iv, &r.blocks, func(c Claim, held Interval) (bool, Interval) { + return held.Contains(c.Blocks), c.Group + }) + + r.remember(r.holdsOf, iv, out, nil) + + return out +} + +// ancestry is iv with every block its rows derive from, and which groups that descent passes through. +func (r *Relater) ancestry(iv Interval) (Interval, []bool) { + if len(r.l) == 0 { + return iv, []bool{} + } + + if c, ok := r.lookup(r.upOf, iv); ok { + return c.out, c.via + } + + out, via := r.close(iv, &r.groups, func(c Claim, _ Interval) (bool, Interval) { return true, c.Blocks }) + + r.remember(r.upOf, iv, out, via) + + return out, via +} + +// reachAncestry marks in reached the groups iv's ancestry passes through, as [Relater.ancestry] +// does, sharing reached across calls: a group some earlier call reached is not followed again, so +// marking the ancestry of a whole part set costs one visit per group rather than one per part. +func (r *Relater) reachAncestry(reached []bool, iv Interval) { + if !iv.Valid() { + return + } + + var queue []int + + push := func(i int) { + if !reached[i] { + reached[i] = true + queue = append(queue, i) + } + } + + for k := range iv.nruns() { + r.groups.meeting(iv.run(k), push) + } + + for len(queue) > 0 { + c := r.l[queue[len(queue)-1]] + queue = queue[:len(queue)-1] + + for k := range c.Blocks.nruns() { + r.groups.meeting(c.Blocks.run(k), push) + } + } +} + +// descendants is iv with every block derived from its rows, and which groups that derivation passes +// through: each group whose claimed ancestry iv meets, and again from that group's members. +func (r *Relater) descendants(iv Interval) (Interval, []bool) { + return r.close(iv, &r.blocks, func(c Claim, _ Interval) (bool, Interval) { return true, c.Group }) +} + +// realize is held with the ancestry of every group whose members it holds, repeated so a group whose +// members were split again resolves once the inner groups have, and which groups that completes. +func (r *Relater) realize(held Interval) (Interval, []bool) { + if len(r.l) == 0 { + return held, []bool{} + } + + if c, ok := r.lookup(r.realized, held); ok { + return c.out, c.via + } + + out, via := r.close(held, &r.groups, func(c Claim, held Interval) (bool, Interval) { + return held.Contains(c.Group), c.Blocks + }) + + r.remember(r.realized, held, out, via) + + return out, via +} + +// level is the lowest level among the data-bearing entries derived from group: the level its +// completion stands at, whichever members or descendants of them complete it. +func (r *Relater) level(group Interval, entries []Entry) (uint32, bool) { + d, _ := r.descendants(group) + + var ( + level uint32 + found bool + ) + + for i := range entries { + e := &entries[i] + if e.Data() && e.Blocks.Valid() && d.Contains(e.Blocks) && (!found || e.Level < level) { + level, found = e.Level, true + } + } + + return level, found +} + +// close grows iv to a fixed point: each claim whose indexed runs meet a run iv gains is offered to +// take, which says whether the claim applies to the closure so far and what it adds. A claim that +// does not apply yet is offered again whenever iv gains another run it meets, so none is missed, and +// one that applies is used once. +func (r *Relater) close( + iv Interval, index *runIndex, take func(c Claim, iv Interval) (bool, Interval), +) (Interval, []bool) { + via := make([]bool, len(r.l)) + if !iv.Valid() { + return iv, via + } + + queue := iv.appendRuns(nil) + + for len(queue) > 0 { + run := queue[len(queue)-1] + queue = queue[:len(queue)-1] + + index.meeting(run, func(i int) { + if via[i] { + return + } + + ok, add := take(r.l[i], iv) + if !ok { + return + } + + via[i] = true + + if grown := iv.Union(add); !grown.Equal(iv) { + iv = grown + queue = add.appendRuns(queue) + } + }) + } + + return iv, via +} + +// runIndex finds the claims one of whose runs meets a given run: the runs sorted by their low bound, +// with the running maximum of their high bounds, so a search skips every run ending before the query. +type runIndex struct { + runs []indexedRun + reach []Block +} + +type indexedRun struct { + Gap + + claim int +} + +func (x *runIndex) add(iv Interval, claim int) { + if !iv.Valid() { + return + } + + for i := range iv.nruns() { + x.runs = append(x.runs, indexedRun{Gap: iv.run(i), claim: claim}) + } +} + +func (x *runIndex) build() { + slices.SortFunc(x.runs, func(a, b indexedRun) int { + return cmp.Or(a.Min.Compare(b.Min), a.Max.Compare(b.Max), cmp.Compare(a.claim, b.claim)) + }) + + x.reach = make([]Block, len(x.runs)) + for i := range x.runs { + x.reach[i] = x.runs[i].Max + if i > 0 { + x.reach[i] = MaxBlock(x.reach[i], x.reach[i-1]) + } + } +} + +// meeting calls fn with the claim of every indexed run that meets q. +func (x *runIndex) meeting(q Gap, fn func(claim int)) { + // reach is non-decreasing, so the first run that can reach q is found by search; runs starting + // after q ends cannot meet it. + i, _ := slices.BinarySearchFunc(x.reach, q.Min, Block.Compare) + + for ; i < len(x.runs) && !q.Max.less(x.runs[i].Min); i++ { + if !x.runs[i].Max.less(q.Min) { + fn(x.runs[i].claim) + } + } +} + +// lookup finds iv's remembered closure by its encoding, which for a valid interval is unique; the +// lookup itself allocates nothing. +func (r *Relater) lookup(cache map[string][]closure, iv Interval) (closure, bool) { + r.key = appendInterval(r.key[:0], iv) + + cs := cache[string(r.key)] + for i := range cs { + if cs[i].iv.Equal(iv) { + return cs[i], true + } + } + + return closure{}, false +} + +func (r *Relater) remember(cache map[string][]closure, iv, out Interval, via []bool) { + k := string(appendInterval(r.key[:0], iv)) + cache[k] = append(cache[k], closure{iv: iv, out: out, via: via}) +} diff --git a/backend/bucketindex/relater_internal_test.go b/backend/bucketindex/relater_internal_test.go new file mode 100644 index 0000000..51ec344 --- /dev/null +++ b/backend/bucketindex/relater_internal_test.go @@ -0,0 +1,154 @@ +package bucketindex + +import ( + "math/rand/v2" + "testing" + + "github.com/stretchr/testify/require" +) + +// The fixed points below rescan every claim until nothing changes: the definitions the [Relater]'s +// work queues must agree with. + +func naiveHolds(l Lineage, iv Interval) Interval { + used := make([]bool, len(l)) + + for changed := true; changed; { + changed = false + + for i, c := range l { + if !used[i] && iv.Contains(c.Blocks) { + used[i], changed = true, true + iv = iv.Union(c.Group) + } + } + } + + return iv +} + +func naiveClose(l Lineage, iv Interval, meet func(c Claim) Interval, add func(c Claim) Interval) (Interval, []bool) { + via := make([]bool, len(l)) + + for changed := true; changed; { + changed = false + + for i, c := range l { + if !via[i] && meets(iv, meet(c)) { + via[i], changed = true, true + iv = iv.Union(add(c)) + } + } + } + + return iv, via +} + +func naiveRealize(l Lineage, held Interval) Interval { + claims := append(Lineage(nil), l...) + + for len(claims) > 0 { + rest := claims[:0] + + for _, c := range claims { + if held.Contains(c.Group) { + held = held.Union(c.Blocks) + + continue + } + + rest = append(rest, c) + } + + if len(rest) == len(claims) { + break + } + + claims = rest + } + + return held +} + +// randomLineage draws nested groups over a small block space in a random order, so closures need +// several levels and the scan order is sometimes against them. +func randomLineage(rnd *rand.Rand) Lineage { + var l Lineage + + for range rnd.IntN(12) + 1 { + var blocks, group Interval + + for range rnd.IntN(3) + 1 { + blocks = blocks.Union(TermBlocks(rnd.Uint64N(2), rnd.Uint64N(30)+1)) + } + + for range rnd.IntN(3) + 1 { + group = group.Union(TermBlocks(rnd.Uint64N(2), rnd.Uint64N(30)+1)) + } + + if c := (Claim{Blocks: blocks, Group: group}); c.Valid() { + l = append(l, c) + } + } + + rnd.Shuffle(len(l), func(i, j int) { l[i], l[j] = l[j], l[i] }) + + return l +} + +func randomSet(rnd *rand.Rand) Interval { + var iv Interval + + for range rnd.IntN(4) + 1 { + iv = iv.Union(TermBlocks(rnd.Uint64N(2), rnd.Uint64N(30)+1)) + } + + return iv +} + +func TestRelaterMatchesTheFixedPoints(t *testing.T) { + t.Parallel() + + rnd := rand.New(rand.NewPCG(71, 73)) + + for range 3000 { + l := randomLineage(rnd) + r := l.Relater() + iv := randomSet(rnd) + + require.Equal(t, naiveHolds(l, iv), r.holds(iv), "holds %v over %v", iv, l) + require.Equal(t, naiveHolds(l, iv), r.holds(iv), "a remembered closure is the same closure") + + up, via := naiveClose(l, iv, func(c Claim) Interval { return c.Group }, func(c Claim) Interval { return c.Blocks }) + gotUp, gotVia := r.ancestry(iv) + require.Equal(t, up, gotUp, "ancestry %v over %v", iv, l) + require.Equal(t, via, gotVia) + + other := randomSet(rnd) + _, otherVia := naiveClose(l, other, func(c Claim) Interval { return c.Group }, func(c Claim) Interval { return c.Blocks }) + reached := make([]bool, len(l)) + r.reachAncestry(reached, iv) + require.Equal(t, via, reached, "the groups %v's ancestry passes through over %v", iv, l) + r.reachAncestry(reached, other) + + for i := range via { + require.Equal(t, via[i] || otherVia[i], reached[i], "reached accumulates across calls") + } + + down, dvia := naiveClose(l, iv, func(c Claim) Interval { return c.Blocks }, func(c Claim) Interval { return c.Group }) + gotDown, gotDvia := r.descendants(iv) + require.Equal(t, down, gotDown, "descendants %v over %v", iv, l) + require.Equal(t, dvia, gotDvia) + + held := naiveRealize(l, iv) + complete := make([]bool, len(l)) + + for i, c := range l { + complete[i] = held.Contains(c.Group) + } + + gotHeld, gotComplete := r.realize(iv) + require.Equal(t, held, gotHeld, "realize %v over %v", iv, l) + require.Equal(t, complete, gotComplete, "the groups %v completes over %v", iv, l) + } +} diff --git a/backend/bucketindex/relations.go b/backend/bucketindex/relations.go new file mode 100644 index 0000000..1968d4c --- /dev/null +++ b/backend/bucketindex/relations.go @@ -0,0 +1,197 @@ +package bucketindex + +import "slices" + +// Relations answers the identity questions of one index — which part satisfies or discharges a want, +// what the index covers, what a want still misses — relating identities by the index's lineage and +// extra. Its closures are computed once and shared by every question, so a batch of wants against +// the live set costs one closure per part rather than one per part per want. +type Relations struct { + ix *Index + r *Relater + known map[claimKey]struct{} + covers [2]*Interval +} + +// Relations returns the relations of ix, with extra lineage — the catalog of the index that owes the +// wants asked about. +func (ix *Index) Relations(extra ...Claim) *Relations { + l := ix.Lineage().With(extra...) + + rel := &Relations{ix: ix, r: l.Relater(), known: make(map[claimKey]struct{}, len(l))} + for _, c := range l { + rel.known[c.key()] = struct{}{} + } + + return rel +} + +// Covered is [Index.Covered] over rel's lineage. +func (rel *Relations) Covered() Interval { return rel.covered(Entry.Data, 0) } + +// Supersedes reports whether a data-bearing entry of the index subsumes o. +func (rel *Relations) Supersedes(o Entry) bool { + r := rel.forClaim(o.Claim).r + + for i := range rel.ix.Entries { + if e := &rel.ix.Entries[i]; e.Data() && r.Subsumes(*e, o) { + return true + } + } + + return false +} + +// Satisfying is [Index.Satisfying] over rel's lineage. +func (rel *Relations) Satisfying(w Want) (Entry, bool) { + return rel.forClaim(w.Claim).satisfying(w, Entry.Data, 0) +} + +// Discharging is [Index.Discharging] over rel's lineage. +func (rel *Relations) Discharging(w Want) (Entry, bool) { + return rel.forClaim(w.Claim).satisfying(w, func(Entry) bool { return true }, 1) +} + +// Missing is [Index.Missing] over rel's lineage. +func (rel *Relations) Missing(w Want) []Block { + if !w.Blocks.Valid() { + return nil + } + + rel = rel.forClaim(w.Claim) + if _, ok := rel.satisfying(w, Entry.Data, 0); ok { + return nil + } + + held := rel.Covered() + _, via := rel.r.descendants(w.Blocks) + + var consumed Interval + + for i, c := range rel.r.l { + if via[i] { + consumed = consumed.Union(c.Blocks) + } + } + + var out []Block + + for i, c := range rel.r.l { + if !via[i] { + continue + } + + c.Group.Each(func(b Block) bool { + one := Single(b) + if !held.Contains(one) && !consumed.Contains(one) && !slices.Contains(out, b) { + out = append(out, b) + } + + return true + }) + } + + slices.SortFunc(out, Block.Compare) + + return out +} + +// forClaim is rel, or relations that also know c when rel does not. +func (rel *Relations) forClaim(c Claim) *Relations { + if !c.Valid() { + return rel + } + + if _, ok := rel.known[c.key()]; ok { + return rel + } + + return rel.ix.Relations(slices.Concat(rel.r.l, []Claim{c})...) +} + +func (rel *Relations) covered(admit func(Entry) bool, slot int) Interval { + if c := rel.covers[slot]; c != nil { + return *c + } + + runs := make([]Gap, 0, len(rel.ix.Entries)) + + for i := range rel.ix.Entries { + e := &rel.ix.Entries[i] + if admit(*e) && e.Blocks.Valid() { + runs = e.Blocks.appendRuns(runs) + } + } + + held, _ := rel.r.realize(fromRuns(runs)) + rel.covers[slot] = &held + + return held +} + +func (rel *Relations) satisfying(w Want, admit func(Entry) bool, slot int) (Entry, bool) { + var ( + best Entry + found bool + ) + + ix := rel.ix + owed := w.Entry() + + for i := range ix.Entries { + e := ix.Entries[i] + if !admit(e) { + continue + } + + if e.Prefix == w.Prefix { + return e, true + } + + if !e.Blocks.Contains(w.Blocks) && !rel.r.Subsumes(e, owed) { + continue + } + + if !found || betterSuccessor(e, best) { + best, found = e, true + } + } + + if found { + return best, true + } + + return rel.jointlySatisfying(w, admit, slot) +} + +// jointlySatisfying answers a want no single part contains: it holds only where the whole index +// covers w's blocks, and then names the best part descending from a split that supplies some of +// them — one whose ancestry, followed through every group lineage records, reaches w's blocks. The +// part need not carry the claim: a merge that consumed a whole inner group folded it into its blocks. +func (rel *Relations) jointlySatisfying(w Want, admit func(Entry) bool, slot int) (Entry, bool) { + if !w.Blocks.Valid() || !rel.covered(admit, slot).Contains(w.Blocks) { + return Entry{}, false + } + + var ( + best Entry + found bool + ) + + for i := range rel.ix.Entries { + e := rel.ix.Entries[i] + if !admit(e) || !e.Blocks.Valid() { + continue + } + + if up, via := rel.forClaim(e.Claim).r.ancestry(e.Blocks); !slices.Contains(via, true) || !meets(up, w.Blocks) { + continue + } + + if !found || betterSuccessor(e, best) { + best, found = e, true + } + } + + return best, found +} diff --git a/backend/bucketindex/rollup.go b/backend/bucketindex/rollup.go new file mode 100644 index 0000000..41a7162 --- /dev/null +++ b/backend/bucketindex/rollup.go @@ -0,0 +1,72 @@ +package bucketindex + +import ( + "encoding/binary" + "math" +) + +// Rollup is the downsampling layout a part's rows have had applied: per tier, rows older than Before +// reduced to one per Interval-wide bucket by Agg, an aggregation id this package does not interpret. +// No tiers is a raw part. +type Rollup struct { + Tiers []RollupTier +} + +// RollupTier is one tier of a [Rollup]. +type RollupTier struct { + Before int64 + Interval int64 + Agg uint8 +} + +// appendRollup writes a known layout; an unknown one is only the absence of its flag bit. +func appendRollup(dst []byte, r *Rollup) []byte { + if r == nil { + return dst + } + + dst = binary.AppendUvarint(dst, uint64(len(r.Tiers))) + for _, t := range r.Tiers { + dst = binary.AppendVarint(dst, t.Before) + dst = binary.AppendVarint(dst, t.Interval) + dst = binary.AppendUvarint(dst, uint64(t.Agg)) + } + + return dst +} + +func readRollup(buf []byte) (*Rollup, []byte, bool) { + n, buf, ok := readUvarint(buf) + if !ok || n > uint64(len(buf)) { + return nil, nil, false + } + + r := &Rollup{} + if n > 0 { + r.Tiers = make([]RollupTier, 0, n) + } + + for range n { + var ( + t RollupTier + agg uint64 + ) + + if t.Before, buf, ok = readVarint(buf); !ok { + return nil, nil, false + } + + if t.Interval, buf, ok = readVarint(buf); !ok { + return nil, nil, false + } + + if agg, buf, ok = readUvarint(buf); !ok || agg > math.MaxUint8 { + return nil, nil, false + } + + t.Agg = uint8(agg) + r.Tiers = append(r.Tiers, t) + } + + return r, buf, true +} diff --git a/backend/bucketindex/want.go b/backend/bucketindex/want.go index d39cedb..f54b83a 100644 --- a/backend/bucketindex/want.go +++ b/backend/bucketindex/want.go @@ -102,15 +102,20 @@ func (ix *Index) Wants() map[string]Want { } // TrimWants drops the wants already discharged — those naming a part the index holds again, or one -// a live part supersedes — and nothing else: an outstanding want is the only record that a part is -// owed, so no count trims it (see [MaxWants]). -func TrimWants(wants []Want, live []Entry) []Want { - ix := Index{Entries: live} +// a live part supersedes, related by the claims live carries and the index's catalog — and nothing +// else: an outstanding want is the only record that a part is owed, so no count trims it (see +// [MaxWants]). +func TrimWants(wants []Want, live []Entry, catalog ...Claim) []Want { + if len(wants) == 0 { + return wants + } + + rel := (&Index{Entries: live, Catalog: catalog, Wanted: wants}).Relations() out := wants[:0] for i := range wants { w := &wants[i] - if _, ok := ix.Discharging(*w); ok { + if _, ok := rel.Discharging(*w); ok { continue } diff --git a/cluster/ARCH.md b/cluster/ARCH.md index fa7e40d..ad9ed31 100644 --- a/cluster/ARCH.md +++ b/cluster/ARCH.md @@ -437,6 +437,16 @@ while the deletion rule below withholds the bytes — unreclaimable at the same what keeps this from resurrecting compacted parts forever; it is the same evidence a repair accepts for a want (`Index.Satisfying`), and it does not depend on tombstones, which are bounded and age out. +The subsumption is judged over **both sides' lineage** — the peer index's catalog and claims, and +the local one's — and so is a repair fetch's answer (`FetchWants` reads the local index and asks each +peer's `SatisfyingWith` it): a member of a group split again is subsumed by a successor of the outer +ancestry only through the outer claim, which a diverged peer may never have recorded. A local index +that cannot be read makes every unanswered want of that pass an error rather than absence, since +without its lineage an absence is not evidence. Installing a peer's index keeps the local catalog's +groups beside the peer's (`bucketindex.MergeLineage`), trimmed against the installed index like any +catalog (`TrimCatalog`), and installs the merged result whenever it differs from the peer's in +contents — equal length says nothing once the merge aged a group out to make room. + `accountsFor` deliberately stops short of `Satisfying`'s **split-group** case: a peer that holds a part's rows only jointly, spread across the fragments of a split, does not account for it and the local copy is kept. Dropping a part on a joint claim would mean trusting that every member of the diff --git a/cluster/partsync/catalog_test.go b/cluster/partsync/catalog_test.go new file mode 100644 index 0000000..3500a74 --- /dev/null +++ b/cluster/partsync/catalog_test.go @@ -0,0 +1,85 @@ +package partsync_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oteldb/storage/backend" + "github.com/oteldb/storage/backend/bucketindex" + "github.com/oteldb/storage/cluster/partsync" +) + +// TestSyncKeepsTheLocalLineage: installing a newer peer's index keeps the groups only this node's +// catalog recorded. Lineage is facts about groups committed anywhere, and a want for a member of one +// may still need it. +func TestSyncKeepsTheLocalLineage(t *testing.T) { + t.Parallel() + ctx := context.Background() + + owner, replica := backend.Memory(), backend.Memory() + + mine := bucketindex.Claim{Blocks: bucketindex.Blocks(1, 2), Group: bucketindex.Blocks(10, 11)} + theirs := bucketindex.Claim{Blocks: bucketindex.Blocks(3), Group: bucketindex.Blocks(20, 21)} + + lix := &bucketindex.Index{Catalog: []bucketindex.Claim{mine}} + saveIndex(t, replica, "default/metrics", lix) + + pix := &bucketindex.Index{ + Generation: bucketindex.Generation{Term: 9, Counter: 1}, + Catalog: []bucketindex.Claim{theirs}, + } + writePart(t, owner, pix, "default/metrics", 1, 100, 200) + saveIndex(t, owner, "default/metrics", pix) + + s := partsync.New(replica, &partsync.Client{}) + st, err := s.Sync(ctx, "default/metrics", []string{serve(t, owner)}, false, nil) + require.NoError(t, err) + require.True(t, st.Synced) + + got, err := bucketindex.Load(ctx, replica, "default/metrics/"+bucketindex.Object) + require.NoError(t, err) + assert.Equal(t, []bucketindex.Claim{mine, theirs}, got.Catalog) + assert.Len(t, got.Entries, 1, "the peer's parts are installed") +} + +// TestSyncKeepsTheLocalLineageBesideAFullCatalog: a peer catalog at MaxLineage and one newer claim +// only this node recorded. The merge ages the peer's oldest unreached group out to make room, so the +// result differs from the peer's catalog in contents while matching it in length — and it is the +// merged result that is installed. +func TestSyncKeepsTheLocalLineageBesideAFullCatalog(t *testing.T) { + t.Parallel() + ctx := context.Background() + + owner, replica := backend.Memory(), backend.Memory() + + full := make([]bucketindex.Claim, 0, bucketindex.MaxLineage) + for i := range uint64(bucketindex.MaxLineage) { + full = append(full, bucketindex.Claim{ + Blocks: bucketindex.Blocks(1000 + 2*i), Group: bucketindex.Blocks(1001 + 2*i), + }) + } + + newer := bucketindex.Claim{Blocks: bucketindex.Blocks(1 << 20), Group: bucketindex.Blocks(1<<20 + 1)} + saveIndex(t, replica, "default/metrics", &bucketindex.Index{Catalog: []bucketindex.Claim{newer}}) + + pix := &bucketindex.Index{ + Generation: bucketindex.Generation{Term: 9, Counter: 1}, + Catalog: bucketindex.MergeLineage(full), + } + writePart(t, owner, pix, "default/metrics", 1, 100, 200) + saveIndex(t, owner, "default/metrics", pix) + + s := partsync.New(replica, &partsync.Client{}) + st, err := s.Sync(ctx, "default/metrics", []string{serve(t, owner)}, false, nil) + require.NoError(t, err) + require.True(t, st.Synced) + + got, err := bucketindex.Load(ctx, replica, "default/metrics/"+bucketindex.Object) + require.NoError(t, err) + require.Len(t, got.Catalog, bucketindex.MaxLineage) + assert.True(t, got.Catalog[len(got.Catalog)-1].Equal(newer), "the local-only claim is installed") + assert.True(t, got.Catalog[0].Equal(full[1]), "the peer's oldest unreached group made room") +} diff --git a/cluster/partsync/held.go b/cluster/partsync/held.go index 71e9e12..94d0c87 100644 --- a/cluster/partsync/held.go +++ b/cluster/partsync/held.go @@ -4,6 +4,7 @@ import ( "context" "math" "path" + "slices" "strings" "github.com/go-faster/errors" @@ -124,14 +125,18 @@ type peerAccount struct { stated bool // live are its data entries, the successors an absence can be explained by. live []bucketindex.Entry + // rel relates live to what they subsume, by the index's own claims and catalog and the other + // side's, since either may be the only one that saw a group split. + rel *bucketindex.Relations } -func accountOf(ix *bucketindex.Index) peerAccount { +func accountOf(ix *bucketindex.Index, other bucketindex.Lineage) peerAccount { a := peerAccount{ present: make(map[string]struct{}, len(ix.Entries)), claimed: make(map[string]struct{}, len(ix.Wanted)), removed: ix.Removals(), stated: ix.RecordsRemovals(), + rel: ix.Relations(other...), } for i := range ix.Entries { @@ -182,15 +187,7 @@ func (a peerAccount) accountsFor(e bucketindex.Entry) bool { return a.supersedes(e) } -func (a peerAccount) supersedes(e bucketindex.Entry) bool { - for i := range a.live { - if a.live[i].Supersedes(e) { - return true - } - } - - return false -} +func (a peerAccount) supersedes(e bucketindex.Entry) bool { return a.rel.Supersedes(e) } func (s *Syncer) holdsManifest(ctx context.Context, partPrefix string) (bool, error) { _, err := backend.ReadView(ctx, s.local, partPrefix+"/"+manifestName) @@ -239,6 +236,21 @@ func retainHeld(peer *bucketindex.Index, h held) *bucketindex.Index { return &ix } +// keepLocalLineage is the index to install with the groups only the local catalog recorded added to +// it. Lineage is facts about groups committed anywhere, so installing a peer's index must not forget +// one: a want for one of its members may still need it. +func keepLocalLineage(installed *bucketindex.Index, raw []byte, local *bucketindex.Index) (*bucketindex.Index, []byte) { + withLocal := *installed + withLocal.Catalog = bucketindex.MergeLineage(installed.Catalog, local.Catalog) + withLocal.TrimCatalog() + + if slices.EqualFunc(withLocal.Catalog, installed.Catalog, bucketindex.Claim.Equal) { + return installed, raw + } + + return &withLocal, withLocal.AppendBinary(nil) +} + // manifestParts is the parts a listing shows a complete copy of. func manifestParts(listed []string, enginePrefix string) map[string]struct{} { out := make(map[string]struct{}) diff --git a/cluster/partsync/nestedwant_test.go b/cluster/partsync/nestedwant_test.go new file mode 100644 index 0000000..ec62de2 --- /dev/null +++ b/cluster/partsync/nestedwant_test.go @@ -0,0 +1,74 @@ +package partsync_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oteldb/storage/backend" + "github.com/oteldb/storage/backend/bucketindex" + "github.com/oteldb/storage/cluster/partsync" +) + +// TestFetchWantNestedGroupFromOuterSuccessor is the nested false hole of #725. This node split blocks +// {1,2} into the outer group {10..12}, then split member 12 with block 7 into the inner group +// {20..22}, and lost member 20. Every outer member has since left its index, so the want for 20 +// carries only the inner claim. The peer diverged before either split: the only surviving copy is its +// higher-level successor of {1,2,7} — which holds 12's rows through the outer ancestry, and so 20's. +// +// Nothing the want or the peer's index carries relates 20 to {1,2}: only this node's lineage catalog +// does. Without it the peer's answer is definitive absence, and three of those commit a hole over +// rows the peer still has. +func TestFetchWantNestedGroupFromOuterSuccessor(t *testing.T) { + t.Parallel() + ctx := context.Background() + + const prefix = "default/logs" + + peer, damaged := backend.Memory(), backend.Memory() + + outer := bucketindex.Claim{Blocks: bucketindex.Blocks(1, 2), Group: bucketindex.Blocks(10, 11, 12)} + inner := bucketindex.Claim{Blocks: bucketindex.Blocks(7, 12), Group: bucketindex.Blocks(20, 21, 22)} + + member := func(ix *bucketindex.Index, name string, block uint64, claim bucketindex.Claim, level uint32) { + ix.Add(bucketindex.Entry{ + Prefix: prefix + "/" + name, MinTime: 1, MaxTime: 2, + Blocks: bucketindex.Blocks(block), Claim: claim, Level: level, + }) + } + + local := &bucketindex.Index{} + for i, b := range []uint64{10, 11, 12} { + member(local, "outer"+string(rune('a'+i)), b, outer, 1) + } + + local.RecordLineage() + + for _, name := range []string{"outera", "outerb", "outerc"} { + require.True(t, local.Remove(prefix+"/"+name)) + } + + for i, b := range []uint64{20, 21, 22} { + member(local, "inner"+string(rune('a'+i)), b, inner, 2) + } + + local.RecordLineage() + + lost := local.Entries[0] + require.True(t, local.Remove(lost.Prefix)) + local.RecordWant(bucketindex.WantOf(lost, bucketindex.Generation{})) + saveIndex(t, damaged, prefix, local) + + pix := &bucketindex.Index{} + successor := writeBlockPart(t, peer, pix, prefix, "succ", bucketindex.Blocks(1, 2, 7), 3) + saveIndex(t, peer, prefix, pix) + + s := partsync.New(damaged, &partsync.Client{}) + + ent, ok, err := s.FetchWant(ctx, prefix, []string{serve(t, peer)}, local.Wanted[0]) + require.NoError(t, err) + require.True(t, ok, "the peer's successor holds the lost member's rows through the outer ancestry") + assert.Equal(t, successor, ent.Prefix) +} diff --git a/cluster/partsync/partsync.go b/cluster/partsync/partsync.go index 6962447..fb63b88 100644 --- a/cluster/partsync/partsync.go +++ b/cluster/partsync/partsync.go @@ -506,7 +506,7 @@ func (s *Syncer) sync(ctx context.Context, enginePrefix string, peers []string, } cmp := compareIndexes(peerIndex, localIndex) - acct := accountOf(peerIndex) + acct := accountOf(peerIndex, localIndex.Lineage()) // The obligations only the local side can state. A peer whose index this one supersedes still // holds parts this one never indexed, and no claim path reaches them: the peer can only omit @@ -540,6 +540,8 @@ func (s *Syncer) sync(ctx context.Context, enginePrefix string, peers []string, zap.String("prefix", enginePrefix), zap.String("peer", addr), zap.Int("claimed", len(h.claimed)), zap.Int("omitted", len(h.omitted))) } + + installed, installedRaw = keepLocalLineage(installed, installedRaw, localIndex) } newer := cmp > 0 || (cmp == 0 && !strict && !bytes.Equal(installedRaw, localRaw)) @@ -999,7 +1001,7 @@ const wantAfterPasses = 3 // confirmedWants is the obligations this node owes for parts only the peer holds, confirmed across // [wantAfterPasses] passes. A part that becomes accounted for in between resets. func (s *Syncer) confirmedWants(enginePrefix string, peer, local *bucketindex.Index) []bucketindex.Want { - ents := unaccountedEntries(peer, accountOf(local), retentionHorizon(local)) + ents := unaccountedEntries(peer, accountOf(local, peer.Lineage()), retentionHorizon(local)) s.mu.Lock() defer s.mu.Unlock() diff --git a/cluster/partsync/repair.go b/cluster/partsync/repair.go index 2ed5411..33c44df 100644 --- a/cluster/partsync/repair.go +++ b/cluster/partsync/repair.go @@ -83,6 +83,25 @@ func (s *Syncer) FetchWants( views, firstErr := s.peerIndexes(ctx, enginePrefix, peers) + // This node's own lineage is what relates a want for a member of a group split again to a peer's + // successor of the outer ancestry, which the peer's index may never have recorded. Without it an + // answer of absence would not be evidence, so a failed read is an error for every silent want. + local, err := bucketindex.Load(ctx, s.local, enginePrefix+"/"+bucketindex.Object) + if err != nil { + local = &bucketindex.Index{} + + if firstErr == nil { + firstErr = errors.Wrap(err, "load local index") + } + } + + // One set of relations per peer, answering every want of the batch with the closures it has + // already computed. + rels := make([]*bucketindex.Relations, len(views)) + for i := range views { + rels[i] = views[i].ix.Relations(local.Lineage()...) + } + tasks := make(map[string]*copyTask) enqueue := func(i int, addr string, ent bucketindex.Entry) { @@ -98,7 +117,7 @@ func (s *Syncer) FetchWants( var silent []int for i := range wants { - if addr, ent, ok := selectSatisfying(views, wants[i]); ok { + if addr, ent, ok := selectSatisfying(views, rels, wants[i]); ok { enqueue(i, addr, ent) } else { silent = append(silent, i) @@ -346,9 +365,11 @@ func (s *Syncer) shuffled(peers []string) []string { // selectSatisfying picks the best entry discharging w across the peers' indexes. Peers are // consulted in the given (shuffled) order and [betterCandidate] is strict, so equal candidates // resolve to whichever peer the shuffle put first rather than to a fixed one. -func selectSatisfying(views []peerView, w bucketindex.Want) (addr string, best bucketindex.Entry, ok bool) { - for _, v := range views { - cand, found := v.ix.Satisfying(w) +func selectSatisfying( + views []peerView, rels []*bucketindex.Relations, w bucketindex.Want, +) (addr string, best bucketindex.Entry, ok bool) { + for i, v := range views { + cand, found := rels[i].Satisfying(w) if !found { continue } diff --git a/cluster/partsync/wants_internal_test.go b/cluster/partsync/wants_internal_test.go index 00687a1..d4bc76c 100644 --- a/cluster/partsync/wants_internal_test.go +++ b/cluster/partsync/wants_internal_test.go @@ -13,6 +13,27 @@ func wantEntry(prefix string, blocks bucketindex.Interval, minT, maxT int64) buc return bucketindex.Entry{Prefix: prefix, Blocks: blocks, MinTime: minT, MaxTime: maxT} } +// TestAccountsForThroughEitherLineage: a peer's successor of an outer ancestry accounts for a member +// of a group split again when either side's lineage records the outer group, so the member's absence +// from the peer's index is explained rather than left as an omission. +func TestAccountsForThroughEitherLineage(t *testing.T) { + t.Parallel() + + outer := bucketindex.Claim{Blocks: bucketindex.Blocks(1, 2), Group: bucketindex.Blocks(10, 11, 12)} + inner := bucketindex.Claim{Blocks: bucketindex.Blocks(7, 12), Group: bucketindex.Blocks(20, 21, 22)} + member := bucketindex.Entry{Prefix: "p/m20", Blocks: bucketindex.Blocks(20), Claim: inner, Level: 2} + + peer := &bucketindex.Index{Generation: bucketindex.Generation{Term: 1, Counter: 1}} + peer.Add(bucketindex.Entry{Prefix: "p/succ", Blocks: bucketindex.Blocks(1, 2, 7), Level: 3}) + peer.Tombstone(bucketindex.Removal{Prefix: "p/other"}) + + assert.False(t, accountOf(peer, nil).accountsFor(member), "no lineage relates the two") + assert.True(t, accountOf(peer, bucketindex.Lineage{outer}).accountsFor(member), "this side's lineage does") + + peer.Catalog = []bucketindex.Claim{outer} + assert.True(t, accountOf(peer, nil).accountsFor(member), "and so does the peer's own catalog") +} + func wantIndex(g bucketindex.Generation, ents ...bucketindex.Entry) *bucketindex.Index { ix := &bucketindex.Index{Generation: g} for i := range ents { @@ -63,7 +84,7 @@ func TestUnaccountedEntriesGating(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - got := unaccountedEntries(tc.peer, accountOf(tc.local), retentionHorizon(tc.local)) + got := unaccountedEntries(tc.peer, accountOf(tc.local, nil), retentionHorizon(tc.local)) prefixes := make([]string, 0, len(got)) for i := range got { diff --git a/engine/ARCH.md b/engine/ARCH.md index 66d6e9d..144bdcd 100644 --- a/engine/ARCH.md +++ b/engine/ARCH.md @@ -690,8 +690,17 @@ readable part's: rebases onto the rival's index. After every rebase the commit re-checks the parts it adopted that the planning never saw. If one does not nest with the output, the output is dropped, its objects are reclaimed like any uncommitted part, and the next merge replans with the adopted part in its - history. An adopted part that does not open escapes the check, since its layout lives only in its - manifest, so a rebase that cannot read it can commit an incompatible output beside it (#745). + history. +- An adopted part that does not open is checked by the layout its index entry records + (`bucketindex.Entry.Rollup`): the guard reads it from the entry (`foreignLayoutLocked`), and + planning sees it through a reader-less stand-in carrying only prefix, time range and layout + (`unopenedLayoutsLocked`), which only the layout checks ever read. Every commit writes its own + parts' layouts from their manifests, and fills in an adopted entry's once its part opens + (`fillRollup`), so an entry written before the index carried layouts is completed by the first + writer that reads the part. An entry whose layout is still unknown and whose part does not open is + legacy and counts as raw, like a part without a marker: it constrains nothing, and never aborts a + commit. Aborting would stall that merge until the part became readable, since nothing else can + fill the layout in; the cost is the check against a part only a pre-v8 index can leave unknown. - Whichever writer commits second backs off, so a merge never adds an incompatible layout to the index. @@ -1013,12 +1022,17 @@ and index commit. **A unit is committed whole or not at all** (`repair.Admit`), where "whole" means two things. First, the unit must answer its want, so a want the peer answered only jointly needs its whole group. -Second, every group whose claimed ancestry overlaps what this node already holds must be complete. A -fragment committed beside the ancestors it partly duplicates, with nothing yet able to retire them, -has those rows read twice. On the record engine, where rows do not collapse by timestamp, it is worse +Second, every group the unit's rows derive through whose claimed ancestry overlaps what this node +already holds must be complete, and with it each of its members split again. That includes a group no +fetched part carries — an outer one whose members were all split again, known only from the lineage +catalog — so a lost outer member owed beside a divergent copy of the ancestry pulls in the rest of the +outer group, through the members' own splits. A fragment committed beside the ancestors it partly +duplicates, with nothing yet able to retire them, has those rows read twice. On the record engine, where rows do not collapse by timestamp, it is worse than a read: the next merge folds the lone fragment into a local part, the want stays outstanding, and the next pass commits the same fragment again — one duplicate per cycle. Completing the group is -what retires those ancestors, through `bucketindex.Subsumed`, the same swap a merge publishes. +what retires those ancestors, through `bucketindex.Subsumed`, the same swap a merge publishes. It +judges every group the lineage records, catalog-only ones included, completed by the live and fetched +parts together, and retires what the group claims below the level of the parts completing it. A member of a group whose ancestry this node no longer holds is different: its rows are rows the node lacks, so it is committed on its own, whatever became of its siblings. Requiring the group there @@ -1050,7 +1064,8 @@ its inputs capped by the merge's byte cap and `maxMergeParts`. Its fragment coun span, plus any writer the router sealed early for size or residency — the router holds at most `timebucket.MaxOpenWriters` (32) days open at once, which bounds memory, not fragments. The rounds terminate because a unit asks for each block at most once per pass, and one answer serves every unit -needing that block. +needing that block. Termination is not a work bound: nothing caps the members, nesting levels or +outer groups the catalog adds to one pass (#751). **A member no owner holds ends in a hole — only for the want it answers.** Evidence is per target. A unit whose own answer satisfies its want — the exact part, a containing successor, or a @@ -1189,8 +1204,8 @@ whether the data exists. **The hole is revocable and re-attempted.** Because the commit is not cross-replica atomic, an owner can acknowledge a loss while a peer still holds the part. So every repair pass targets the holes as -well as the wants, and a hole is replaced by the part turning up — at its exact prefix, or inside a -containing successor. Holes are held apart from `parts` (there is nothing to open) and re-read from +well as the wants, and a hole is replaced by the part turning up — at its exact prefix, inside a +containing successor, or as a complete split group jointly covering it. Holes are held apart from `parts` (there is nothing to open) and re-read from the index on load, so an acknowledgement survives a restart. `LostParts` does not fall when a hole is revoked. diff --git a/engine/engine.go b/engine/engine.go index a84c943..5478ec0 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -286,6 +286,11 @@ type Engine struct { // established is the ownership term whose first commit has landed: until it equals the current // term, no commit of that tenure is allowed. See [Engine.establishTenureLocked]. established uint64 + // catalog is the lineage catalog this engine last committed or adopted + // ([bucketindex.Index.Catalog]): every split group's claim, kept after its members are gone. + catalog []bucketindex.Claim + // lineageWarned makes the warning that the catalog outgrew its target a once-per-engine one. + lineageWarned atomic.Bool // pendingHoles are the losses the next commit must acknowledge. Like pendingWants they are // held rather than applied on the spot, so a commit that never lands leaves the want // outstanding instead of half-discharged. diff --git a/engine/entrylayout.go b/engine/entrylayout.go new file mode 100644 index 0000000..9d2b469 --- /dev/null +++ b/engine/entrylayout.go @@ -0,0 +1,80 @@ +package engine + +import ( + "github.com/oteldb/storage/backend/bucketindex" + "github.com/oteldb/storage/signal" +) + +// A part's rollup layout lives in its manifest and is carried in its index entry +// ([bucketindex.Entry.Rollup]), so a writer can check an adopted part's layout without opening it. + +// entryRollup is the layout p's index entry records: its manifest's, or unknown. +func entryRollup(p *part) *bucketindex.Rollup { + if !p.rollupKnown { + return nil + } + + r := &bucketindex.Rollup{} + for _, t := range p.rollup { + r.Tiers = append(r.Tiers, bucketindex.RollupTier{Before: t.Before, Interval: t.Interval, Agg: uint8(t.Agg)}) + } + + return r +} + +// entryTiers is the layout an entry records. An unknown one — an entry no commit has filled in since +// its part was written, and a part whose manifest records none — is raw, as it is to every layout +// check: it constrains nothing. +func entryTiers(r *bucketindex.Rollup) []DownsampleTier { + if r == nil { + return nil + } + + out := make([]DownsampleTier, 0, len(r.Tiers)) + for _, t := range r.Tiers { + out = append(out, DownsampleTier{Before: t.Before, Interval: t.Interval, Agg: signal.Aggregation(t.Agg)}) + } + + return out +} + +// foreignLayoutLocked is the layout of the adopted entry ent: its part's manifest where the part is +// open, else the layout the entry records. Caller holds e.mu. +func (e *Engine) foreignLayoutLocked(ent *bucketindex.Entry) []DownsampleTier { + if p, ok := e.foreignParts[ent.Prefix]; ok && p.rollupKnown { + return p.rollup + } + + return entryTiers(ent.Rollup) +} + +// unopenedLayoutsLocked stands in, by the layout its entry records, for each adopted part this engine +// could not open, so a merge plans against a rival's layout it cannot read. A stand-in carries a +// prefix, a time range and a layout and no reader: only the layout checks read it ([mergeCohorts], +// [resolvePolicy], the commit's [rollupGuard]), never a decode or a selection. Caller holds e.mu. +func (e *Engine) unopenedLayoutsLocked() []*part { + var out []*part + + for i := range e.foreign { + ent := &e.foreign[i] + if _, open := e.foreignParts[ent.Prefix]; open || ent.Rollup == nil { + continue + } + + out = append(out, &part{ + prefix: ent.Prefix, minTime: ent.MinTime, maxTime: ent.MaxTime, + rollup: entryTiers(ent.Rollup), rollupKnown: true, + }) + } + + return out +} + +// fillRollup records an adopted part's layout in its entry once the part opens, for the next commit +// to publish: an entry written before the index carried layouts is filled in by the first writer +// that reads the part. +func fillRollup(ent *bucketindex.Entry, p *part) { + if ent.Rollup == nil { + ent.Rollup = entryRollup(p) + } +} diff --git a/engine/entrylayout_test.go b/engine/entrylayout_test.go new file mode 100644 index 0000000..cf97a61 --- /dev/null +++ b/engine/entrylayout_test.go @@ -0,0 +1,129 @@ +package engine_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oteldb/storage/backend" + "github.com/oteldb/storage/backend/bucketindex" + "github.com/oteldb/storage/engine" + "github.com/oteldb/storage/signal" +) + +// forgetLayouts rewrites the shared index as a writer before the index carried layouts left it. +func forgetLayouts(t *testing.T, be backend.Backend) { + t.Helper() + + ctx := context.Background() + key := sharedPrefix + "/" + bucketindex.Object + + ix, version, err := bucketindex.LoadVersioned(ctx, be, key) + require.NoError(t, err) + + for i := range ix.Entries { + ix.Entries[i].Rollup = nil + } + + _, err = ix.Save(ctx, be, key, version) + require.NoError(t, err) +} + +func rolled(agg signal.Aggregation) engine.MergeOptions { + return engine.MergeOptions{Downsample: []engine.DownsampleTier{{Before: 1 << 62, Interval: 1000, Agg: agg}}} +} + +// TestEntryLayoutFilledFromTheManifest: an index entry recording no layout is filled in from the +// part's manifest once the part opens, own or adopted, and the next commit publishes it. +func TestEntryLayoutFilledFromTheManifest(t *testing.T) { + t.Parallel() + + ctx := context.Background() + be := backend.Memory() + a, b, _, _ := rivals(t, be) + + for i := range int64(5) { + mustAppend(t, a, mkSeries("job", "api"), 100+i, 1) + } + + require.NoError(t, a.Flush(ctx)) + require.NoError(t, a.MergeWith(ctx, rolled(signal.AggCount))) + + rolledPart := committedIndex(t, be).Entries[0].Prefix + require.NotNil(t, committedIndex(t, be).Entries[0].Rollup, "a commit records its own parts' layouts") + + forgetLayouts(t, be) + + // b adopts a's part by losing the CAS to it, opens it, and publishes its layout. + mustAppend(t, b, mkSeries("job", "web"), 100, 1) + require.NoError(t, b.Flush(ctx)) + + layouts := map[string]*bucketindex.Rollup{} + for _, ent := range committedIndex(t, be).Entries { + layouts[ent.Prefix] = ent.Rollup + } + + require.Contains(t, layouts, rolledPart) + require.NotNil(t, layouts[rolledPart], "the adopted part's layout is filled from its manifest") + assert.Equal(t, uint8(signal.AggCount), layouts[rolledPart].Tiers[0].Agg) + + forgetLayouts(t, be) + + // a loads it as its own part and fills it the same way. + require.NoError(t, a.LoadParts(ctx)) + mustAppend(t, a, mkSeries("job", "api"), 200, 1) + require.NoError(t, a.Flush(ctx)) + + for _, ent := range committedIndex(t, be).Entries { + assert.NotNilf(t, ent.Rollup, "%s records its layout again", ent.Prefix) + } +} + +// TestUnknownLayoutOfAnUnreadablePartIsRaw: an adopted entry that records no layout and whose part +// cannot be opened is legacy, and counts as raw — it never aborts a commit. Aborting would stall the +// merge for good, since only reading the manifest could ever fill the layout in. What it costs is a +// layout check against that one part, which only an index written before layouts were carried can +// lack. +func TestUnknownLayoutOfAnUnreadablePartIsRaw(t *testing.T) { + t.Parallel() + + ctx := context.Background() + shared := backend.Memory() + blind := &blindBackend{Backend: shared} + + a := engine.New(engine.Config{Backend: shared, Prefix: sharedPrefix, WriterID: "a"}) + require.NoError(t, a.LoadParts(ctx)) + b := engine.New(engine.Config{Backend: blind, Prefix: sharedPrefix, WriterID: "b"}) + require.NoError(t, b.LoadParts(ctx)) + + for i := range int64(5) { + mustAppend(t, a, mkSeries("job", "api"), 100+i, 1) + mustAppend(t, b, mkSeries("job", "web"), 100+i, 1) + } + + require.NoError(t, a.Flush(ctx)) + require.NoError(t, b.Flush(ctx)) + require.NoError(t, a.MergeWith(ctx, rolled(signal.AggCount))) + forgetLayouts(t, shared) + + parts := a.Parts() + + hidden := make([]string, 0, len(parts)) + for _, p := range parts { + hidden = append(hidden, p.ID) + } + + blind.setHidden(hidden...) + + require.NoError(t, b.MergeWith(ctx, rolled(signal.AggSum))) + + var sum bool + + for _, ent := range committedIndex(t, shared).Entries { + sum = sum || ent.Rollup != nil && len(ent.Rollup.Tiers) > 0 && ent.Rollup.Tiers[0].Agg == uint8(signal.AggSum) + } + + assert.True(t, sum, "b's rollup committed: the unknown layout did not abort it") +} diff --git a/engine/index.go b/engine/index.go index 6bb671c..c587d3d 100644 --- a/engine/index.go +++ b/engine/index.go @@ -120,6 +120,7 @@ func (e *Engine) commitOnceLocked(ctx context.Context) (bool, error) { e.pendingBlocks = nil e.holes, e.lostParts = ix.Holes(), ix.LostParts e.allocated = ix.AllocatedBlocks + e.catalog = ix.Catalog e.pendingHoles = nil return true, nil @@ -162,6 +163,7 @@ func (e *Engine) adoptIndexLocked(ctx context.Context) error { e.holes = mergeHoles(e.holes, ix.Holes()) e.lostParts = max(e.lostParts, ix.LostParts) e.allocated = bucketindex.MaxBlock(e.allocated, ix.AllocatedBlocks) + e.catalog = bucketindex.MergeLineage(e.catalog, ix.Catalog) e.foreign = foreignEntries(ix.Entries, e.indexed, e.removals, e.wants) e.openForeignLocked(ctx) @@ -200,6 +202,7 @@ func (e *Engine) openForeignLocked(ctx context.Context) { ent := &e.foreign[i] if p, ok := e.foreignParts[ent.Prefix]; ok { open[ent.Prefix] = p + fillRollup(ent, p) continue } @@ -215,6 +218,7 @@ func (e *Engine) openForeignLocked(ctx context.Context) { p.minTime, p.maxTime = ent.MinTime, ent.MaxTime open[ent.Prefix] = p + fillRollup(ent, p) // Without its identities the part is open but unresolvable: matchers resolve through the // head's identity index, so the rows would be there and nothing would name them. @@ -291,7 +295,7 @@ func (e *Engine) nextIndexLocked(ctx context.Context) *bucketindex.Index { ix.Add(bucketindex.Entry{ Prefix: p.prefix, MinTime: p.minTime, MaxTime: p.maxTime, - Blocks: blocks, Claim: claim, Level: level, Term: term, + Blocks: blocks, Claim: claim, Level: level, Term: term, Rollup: entryRollup(p), }) live[p.prefix] = struct{}{} } @@ -317,7 +321,7 @@ func (e *Engine) nextIndexLocked(ctx context.Context) *bucketindex.Index { // as indexed, or the next commit would read it as a removal. ix.LostParts = e.lostParts - trimmed := bucketindex.TrimHoles(slices.Clone(e.holes), ix.Entries) + trimmed := bucketindex.TrimHoles(slices.Clone(e.holes), ix.Entries, e.catalog...) for i := range trimmed { ix.Add(trimmed[i]) live[trimmed[i].Prefix] = struct{}{} @@ -358,7 +362,15 @@ func (e *Engine) nextIndexLocked(ctx context.Context) *bucketindex.Index { // Committing a part is what discharges a want, so the trim runs against the entries this // commit publishes: a want naming a part the index holds again, or one a live part contains, // is repaired by the act of writing this index. - ix.Wanted = bucketindex.TrimWants(ix.Wanted, ix.Entries) + ix.Wanted = bucketindex.TrimWants(ix.Wanted, ix.Entries, e.catalog...) + + // The lineage of every group this commit publishes joins the catalog here, and reaches e.catalog + // only if the commit lands: a group run a lost attempt allocated may be handed to other parts. + ix.Catalog = slices.Clone(e.catalog) + if over := ix.RecordLineage(); over > 0 && e.lineageWarned.CompareAndSwap(false, true) { + zctx.From(ctx).Warn("lineage catalog holds more reachable groups than its target; none is aged out", + zap.String("prefix", e.cfg.Prefix), zap.Int("over", over), zap.Int("target", bucketindex.MaxLineage)) + } // Past the horizon the node needs the wholesale adoption cluster/partsync performs, not // part-by-part repair. The wants stay either way: each is the only record that its part is @@ -646,6 +658,7 @@ func (e *Engine) adoptLoadLocked(l *indexLoad) { e.foreign, e.foreignParts = nil, nil e.holes, e.lostParts, e.allocated = l.holes, l.ix.LostParts, l.ix.AllocatedBlocks + e.catalog = l.ix.Catalog // A part disappearing means identities may have died with it, which is what arms the identity // prune on a node that never merges (a replica adopting the owner's part set). diff --git a/engine/merge.go b/engine/merge.go index 10d4b32..be06828 100644 --- a/engine/merge.go +++ b/engine/merge.go @@ -120,7 +120,7 @@ func (e *Engine) merge(ctx context.Context, opts MergeOptions) (mergeResult, err } src := e.parts - adopted := slices.Collect(maps.Values(e.foreignParts)) + adopted := append(slices.Collect(maps.Values(e.foreignParts)), e.unopenedLayoutsLocked()...) e.mu.Unlock() capBytes := e.mergeCapBytes(ctx) @@ -307,8 +307,9 @@ var errRollupConflict = errors.New("a rival writer committed a part recording a // part the rival committed that the merge's planning never saw, and whose recorded layout does not // nest with the output's ([tiersNest]: another Agg, or a grid that does not nest), means the commit // would leave the index holding layouts no later merge can combine. The merge is dropped and the next -// one replans with that part. A merge whose output records no tier cannot introduce a conflict. -// Called with e.mu held. +// one replans with that part. A merge whose output records no tier cannot introduce a conflict. The +// layout comes from the adopted entry when its part does not open ([Engine.foreignLayoutLocked]), so +// a rival part this node cannot read still constrains the commit. Called with e.mu held. func rollupGuard(readable []*part, marker *block.Rollup, e *Engine) func() error { out, _ := appliedRollup(marker) if !activeTiers(out) { @@ -321,8 +322,9 @@ func rollupGuard(readable []*part, marker *block.Rollup, e *Engine) func() error } return func() error { - for _, p := range e.foreignParts { - if _, ok := planned[p.prefix]; !ok && !layoutsNest(out, p.rollup) { + for i := range e.foreign { + ent := &e.foreign[i] + if _, ok := planned[ent.Prefix]; !ok && !layoutsNest(out, e.foreignLayoutLocked(ent)) { return errRollupConflict } } diff --git a/engine/rebase_test.go b/engine/rebase_test.go index c6505a6..0d46c4a 100644 --- a/engine/rebase_test.go +++ b/engine/rebase_test.go @@ -14,7 +14,6 @@ import ( "github.com/oteldb/storage/backend" "github.com/oteldb/storage/backend/bucketindex" "github.com/oteldb/storage/engine" - "github.com/oteldb/storage/internal/reproduce" "github.com/oteldb/storage/query/fetch" "github.com/oteldb/storage/signal" ) @@ -340,7 +339,6 @@ func (b *blindBackend) setHidden(prefixes ...string) { // sees its layout and b commits its Sum output beside a's Count part. The committed index must never // hold both; once the manifest reads again, b's rollup must still go through. func TestRebaseBlindToAdoptedPart(t *testing.T) { - reproduce.Unfixed(t, 745, "a rebase that cannot open an adopted part commits a rollup beside an incompatible one") t.Parallel() ctx := context.Background() diff --git a/engine/repair.go b/engine/repair.go index 5ef8ac4..c90d6cd 100644 --- a/engine/repair.go +++ b/engine/repair.go @@ -52,8 +52,8 @@ func (h repairHost) Locks() (state, flush sync.Locker) { return &h.e.mu, &h.e.fl // ObligationsLocked includes pending wants: a load that could not commit them left them for the // first commit this engine makes, and the repair commit is one. -func (h repairHost) ObligationsLocked() ([]bucketindex.Want, []bucketindex.Entry) { - return slices.Concat(h.e.wants, h.e.pendingWants, h.e.adoptedWants), slices.Clone(h.e.holes) +func (h repairHost) ObligationsLocked() ([]bucketindex.Want, []bucketindex.Entry, []bucketindex.Claim) { + return slices.Concat(h.e.wants, h.e.pendingWants, h.e.adoptedWants), slices.Clone(h.e.holes), h.e.catalog } func (h repairHost) LiveLocked() ([]*part, []bucketindex.Entry) { return h.e.parts, h.e.foreign } diff --git a/internal/enginetest/enginetest.go b/internal/enginetest/enginetest.go index 3086b33..424706c 100644 --- a/internal/enginetest/enginetest.go +++ b/internal/enginetest/enginetest.go @@ -299,6 +299,8 @@ var suite = []struct { {"TenuresAllocateDisjointBlocks", tenuresAllocateDisjointBlocks}, {"LapsedClaimCommitsNothing", lapsedClaimCommitsNothing}, {"WritingTermSurvivesTheNextTenure", writingTermSurvivesTheNextTenure}, + {"SplitLineageOutlivesItsMembers", splitLineageOutlivesItsMembers}, + {"NestedWantRepairedThroughTheCatalog", nestedWantRepairedThroughTheCatalog}, } // Run runs every suite test against k, each as /. diff --git a/internal/enginetest/nestedrepair.go b/internal/enginetest/nestedrepair.go new file mode 100644 index 0000000..70576a5 --- /dev/null +++ b/internal/enginetest/nestedrepair.go @@ -0,0 +1,72 @@ +package enginetest + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oteldb/storage/backend" + "github.com/oteldb/storage/backend/bucketindex" +) + +// nestedWantRepairedThroughTheCatalog is the repair half of the nested false hole. This node's index +// owes an inner split member that carries only the inner claim, and relates it to its outer ancestry +// through the catalog alone: the outer group is retired. The peer's only copy of those rows is a +// higher-level successor of the outer ancestry, which the fetcher returns. The repair pass must +// judge that successor with the catalog, admit it, and discharge the want — not drop the unit and +// leave the want to earn a hole. +func nestedWantRepairedThroughTheCatalog(t *testing.T, k Kind) { + t.Helper() + + ctx := context.Background() + be, peer := backend.Memory(), backend.Memory() + + outer := bucketindex.Claim{Blocks: bucketindex.Blocks(101, 102), Group: bucketindex.Blocks(110, 111, 112)} + inner := bucketindex.Claim{Blocks: bucketindex.Blocks(107, 112), Group: bucketindex.Blocks(120, 121, 122)} + lostRow := api(100, 1) + + // The lost member's rows, flushed here and then taken out of the index into a want. + w := k.open(t, be) + lost := k.flushEach(t, w, be, lostRow)[0] + lostPrefix := k.Prefix + "/" + lost + + key := k.indexKey() + ix, version, err := bucketindex.LoadVersioned(ctx, be, key) + require.NoError(t, err) + + ent := ix.Entries[0] + require.True(t, ix.Remove(lostPrefix)) + ent.Blocks, ent.Claim, ent.Level = bucketindex.Blocks(120), inner, 2 + ix.RecordWant(bucketindex.WantOf(ent, ix.Generation)) + ix.Catalog = []bucketindex.Claim{outer} + _, err = ix.Save(ctx, be, key, version) + require.NoError(t, err) + dropObjects(t, be, lostPrefix+"/") + + // The peer's successor holds the same rows under a part of its own. + p := k.open(t, peer) + succ := k.Prefix + "/" + k.flushEach(t, p, peer, lostRow)[0] + + fetcher := NewFetcher(func(bucketindex.Want) (bucketindex.Entry, bucketindex.WantOutcome, error) { + CopyObjects(t, peer, be, succ+"/") + + return bucketindex.Entry{ + Prefix: succ, MinTime: lostRow.Ts, MaxTime: lostRow.Ts, + Blocks: bucketindex.Blocks(101, 102, 107), Level: 3, + }, bucketindex.WantSatisfied, nil + }) + + r := k.openRepair(t, be, fetcher) + require.NoError(t, r.LoadParts(ctx)) + require.Equal(t, []string{lostPrefix}, wantPrefixes(k.loadIndex(t, be).Wanted)) + + require.NoError(t, r.ForceMerge(ctx)) + + got := k.loadIndex(t, be) + assert.Empty(t, got.Wanted, "the successor discharges the want through the outer claim") + assert.Zero(t, got.LostParts) + assert.Positive(t, r.RepairStats().Fetched) + assert.Equal(t, []Row{lostRow}, sortedRows(t, r)) +} diff --git a/internal/enginetest/splitlineage.go b/internal/enginetest/splitlineage.go index 0d2ea63..0586f21 100644 --- a/internal/enginetest/splitlineage.go +++ b/internal/enginetest/splitlineage.go @@ -118,6 +118,43 @@ func splitOfSeveralInputsKeepsEveryLineage(t *testing.T, k Kind) { assert.Subset(t, storedRows(t, e, streamNames(splitInputs)...), want) } +// splitLineageOutlivesItsMembers: a split group's claim is recorded in the index's lineage catalog by +// the commit that publishes the group, and stays there once every member has left the index and no +// entry carries the claim any more — which is what lets a want for a member of a group split again be +// answered by a successor of the outer ancestry. +func splitLineageOutlivesItsMembers(t *testing.T, k Kind) { + t.Helper() + + ctx := context.Background() + be := backend.Memory() + inputs := k.flushSplitInputs(t, be) + + _, fragments := k.splitAll(t, be, inputs) + claim := fragments[0].Claim + require.True(t, claim.Valid()) + + requireInCatalog := func(when string) { + t.Helper() + + ix := k.loadIndex(t, be) + assert.Truef(t, slices.ContainsFunc(ix.Catalog, claim.Equal), "%s: the group's claim is in the catalog", when) + } + + requireInCatalog("published") + + e := k.open(t, be) + require.NoError(t, e.LoadParts(ctx)) + require.NoError(t, e.Merge(ctx, 1<<62), "retention past every row drops the members whole") + + ix := k.loadIndex(t, be) + require.Empty(t, ix.Entries) + requireInCatalog("after every member left the index") + + e.Append(t, api(1, 1)) + require.NoError(t, e.Flush(ctx)) + requireInCatalog("after a later commit") +} + // splitOfSeveralInputsRepairsFromPeer is the loss the joint claim prevents: one replica loses a // straddler the other merged into day fragments. The peer's index must answer the want, and repair // must bring every lost row back rather than acknowledge a hole. diff --git a/internal/repair/catalogprune_test.go b/internal/repair/catalogprune_test.go new file mode 100644 index 0000000..9cce8ac --- /dev/null +++ b/internal/repair/catalogprune_test.go @@ -0,0 +1,82 @@ +package repair + +import ( + "context" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/oteldb/storage/backend/bucketindex" +) + +// catalogOnlyOuter is {1,2} merged at level 1, split at level 2 into {10,11}, and each member split +// again at level 3, so the outer claim is on no entry, only in the catalog. +func catalogOnlyOuter() (outer bucketindex.Claim, fragments []bucketindex.Entry, catalog []bucketindex.Claim) { + outer = bucketindex.Claim{Blocks: bucketindex.Blocks(1, 2), Group: bucketindex.Blocks(10, 11)} + inner10 := group("g", 20, 2, bucketindex.Blocks(10)) + inner11 := group("h", 22, 2, bucketindex.Blocks(11)) + fragments = slices.Concat(inner10, inner11) + + for i := range fragments { + fragments[i].Level = 3 + } + + return outer, fragments, bucketindex.MergeLineage([]bucketindex.Claim{outer, inner10[0].Claim, inner11[0].Claim}) +} + +// TestAdmitRetiresAncestorOfCatalogOnlyGroup is a node still holding a divergent copy of block 1 +// while repair fetches the four inner fragments for the lost {1,2}. Together they hold every row of +// {1,2}, so the copy is retired and the fragments commit, instead of being refused for overlapping +// it forever. +func TestAdmitRetiresAncestorOfCatalogOnlyGroup(t *testing.T) { + t.Parallel() + + _, fragments, catalog := catalogOnlyOuter() + live := []bucketindex.Entry{{Prefix: "anc", Blocks: bucketindex.Blocks(1)}} + lost := bucketindex.Want{Prefix: "lost", Blocks: bucketindex.Blocks(1, 2), Level: 1, MinTime: 1, MaxTime: 2} + + u := make(Unit, 0, len(fragments)) + u = append(u, Result{Target: Target{Want: lost}, Entry: fragments[0]}) + + for _, f := range fragments[1:] { + u = append(u, Result{Target: Target{Want: bucketindex.Want{Blocks: f.Blocks}, Member: true}, Entry: f}) + } + + var stats bucketindex.RepairStats + + got, retired := Admit(context.Background(), live, catalog, []Unit{u}, func(*Result) error { return nil }, &stats) + + assert.Equal(t, fragments, got, "the fragments commit") + assert.Equal(t, map[string]struct{}{"anc": {}}, retired, "the complete outer group retires the copy") + assert.Zero(t, stats.Failed) +} + +// TestRunMemberBesideDivergentAncestor is a lost outer member, block 10, owed by a node that still +// holds a divergent copy of block 1. Its inner fragments alone would duplicate rows of that copy +// without retiring it, and commit would refuse them forever; so repair completes the whole outer +// group, through member 11's own split, and the commit retires the copy. +func TestRunMemberBesideDivergentAncestor(t *testing.T) { + t.Parallel() + + outer, fragments, catalog := catalogOnlyOuter() + live := []bucketindex.Entry{{Prefix: "anc", Blocks: bucketindex.Blocks(1)}} + p := &peer{ix: bucketindex.Index{Entries: fragments, Catalog: catalog}} + lost := bucketindex.Want{ + Prefix: "m10", Blocks: bucketindex.Blocks(10), Claim: outer, Level: 2, MinTime: 1, MaxTime: 2, + } + + plan := Pass{Fetcher: p, Catalog: catalog}.Run(context.Background(), live, []bucketindex.Want{lost}, nil) + + require.Len(t, plan.Units, 1) + assert.ElementsMatch(t, []string{"g00", "g01", "h00", "h01"}, prefixes(plan.Units[0])) + + var stats bucketindex.RepairStats + + got, retired := Admit(context.Background(), live, catalog, plan.Units, func(*Result) error { return nil }, &stats) + + assert.ElementsMatch(t, fragments, got) + assert.Equal(t, map[string]struct{}{"anc": {}}, retired) + assert.Zero(t, stats.Failed) +} diff --git a/internal/repair/commit.go b/internal/repair/commit.go index b592c64..cafed95 100644 --- a/internal/repair/commit.go +++ b/internal/repair/commit.go @@ -25,7 +25,8 @@ import ( // representations of some rows live is dropped. Either can change what the rest may commit, so // judgement and pruning repeat until neither drops a unit. func Admit( - ctx context.Context, live []bucketindex.Entry, units []Unit, open func(*Result) error, stats *bucketindex.RepairStats, + ctx context.Context, live []bucketindex.Entry, catalog []bucketindex.Claim, units []Unit, + open func(*Result) error, stats *bucketindex.RepairStats, ) ([]bucketindex.Entry, map[string]struct{}) { have := make(map[string]struct{}, len(live)) for i := range live { @@ -45,9 +46,9 @@ func Admit( var p pruned for { - in = settle(live, units, added, openErr, in) + in = settle(live, catalog, units, added, openErr, in) - p = prune(live, units, added, openErr, in) + p = prune(live, catalog, units, added, openErr, in) if len(p.drop) == 0 { break } @@ -152,7 +153,8 @@ func openAll( // settle returns which of the units in the commit takes: every unit is judged against live and every // other unit still in, all at once, until no verdict changes. A unit once out stays out. func settle( - live []bucketindex.Entry, units []Unit, added [][]bucketindex.Entry, openErr map[string]error, in []bool, + live []bucketindex.Entry, catalog []bucketindex.Claim, units []Unit, added [][]bucketindex.Entry, + openErr map[string]error, in []bool, ) []bool { for { verdict := make([]bool, len(units)) @@ -169,7 +171,7 @@ func settle( } } - verdict[k] = admissible(base, u, added[k], openErr) + verdict[k] = admissible(base, catalog, u, added[k], openErr) } if slices.Equal(verdict, in) { @@ -182,8 +184,10 @@ func settle( // admissible reports whether u may be committed over base with its opened parts added: every part // that would not open was fetched for something the commit covers anyway, and u is [committable]. -func admissible(base []bucketindex.Entry, u Unit, added []bucketindex.Entry, openErr map[string]error) bool { - all := &bucketindex.Index{Entries: slices.Concat(base, added)} +func admissible( + base []bucketindex.Entry, catalog []bucketindex.Claim, u Unit, added []bucketindex.Entry, openErr map[string]error, +) bool { + all := &bucketindex.Index{Entries: slices.Concat(base, added), Catalog: catalog} for i := range u { if openErr[u[i].Entry.Prefix] == nil { @@ -195,7 +199,7 @@ func admissible(base []bucketindex.Entry, u Unit, added []bucketindex.Entry, ope } } - return committable(base, u[0].Want, added) + return committable(base, u[0].Want, added, catalog) } // ConfirmLost advances evidence, the per-want count of consecutive definitive-absence conclusions, diff --git a/internal/repair/engine.go b/internal/repair/engine.go index e9a6bbf..594fce1 100644 --- a/internal/repair/engine.go +++ b/internal/repair/engine.go @@ -19,8 +19,10 @@ type Host[P any] interface { // Locks are the engine's state lock and its flush lock. Locks() (state, flush sync.Locker) - // ObligationsLocked are the wants and holes a pass services. - ObligationsLocked() ([]bucketindex.Want, []bucketindex.Entry) + // ObligationsLocked are the wants and holes a pass services, and the lineage catalog + // ([bucketindex.Index.Catalog]) they are judged by: what relates a successor a peer found through + // an outer claim to the want it answers. + ObligationsLocked() ([]bucketindex.Want, []bucketindex.Entry, []bucketindex.Claim) // LiveLocked is the engine's own live part set, and the entries it adopted from a rival writer. LiveLocked() ([]P, []bucketindex.Entry) // Identity is p's index identity. @@ -129,7 +131,7 @@ func Drive[P any](ctx context.Context, s *State, cfg Config, h Host[P]) { state, _ := h.Locks() state.Lock() - wants, holes := h.ObligationsLocked() + wants, holes, catalog := h.ObligationsLocked() live := entriesLocked(h) state.Unlock() @@ -142,6 +144,7 @@ func Drive[P any](ctx context.Context, s *State, cfg Config, h Host[P]) { opened := make(map[string]P) pass := Pass{ Fetcher: cfg.Fetcher, + Catalog: catalog, Prefix: cfg.Prefix, Tried: s.tried, Hold: func(ctx context.Context, prefix string) bool { @@ -211,7 +214,9 @@ func publish[P any]( state.Lock() - admitted, superseded := Admit(ctx, entriesLocked(h), plan.Units, func(r *Result) error { + _, _, catalog := h.ObligationsLocked() + + admitted, superseded := Admit(ctx, entriesLocked(h), catalog, plan.Units, func(r *Result) error { if r.Held { return nil } diff --git a/internal/repair/engine_test.go b/internal/repair/engine_test.go index 45ce8ef..5e1394d 100644 --- a/internal/repair/engine_test.go +++ b/internal/repair/engine_test.go @@ -33,7 +33,9 @@ type host struct { func (h *host) Locks() (state, flush sync.Locker) { return &h.mu, &h.flush } -func (h *host) ObligationsLocked() ([]bucketindex.Want, []bucketindex.Entry) { return h.wants, h.holes } +func (h *host) ObligationsLocked() ([]bucketindex.Want, []bucketindex.Entry, []bucketindex.Claim) { + return h.wants, h.holes, nil +} func (h *host) LiveLocked() ([]*fakePart, []bucketindex.Entry) { return h.parts, nil } diff --git a/internal/repair/fairness_test.go b/internal/repair/fairness_test.go index e52ce97..ff6ac4a 100644 --- a/internal/repair/fairness_test.go +++ b/internal/repair/fairness_test.go @@ -23,7 +23,7 @@ func TestAdmitIsOrderIndependent(t *testing.T) { for _, units := range [][]Unit{{member, merged}, {merged, member}} { var stats bucketindex.RepairStats - got, _ := Admit(context.Background(), nil, units, func(*Result) error { return nil }, &stats) + got, _ := Admit(context.Background(), nil, nil, units, func(*Result) error { return nil }, &stats) assert.Equal(t, []bucketindex.Entry{successor}, got, "the member's rows are inside the successor's ancestry, so it waits for its group") diff --git a/internal/repair/group.go b/internal/repair/group.go index 5cfa8eb..31101bf 100644 --- a/internal/repair/group.go +++ b/internal/repair/group.go @@ -8,14 +8,16 @@ import ( // committable reports whether added may be committed over base on behalf of want: together they // answer it, and every group [groupsNeeded] names is complete. -func committable(base []bucketindex.Entry, want bucketindex.Want, added []bucketindex.Entry) bool { - all := (&bucketindex.Index{Entries: slices.Concat(base, added)}) +func committable( + base []bucketindex.Entry, want bucketindex.Want, added []bucketindex.Entry, catalog []bucketindex.Claim, +) bool { + all := &bucketindex.Index{Entries: slices.Concat(base, added), Catalog: catalog} if _, ok := all.Satisfying(want); !ok { return false } held := all.Covered() - for _, c := range groupsNeeded(base, want, added) { + for _, c := range groupsNeeded(base, want, added, catalog) { if !held.Contains(c.Group) { return false } @@ -24,29 +26,60 @@ func committable(base []bucketindex.Entry, want bucketindex.Want, added []bucket return true } -// groupsNeeded is the split groups among added whose members must all be present before added may -// be committed over base. While base and added do not yet answer want, that is every group added -// carries, since one of them is what answers it. Once they do, it is only the groups whose claimed -// ancestry overlaps what base holds: a lone member of one duplicates rows base already has, and only -// the complete group retires them. A member of any other group holds rows base lacks, and commits -// alone. -func groupsNeeded(base []bucketindex.Entry, want bucketindex.Want, added []bucketindex.Entry) []bucketindex.Claim { - _, answered := (&bucketindex.Index{Entries: slices.Concat(base, added)}).Satisfying(want) - - var held bucketindex.Interval - if answered { - held = (&bucketindex.Index{Entries: base}).Covered() +// groupsNeeded is the split groups whose members must all be present before added may be committed +// over base. While base and added do not yet answer want, that is every group added carries, since +// one of them is what answers it. Once they do, it is the groups added's rows derive through whose +// claimed ancestry overlaps what base holds: a lone member of one duplicates rows base already has, +// and only the complete group retires them. Such a group's members split again are needed whole as +// well, since the group is complete only once they are. A member of any other group holds rows base +// lacks, and commits alone. +func groupsNeeded( + base []bucketindex.Entry, want bucketindex.Want, added []bucketindex.Entry, catalog []bucketindex.Claim, +) []bucketindex.Claim { + all := slices.Concat(base, added) + + _, answered := (&bucketindex.Index{Entries: all, Catalog: catalog}).Satisfying(want) + if !answered { + var out []bucketindex.Claim + + for i := range added { + if c := added[i].Claim; c.Valid() && !slices.ContainsFunc(out, c.Equal) { + out = append(out, c) + } + } + + return out } - var out []bucketindex.Claim + held := (&bucketindex.Index{Entries: base, Catalog: catalog}).Covered() + r := bucketindex.LineageOf(all).With(catalog...).Relater() + + var derived []bucketindex.Claim for i := range added { - c := added[i].Claim - if !c.Valid() || answered && !overlaps(held, c.Blocks) || slices.ContainsFunc(out, c.Equal) { - continue + for _, c := range r.Ancestors(added[i].Blocks) { + if !slices.ContainsFunc(derived, c.Equal) { + derived = append(derived, c) + } } + } + + var out []bucketindex.Claim - out = append(out, c) + for grew := true; grew; { + grew = false + + for _, c := range derived { + if slices.ContainsFunc(out, c.Equal) { + continue + } + + if overlaps(held, c.Blocks) || slices.ContainsFunc(out, func(n bucketindex.Claim) bool { + return overlaps(n.Group, c.Blocks) + }) { + out, grew = append(out, c), true + } + } } return out diff --git a/internal/repair/group_test.go b/internal/repair/group_test.go index 7351c91..5f67fb0 100644 --- a/internal/repair/group_test.go +++ b/internal/repair/group_test.go @@ -106,7 +106,7 @@ func TestSupersededResultIsNotPublished(t *testing.T) { var stats bucketindex.RepairStats - got, _ := Admit(context.Background(), nil, plan.Units, func(*Result) error { return nil }, &stats) + got, _ := Admit(context.Background(), nil, nil, plan.Units, func(*Result) error { return nil }, &stats) assert.Equal(t, []bucketindex.Entry{merged, members[2]}, got, "f00's rows are inside t") assert.Equal(t, int64(2), stats.Fetched) @@ -124,7 +124,7 @@ func TestSharedGroupCountsEachPartOnce(t *testing.T) { var stats bucketindex.RepairStats - got, _ := Admit(context.Background(), nil, plan.Units, func(*Result) error { return nil }, &stats) + got, _ := Admit(context.Background(), nil, nil, plan.Units, func(*Result) error { return nil }, &stats) assert.Len(t, got, 3) assert.Equal(t, bucketindex.RepairStats{Fetched: 3}, stats, "three parts published, three fetched") diff --git a/internal/repair/prune.go b/internal/repair/prune.go index 4e4767f..58def21 100644 --- a/internal/repair/prune.go +++ b/internal/repair/prune.go @@ -22,7 +22,8 @@ type pruned struct { // judged over the lineage of everything the commit saw: a member left out may be the only record // relating what remains. func prune( - live []bucketindex.Entry, units []Unit, added [][]bucketindex.Entry, openErr map[string]error, in []bool, + live []bucketindex.Entry, catalog []bucketindex.Claim, units []Unit, added [][]bucketindex.Entry, + openErr map[string]error, in []bool, ) pruned { var admitted []bucketindex.Entry @@ -40,7 +41,7 @@ func prune( } all := slices.Concat(live, admitted) - lineage := bucketindex.LineageOf(all) + lineage := bucketindex.LineageOf(all).With(catalog...).Relater() subsumed := lineage.Subsumed(admitted, all) published := slices.DeleteFunc(admitted, func(e bucketindex.Entry) bool { _, ok := subsumed[e.Prefix] @@ -81,7 +82,7 @@ func prune( return slices.ContainsFunc(own[k], func(o bucketindex.Entry) bool { return o.Prefix == e.Prefix }) }) - if !admissible(base, units[k], own[k], openErr) { + if !admissible(base, catalog, units[k], own[k], openErr) { out.drop = append(out.drop, k) } } @@ -110,7 +111,7 @@ type unitOverlaps struct { // representation is already in place; failing that, every one overlapping the first overlapping // unit by target. func overlapping( - lineage bucketindex.Lineage, live []bucketindex.Entry, units []Unit, own [][]bucketindex.Entry, + lineage *bucketindex.Relater, live []bucketindex.Entry, units []Unit, own [][]bucketindex.Entry, final []bucketindex.Entry, in []bool, ) []int { o := overlapsOf(lineage, live, own, final, in) @@ -149,7 +150,7 @@ func overlapping( } func overlapsOf( - lineage bucketindex.Lineage, live []bucketindex.Entry, own [][]bucketindex.Entry, final []bucketindex.Entry, in []bool, + lineage *bucketindex.Relater, live []bucketindex.Entry, own [][]bucketindex.Entry, final []bucketindex.Entry, in []bool, ) unitOverlaps { owners := make(map[string][]int) diff --git a/internal/repair/repair.go b/internal/repair/repair.go index e2c3408..b52ae14 100644 --- a/internal/repair/repair.go +++ b/internal/repair/repair.go @@ -52,6 +52,10 @@ type Unit []Result // Pass is one engine's repair pass. type Pass struct { Fetcher bucketindex.PartFetcher + // Catalog is the engine's lineage catalog, which every identity question the pass asks is + // answered with: a successor a peer found through an outer claim satisfies the want only + // through that claim. + Catalog []bucketindex.Claim // Hold opens the part at prefix from this node's own backend and reports whether it is there. Hold func(ctx context.Context, prefix string) bool // Prefix names the engine in logs. @@ -85,11 +89,12 @@ func (p Pass) Run( ) Plan { var plan Plan - ix := bucketindex.Index{Entries: entries} + ix := bucketindex.Index{Entries: entries, Catalog: p.Catalog} + rel := ix.Relations() pending := make([]Target, 0, len(wants)+len(holes)) for i := range wants { - if _, ok := ix.Satisfying(wants[i]); ok { + if _, ok := rel.Satisfying(wants[i]); ok { plan.Stats.Local++ continue @@ -100,7 +105,7 @@ func (p Pass) Run( for i := range holes { w := bucketindex.WantOf(holes[i], bucketindex.Generation{}) - if _, ok := ix.Satisfying(w); ok { + if _, ok := rel.Satisfying(w); ok { plan.Stats.Revoked++ continue @@ -341,17 +346,26 @@ func (u *unit) entries(ix *bucketindex.Index) []bucketindex.Entry { // rows the index already holds ([groupsNeeded]). func (u *unit) missing(ix *bucketindex.Index) []bucketindex.Block { entries := u.entries(ix) - held := (&bucketindex.Index{Entries: entries}).Covered() + all := &bucketindex.Index{Entries: entries, Catalog: ix.Catalog} + held := all.Covered() out := make(map[bucketindex.Block]struct{}) - for _, c := range groupsNeeded(ix.Entries, u.results[0].Want, entries[len(ix.Entries):]) { - c.Group.Each(func(b bucketindex.Block) bool { - if _, asked := u.asked[b]; !asked && !held.Contains(bucketindex.Single(b)) { - out[b] = struct{}{} - } + ask := func(b bucketindex.Block) bool { + if _, asked := u.asked[b]; !asked && !held.Contains(bucketindex.Single(b)) { + out[b] = struct{}{} + } + + return true + } + + for _, c := range groupsNeeded(ix.Entries, u.results[0].Want, entries[len(ix.Entries):], ix.Catalog) { + c.Group.Each(ask) + } - return true - }) + // A want answered jointly across groups split again: the members every group derived from its + // rows still lacks, the outer ones included until a peer's answer names their own split. + for _, b := range all.Missing(u.results[0].Want) { + ask(b) } return slices.SortedFunc(maps.Keys(out), bucketindex.Block.Compare) @@ -381,12 +395,13 @@ func (u *unit) take(a attempt) { // answer satisfies it, because a member's absence says nothing about a part that exists. func (u *unit) whole(ix *bucketindex.Index) bool { entries := u.entries(ix) - if committable(ix.Entries, u.results[0].Want, entries[len(ix.Entries):]) { + if committable(ix.Entries, u.results[0].Want, entries[len(ix.Entries):], ix.Catalog) { return true } target := u.results[0] - if _, own := (&bucketindex.Index{Entries: []bucketindex.Entry{target.Entry}}).Satisfying(target.Want); own || + own := &bucketindex.Index{Entries: []bucketindex.Entry{target.Entry}, Catalog: ix.Catalog} + if _, ok := own.Satisfying(target.Want); ok || u.worst == outcomeNone { u.worst = outcomeFailed } diff --git a/internal/repair/repair_test.go b/internal/repair/repair_test.go index 669aebd..8d64958 100644 --- a/internal/repair/repair_test.go +++ b/internal/repair/repair_test.go @@ -360,7 +360,7 @@ func TestAdmit(t *testing.T) { var stats bucketindex.RepairStats open, opened := opener() - got, _ := Admit(ctx, nil, []Unit{unit("a", false, false, members...)}, open, &stats) + got, _ := Admit(ctx, nil, nil, []Unit{unit("a", false, false, members...)}, open, &stats) assert.Equal(t, members, got) assert.Equal(t, []string{"f00", "f01", "f02"}, *opened) @@ -374,7 +374,7 @@ func TestAdmit(t *testing.T) { plain := bucketindex.Entry{Prefix: "b", Blocks: bucketindex.Blocks(5)} open, _ := opener("f02") - got, _ := Admit(ctx, nil, []Unit{ + got, _ := Admit(ctx, nil, nil, []Unit{ unit("a", false, false, members...), unit("b", false, false, plain), }, open, &stats) @@ -390,7 +390,7 @@ func TestAdmit(t *testing.T) { var stats bucketindex.RepairStats open, _ := opener() - got, _ := Admit(ctx, nil, []Unit{unit("a", false, false, members[:2]...)}, open, &stats) + got, _ := Admit(ctx, nil, nil, []Unit{unit("a", false, false, members[:2]...)}, open, &stats) assert.Empty(t, got) assert.Equal(t, int64(2), stats.Failed) @@ -406,7 +406,7 @@ func TestAdmit(t *testing.T) { phantom := bucketindex.Entry{Prefix: "x", Blocks: bucketindex.Blocks(2)} open, opened := opener("x") - got, retired := Admit(ctx, live, []Unit{ + got, retired := Admit(ctx, live, nil, []Unit{ unit("a", false, false, members...), unit("b", false, true, succ), unit("c", true, false, phantom), @@ -447,7 +447,7 @@ func TestAdmit(t *testing.T) { var stats bucketindex.RepairStats open, _ := opener() - got, _ := Admit(ctx, tc.live, order, open, &stats) + got, _ := Admit(ctx, tc.live, nil, order, open, &stats) assert.ElementsMatch(t, tc.want, got) assert.Equal(t, int64(1), stats.Failed, "the part left out is counted") @@ -463,7 +463,7 @@ func TestAdmit(t *testing.T) { rival := bucketindex.Entry{Prefix: "r", Blocks: bucketindex.Range(0, 1, 2), Level: 1} open, _ := opener() - got, _ := Admit(ctx, members[:1], []Unit{ + got, _ := Admit(ctx, members[:1], nil, []Unit{ unit("a", false, false, members...), unit("b", false, false, rival), }, open, &stats) @@ -501,7 +501,7 @@ func TestAdmit(t *testing.T) { } open, _ := opener() - got, retired := Admit(ctx, inner, []Unit{ + got, retired := Admit(ctx, inner, nil, []Unit{ unit("a", false, false, members[:2]...), unit("b", false, false, succ), }, open, &stats) diff --git a/recordengine/engine.go b/recordengine/engine.go index 67b13b4..30a265c 100644 --- a/recordengine/engine.go +++ b/recordengine/engine.go @@ -262,6 +262,11 @@ type Engine struct { // established is the ownership term whose first commit has landed: until it equals the current // term, no commit of that tenure is allowed. See [Engine.establishTenureLocked]. established uint64 + // catalog is the lineage catalog this engine last committed or adopted + // ([bucketindex.Index.Catalog]): every split group's claim, kept after its members are gone. + catalog []bucketindex.Claim + // lineageWarned makes the warning that the catalog outgrew its target a once-per-engine one. + lineageWarned atomic.Bool // pendingHoles are the losses the next commit must acknowledge. Like pendingWants they are // held rather than applied on the spot, so a commit that never lands leaves the want // outstanding instead of half-discharged. diff --git a/recordengine/index.go b/recordengine/index.go index cbc05ec..f9a91da 100644 --- a/recordengine/index.go +++ b/recordengine/index.go @@ -105,6 +105,7 @@ func (e *Engine) commitOnceLocked(ctx context.Context) (bool, error) { e.pendingBlocks = nil e.holes, e.lostParts = ix.Holes(), ix.LostParts e.allocated = ix.AllocatedBlocks + e.catalog = ix.Catalog e.pendingHoles = nil return true, nil @@ -162,6 +163,7 @@ func (e *Engine) adoptIndexLocked(ctx context.Context) error { e.holes = mergeHoles(e.holes, ix.Holes()) e.lostParts = max(e.lostParts, ix.LostParts) e.allocated = bucketindex.MaxBlock(e.allocated, ix.AllocatedBlocks) + e.catalog = bucketindex.MergeLineage(e.catalog, ix.Catalog) e.foreign = foreignEntries(ix.Entries, e.indexed, e.removals, e.wants) e.openForeignLocked(ctx) @@ -317,7 +319,7 @@ func (e *Engine) nextIndexLocked(ctx context.Context) *bucketindex.Index { // as indexed, or the next commit would read it as a removal. ix.LostParts = e.lostParts - trimmed := bucketindex.TrimHoles(slices.Clone(e.holes), ix.Entries) + trimmed := bucketindex.TrimHoles(slices.Clone(e.holes), ix.Entries, e.catalog...) for i := range trimmed { ix.Add(trimmed[i]) live[trimmed[i].Prefix] = struct{}{} @@ -358,7 +360,15 @@ func (e *Engine) nextIndexLocked(ctx context.Context) *bucketindex.Index { // Committing a part is what discharges a want, so the trim runs against the entries this // commit publishes: a want naming a part the index holds again, or one a live part contains, // is repaired by the act of writing this index. - ix.Wanted = bucketindex.TrimWants(ix.Wanted, ix.Entries) + ix.Wanted = bucketindex.TrimWants(ix.Wanted, ix.Entries, e.catalog...) + + // The lineage of every group this commit publishes joins the catalog here, and reaches e.catalog + // only if the commit lands: a group run a lost attempt allocated may be handed to other parts. + ix.Catalog = slices.Clone(e.catalog) + if over := ix.RecordLineage(); over > 0 && e.lineageWarned.CompareAndSwap(false, true) { + zctx.From(ctx).Warn("lineage catalog holds more reachable groups than its target; none is aged out", + zap.String("prefix", e.cfg.Prefix), zap.Int("over", over), zap.Int("target", bucketindex.MaxLineage)) + } // Past the horizon the node needs the wholesale adoption cluster/partsync performs, not // part-by-part repair. The wants stay either way: each is the only record that its part is @@ -641,6 +651,7 @@ func (e *Engine) adoptLoadLocked(l *indexLoad) { e.foreign, e.foreignParts = nil, nil e.holes, e.lostParts, e.allocated = l.holes, l.ix.LostParts, l.ix.AllocatedBlocks + e.catalog = l.ix.Catalog // A part disappearing means identities may have died with it, which is what arms the identity // prune on a node that never merges (a replica adopting the owner's part set). diff --git a/recordengine/repair.go b/recordengine/repair.go index f41a5b0..bb19f1c 100644 --- a/recordengine/repair.go +++ b/recordengine/repair.go @@ -52,8 +52,8 @@ func (h repairHost) Locks() (state, flush sync.Locker) { return &h.e.mu, &h.e.fl // ObligationsLocked includes pending wants: a load that could not commit them left them for the // first commit this engine makes, and the repair commit is one. -func (h repairHost) ObligationsLocked() ([]bucketindex.Want, []bucketindex.Entry) { - return slices.Concat(h.e.wants, h.e.pendingWants, h.e.adoptedWants), slices.Clone(h.e.holes) +func (h repairHost) ObligationsLocked() ([]bucketindex.Want, []bucketindex.Entry, []bucketindex.Claim) { + return slices.Concat(h.e.wants, h.e.pendingWants, h.e.adoptedWants), slices.Clone(h.e.holes), h.e.catalog } func (h repairHost) LiveLocked() ([]*part, []bucketindex.Entry) { return h.e.parts, h.e.foreign }