diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go new file mode 100644 index 00000000..f74724fd --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog_branches_test.go @@ -0,0 +1,66 @@ +package index + +import ( + "context" + "testing" + "time" + + "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" +) + +// Design 47 G A-85, §291: the indexer keeps a catalog revision per watched branch, trunk. for the first and +// branch.. (the branch escaped) for another, each numbered by its own branch, and the verb revision answers the +// branch it is asked for; the event names the branch, escaped. +func TestACatalogRevisionIsKeptPerWatchedBranch(t *testing.T) { + ctx := context.Background() + ix := &Indexer{Revisions: newMemObjects(1 << 20), Now: func() time.Time { return time.Date(2026, 11, 1, 3, 0, 0, 0, time.UTC) }} + var said []meshcatalog.CatalogRevisionEvent + ix.SayCatalogRevision = func(_ context.Context, e meshcatalog.CatalogRevisionEvent) error { said = append(said, e); return nil } + reader := meshcatalog.CatalogEntry{Module: "reader", Repository: "novox/r", Commit: "c1", Invokes: []string{RevisionInvoke}, Manifest: map[string]any{}} + tip := func(commit string) map[string]meshcatalog.CatalogEntry { + e := reader + e.Commit = commit + return map[string]meshcatalog.CatalogEntry{"reader": e} + } + if err := ix.KeepBranchEntries(ctx, "main", 5, tip("c1")); err != nil { + t.Fatal(err) + } + if err := ix.KeepBranchEntries(ctx, "feature/x.y", 7, tip("c2")); err != nil { + t.Fatal(err) + } + if err := ix.KeepBranchEntries(ctx, "feature/x.y", 8, tip("c3")); err != nil { + t.Fatal(err) + } + names, _ := ix.Revisions.Names(ctx) + have := map[string]bool{} + for _, n := range names { + have[n] = true + } + for _, want := range []string{"trunk.1", "branch.feature/x=2ey.1", "branch.feature/x=2ey.2"} { + if !have[want] { + t.Errorf("the objects hold %v, want %s", names, want) + } + } + if len(said) != 3 || said[0].Branch != "main" || said[1].Branch != "feature/x=2ey" || said[2].Revision != 2 { + t.Errorf("said %+v, want main 1, then the escaped branch 1 and 2", said) + } + answer := func(branch string, revision int64) meshcatalog.RevisionAnswer { + a, err := Revision(ctx, ix.Revisions, "indexer", "reader", meshcatalog.RevisionInput{Branch: branch, Revision: revision}) + if err != nil { + t.Fatalf("revision of %q: %v", branch, err) + } + return a + } + if a := answer("main", 0); a.Revision.Revision != 1 || a.Entries[0].Commit != "c1" { + t.Errorf("main answered %+v", a) + } + if a := answer("feature/x=2ey", 0); a.Revision.Revision != 2 || a.Revision.Branch != "feature/x.y" || a.Entries[0].Commit != "c3" { + t.Errorf("the branch's newest answered %+v, want its revision 2 at c3", a.Revision) + } + if a := answer("feature/x=2ey", 1); a.Revision.Revision != 1 || a.Entries[0].Commit != "c2" { + t.Errorf("the branch's revision 1 answered %+v", a.Revision) + } + if a := answer("beta", 0); !a.NoneYet { + t.Errorf("a branch with no revision answered %+v, want none-yet", a) + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go b/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go index d27fa244..cea8ecec 100644 --- a/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go +++ b/modules/mesh-delivery-indexer/internal/index/catalog_revisions.go @@ -14,6 +14,7 @@ import ( "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" "git.novox.be/novox/mesh-delivery/sdk/graph" "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/process" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -46,15 +47,41 @@ type Objects interface { // EntryObject is the object name of the entry of that digest. func EntryObject(digest string) string { return "entry." + digest } -// RevisionObject is the object name of a revision: trunk., or candidate.. for a candidate, +// TrunkBranch is the first watched branch, whose revisions are kept as trunk. (design 47 G §291, A-85). +const TrunkBranch = "main" + +// isTrunkBranch says whether a branch's revisions are the trunk's: the first watched branch, or none named. +func isTrunkBranch(branch string) bool { return branch == "" || branch == TrunkBranch } + +// BranchObject is the object name of a revision of a watched branch other than the first: branch.., +// the branch escaped as a name (A-85), so a dot in it never reads as a part. +func BranchObject(branch string, revision int64) string { + return "branch." + keys.EscapeName(branch) + "." + strconv.FormatInt(revision, 10) +} + +// RevisionObject is the object name of a revision: trunk. for the first watched branch, +// branch.. for another watched branch (A-85), or candidate.. for a candidate, // which keeps its newest revision only. func RevisionObject(rev meshcatalog.CatalogRevision) string { if rev.Kind == "candidate" { return "candidate." + rev.Group + "." + rev.Heads } + if !isTrunkBranch(rev.Branch) { + return BranchObject(rev.Branch, rev.Revision) + } return "trunk." + strconv.FormatInt(rev.Revision, 10) } +// BranchRevisionOf is the revision number a branch object's name holds for that branch, and whether it is one. +func BranchRevisionOf(branch, name string) (int64, bool) { + n, ok := strings.CutPrefix(name, "branch."+keys.EscapeName(branch)+".") + if !ok { + return 0, false + } + r, err := strconv.ParseInt(n, 10, 64) + return r, err == nil +} + // TrunkRevisionOf is the revision number a trunk object's name holds, and whether it is one. func TrunkRevisionOf(name string) (int64, bool) { n, ok := strings.CutPrefix(name, "trunk.") @@ -128,26 +155,37 @@ func ReadRevision(ctx context.Context, o Objects, name string) (meshcatalog.Cata // newestTrunk is the newest trunk revision the bucket keeps, its list only; none is a zero revision and false. func newestTrunk(ctx context.Context, o Objects) (meshcatalog.CatalogRevision, bool, error) { + return newestOf(ctx, o, "") +} + +// newestOf is the newest revision of a watched branch the bucket keeps (the trunk's for the first watched branch or none +// named), its list only; none is a zero revision and false. +func newestOf(ctx context.Context, o Objects, branch string) (meshcatalog.CatalogRevision, bool, error) { names, err := o.Names(ctx) if err != nil { return meshcatalog.CatalogRevision{}, false, err } + number := TrunkRevisionOf + if !isTrunkBranch(branch) { + number = func(n string) (int64, bool) { return BranchRevisionOf(branch, n) } + } var newest int64 for _, n := range names { - if r, ok := TrunkRevisionOf(n); ok && r > newest { + if r, ok := number(n); ok && r > newest { newest = r } } if newest == 0 { return meshcatalog.CatalogRevision{}, false, nil } - data, err := o.Get(ctx, "trunk."+strconv.FormatInt(newest, 10)) + name := RevisionObject(meshcatalog.CatalogRevision{Branch: branch, Revision: newest}) + data, err := o.Get(ctx, name) if err != nil { return meshcatalog.CatalogRevision{}, false, err } var rev meshcatalog.CatalogRevision if err := json.Unmarshal(data, &rev); err != nil { - return rev, false, fmt.Errorf("trunk.%d: %w", newest, err) + return rev, false, fmt.Errorf("%s: %w", name, err) } return rev, true, nil } @@ -246,12 +284,26 @@ func (ix *Indexer) keepEntries(ctx context.Context, entries map[string]meshcatal if ix.catchingUp.Load() { return nil // the catch-up of a first start keeps its one revision at its end (CatchUp) } - ix.keepMu.Lock() - defer ix.keepMu.Unlock() ix.mu.Lock() src := int64(ix.source.rev) ix.mu.Unlock() - newest, any, err := newestTrunk(ctx, ix.Revisions) + return ix.keepBranchEntries(ctx, TrunkBranch, src, entries) +} + +// KeepBranchEntries keeps entries as the revision of a watched branch after that branch's newest (design 47 G §291, +// A-85): trunk. for the first watched branch, branch.. for another, numbered per branch, and +// says catalog-revision naming the branch. src is the branch's source revision the entries were taken in from. +func (ix *Indexer) KeepBranchEntries(ctx context.Context, branch string, src int64, entries map[string]meshcatalog.CatalogEntry) error { + return ix.keepBranchEntries(ctx, branch, src, entries) +} + +func (ix *Indexer) keepBranchEntries(ctx context.Context, branch string, src int64, entries map[string]meshcatalog.CatalogEntry) error { + if branch == "" { + branch = TrunkBranch + } + ix.keepMu.Lock() + defer ix.keepMu.Unlock() + newest, any, err := newestOf(ctx, ix.Revisions, branch) if errors.Is(err, process.ErrNotYet) { ix.logf("catalog graph: no revision is kept: its objects %s are not on the bus yet", CatalogRevisionsObjects) return nil @@ -261,7 +313,7 @@ func (ix *Indexer) keepEntries(ctx context.Context, entries map[string]meshcatal return fmt.Errorf("the newest catalog graph revision: %w", err) } at := ix.now().UTC() - rev, objects, err := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: newest.Revision + 1, Kind: "trunk", Source: src, At: &at}, entries) + rev, objects, err := CatalogRevisionOf(meshcatalog.CatalogRevision{Revision: newest.Revision + 1, Kind: "trunk", Branch: branch, Source: src, At: &at}, entries) if err != nil { return err } @@ -292,7 +344,7 @@ func (ix *Indexer) keepEntries(ctx context.Context, entries map[string]meshcatal ix.logf("catalog graph: revision %d of source revision %d kept, %d entries, %d changed", rev.Revision, src, len(rev.Entries), len(changed)) if ix.SayCatalogRevision != nil { if err := ix.SayCatalogRevision(ctx, meshcatalog.CatalogRevisionEvent{V: CatalogEntryLayout, Revision: rev.Revision, Kind: rev.Kind, - Source: src, At: &at, Changed: changed, Entries: int64(len(rev.Entries))}); err != nil { + Branch: keys.EscapeName(branch), Source: src, At: &at, Changed: changed, Entries: int64(len(rev.Entries))}); err != nil { ix.logf("catalog graph: revision %d is not said: %v", rev.Revision, err) } } diff --git a/modules/mesh-delivery-indexer/internal/index/revision_verb.go b/modules/mesh-delivery-indexer/internal/index/revision_verb.go index ffcf0d07..d2ecc045 100644 --- a/modules/mesh-delivery-indexer/internal/index/revision_verb.go +++ b/modules/mesh-delivery-indexer/internal/index/revision_verb.go @@ -5,11 +5,11 @@ import ( "encoding/json" "fmt" "slices" - "strconv" "strings" "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/keys" "git.novox.be/novox/mesh-sdk/go/views" ) @@ -53,6 +53,22 @@ func Revision(ctx context.Context, o Objects, holder, caller string, in meshcata if err := views.Refusal(meshcatalog.Seat+"."+meshcatalog.VerbRevision, RevisionCallers(trunkEntries, holder), caller); err != nil { return out, fmt.Errorf("%w (novox/hq ADR 0663 §2: a part reads it by declaring %s in invokes)", err, RevisionInvoke) } + // A watched branch other than the first keeps its own revisions (A-85); the first's are the trunk's. The branch is + // named as a key name is escaped, so it is read back to the branch's own name. + branch, err := keys.UnescapeName(in.Branch) + if err != nil && in.Branch != "" { + return out, fmt.Errorf("the branch %q is no escaped name: %w", in.Branch, err) + } + if !isTrunkBranch(branch) { + other, found, err := newestOf(ctx, o, branch) + if err != nil { + return out, fmt.Errorf("the catalog graph revisions of %s cannot be read: %w", branch, err) + } + if !found { + return meshcatalog.RevisionAnswer{NoneYet: true}, nil + } + newest = other + } name := RevisionObject(newest) switch { case in.Group != "" || in.Heads != "": @@ -61,10 +77,14 @@ func Revision(ctx context.Context, o Objects, holder, caller string, in meshcata } name = RevisionObject(meshcatalog.CatalogRevision{Kind: "candidate", Group: in.Group, Heads: in.Heads}) case in.Revision != 0 && in.Revision != newest.Revision: - name = "trunk." + strconv.FormatInt(in.Revision, 10) + name = RevisionObject(meshcatalog.CatalogRevision{Branch: branch, Revision: in.Revision}) } rev, entries := newest, trunkEntries - if name != RevisionObject(newest) { + if !isTrunkBranch(branch) && in.Group == "" && in.Heads == "" { + if rev, entries, err = ReadRevision(ctx, o, name); err != nil { + return out, err + } + } else if name != RevisionObject(newest) { if rev, entries, err = ReadRevision(ctx, o, name); err != nil { return out, err }