Files
mesh-delivery/modules/mesh-delivery-indexer/internal/index/previews.go
T
jschoubben fba98ee80f Indexer computes fingerprints alone, over the packages a module imports
The fingerprint, named tree and package versions move from mesh-sdk into the
indexer's internal/fingerprint (ADR 0593 §2). The indexer publishes each reached
mesh Go module's package digests from the forge's listing in its new packages
state, keeps the imports a done build reports, and writes the import form only
when an input differs from the build that reported them (§4), so the form change
costs no rebuild and a package merge moves only its importers (§3). Candidates
take the same form against the group's member heads (D1-D4).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:25:57 +01:00

337 lines
12 KiB
Go

package index
import (
"context"
"fmt"
"maps"
"reflect"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"mesh-delivery-indexer/internal/fingerprint"
)
// The merge previews the checker writes (mesh-delivery-checker_previews; novox/hq ADR 0484 clause 3, issue 1445): a pull
// request's head merged onto its trunk's head, with the tree, the manifest digest and the lock hashes of each module
// directory. From one the indexer gives the candidate's touched module its new-form fingerprint, by the rules a module of
// the trunk is given its own (Recompute), or says why it has none.
// The reasons a candidate's touched module has no fingerprint of the new form while a preview exists or is missing.
const (
// NonePreviewStale is for a module whose preview was made onto a trunk head that is not the trunk's head now.
NonePreviewStale = "the merge preview is out of date: the trunk moved since it was made"
// NonePreviewConflict opens the reason of a module whose pull request's head does not merge into the trunk; the files
// in conflict follow.
NonePreviewConflict = "does not merge: the head conflicts with the trunk in "
// NonePreviewNoModule is for a module whose directory the preview names no manifest for: mesh-sdk's words, which the
// assigner reads as the change removing the module (issue 1297).
NonePreviewNoModule = delivery.CandidateNoneNoModule
// NonePreviewNoManifest is for a module whose manifest at the merge the indexer does not hold.
NonePreviewNoManifest = "the manifest of the merge is not kept"
// NoneNoDirectory is for a module whose directory in its repository neither the modules bucket nor the preview tells.
NoneNoDirectory = "the module's directory in its repository is not known"
)
// PutPreview takes a change of the previews bucket in without writing any candidate: a put of a preview (key
// delivery.PreviewKey) or its delete. It answers whether the previews held changed. A key or a value that is not a
// preview is said in the log and left out.
func (ix *Indexer) PutPreview(key string, value []byte, deleted bool) bool {
if _, _, _, err := delivery.ParsePreviewKey(key); err != nil {
ix.logf("previews.%s is not a preview key, left out: %v", key, err)
return false
}
ix.mu.Lock()
defer ix.mu.Unlock()
if deleted {
if _, had := ix.previews[key]; !had {
return false
}
delete(ix.previews, key)
return true
}
pv, err := delivery.DecodePreview(value)
if err != nil {
ix.logf("previews.%s is not a preview, left out: %v", key, err)
return false
}
if ix.previews == nil {
ix.previews = map[string]delivery.Preview{}
}
if cur, had := ix.previews[key]; had && delivery.SamePreview(cur, pv) {
return false
}
ix.previews[key] = pv
return true
}
// ApplyPreview takes one change of the previews in (PutPreview) and, when the previews held changed, writes the
// candidates' fingerprints again (RecomputeCandidates).
func (ix *Indexer) ApplyPreview(ctx context.Context, key string, value []byte, deleted bool) error {
if !ix.PutPreview(key, value, deleted) {
return nil
}
return ix.RecomputeCandidates(ctx)
}
// RecomputeCandidates writes again every candidate whose pull requests' fingerprints, from the previews held, are no longer
// what it holds. A candidate is written only when it differs, by compare-and-set on the revision read.
func (ix *Indexer) RecomputeCandidates(ctx context.Context) error {
if ix.Store == nil {
return nil
}
ix.mu.Lock()
none := len(ix.previews) == 0
ix.mu.Unlock()
if none {
return nil // no preview held: every candidate has what the pull messages gave it
}
keys, err := ix.Store.Keys(ctx)
if err != nil {
return fmt.Errorf("listing the candidates: %w", err)
}
for _, key := range keys {
err := write(ctx, ix.Store, key, func(value []byte, found bool) ([]byte, bool, error) {
if !found {
return nil, false, nil
}
c, err := delivery.DecodeCandidate(value)
if err != nil {
ix.logf("candidates.%s holds what is not a candidate, left: %v", key, err)
return nil, false, nil
}
if !ix.withPreviews(ctx, &c) {
return nil, false, nil
}
data, err := delivery.EncodeCandidate(c)
return data, false, err
})
if err != nil {
return fmt.Errorf("candidates.%s: %w", key, err)
}
}
return nil
}
// previewOf is the preview of a head merged onto the trunk head the source heads name: found, or stale when only a preview
// onto another trunk head exists, or neither (found false, stale false) when the source is not known or none exists.
func (ix *Indexer) previewOf(heads map[string]string, repository, head string) (pv delivery.Preview, found, stale bool) {
ix.mu.Lock()
defer ix.mu.Unlock()
trunk := heads[repository]
if trunk == "" {
return delivery.Preview{}, false, false
}
if key, err := delivery.PreviewKey(repository, head, trunk); err == nil {
if pv, ok := ix.previews[key]; ok {
return pv, true, false
}
}
for _, pv := range ix.previews {
if pv.Repository == repository && pv.Head == head {
return delivery.Preview{}, false, true
}
}
return delivery.Preview{}, false, false
}
// withPreviews gives each touched module of the candidate's pull requests the fingerprint its preview gives it, or the
// reason it has none, and the candidate its fpset. It answers whether the candidate changed. A module of a pull request with
// no preview keeps what it had.
//
// The candidate's Base is the source revision whose trunk heads the previews were read onto (novox/hq ADR 0437 §5): written
// whenever the fingerprints are, so the builder merges a preview onto the trunk head its fingerprint was computed on (the head
// of the pull request's repository in that revision), never onto a module value's commit, which stays at the commit its
// fingerprint first appeared at.
func (ix *Indexer) withPreviews(ctx context.Context, c *delivery.Candidate) bool {
ix.mu.Lock()
rev, heads := ix.source.rev, ix.source.heads
ix.mu.Unlock()
changed, read := false, false
// A package one of the candidate's pull requests publishes is built at that pull request's head, every other at its
// latest published version (novox/hq ADR 0573 §4): a group's members build against the group's own package.
members := map[string]string{}
for _, p := range c.Pulls {
members[delivery.RepositoryName(p.Repository)] = p.Head.Head
}
var held map[string]delivery.Module
listed := false
for i := range c.Pulls {
p := &c.Pulls[i]
pv, found, stale := ix.previewOf(heads, p.Repository, p.Head.Head)
if !found && !stale {
continue
}
read = true
if !listed {
var err error
if held, err = ix.heldModules(ctx); err != nil {
ix.logf("the modules cannot be listed, so no touched module's directory or package version is known: %v", err)
}
listed = true
}
// A touched module's directory: the one the modules bucket holds of the repository, else the preview's directory
// whose manifest is the one the pull request gave the module (a module the pull request adds; ADR 0501 clause 6).
dirs := map[string]string{}
for name, m := range held {
if delivery.SameRepository(m.Repository, p.Repository) && m.Path != "" {
dirs[name] = m.Path
}
}
for _, m := range p.Modules {
if _, known := dirs[m]; known || p.Fps[m].Manifest == "" {
continue
}
for dir, pm := range pv.Modules {
if pm.Manifest == p.Fps[m].Manifest {
dirs[m] = dir
}
}
}
fps := maps.Clone(p.Fps)
for _, m := range p.Modules {
next := delivery.CandidateModule{Manifest: p.Fps[m].Manifest}
switch {
case stale:
next.None2 = NonePreviewStale
case len(pv.Conflicts) > 0:
next.None2 = NonePreviewConflict + strings.Join(pv.Conflicts, ", ")
default:
dir, known := dirs[m]
if !known {
next.None2 = NoneNoDirectory
break
}
next = ix.candidateModule(ctx, m, dir, pv, held, members)
}
if fps == nil {
fps = map[string]delivery.CandidateModule{}
}
if !reflect.DeepEqual(fps[m], next) {
fps[m] = next
changed = true
}
}
if changed || !maps.EqualFunc(fps, p.Fps, func(a, b delivery.CandidateModule) bool { return reflect.DeepEqual(a, b) }) {
p.Fps = fps
}
}
if changed {
c.FPSet = delivery.FingerprintSet(c.Pulls)
}
if read && rev != 0 && c.Base != rev && (changed || c.Base == 0) {
c.Base, changed = rev, true
}
return changed
}
// candidateModule is the fingerprint of module m, whose directory is dir, at the preview: the module's inputs from the
// merge (tree, manifest, locks), its bases' and the builder's fingerprints as the trunk's modules hold them, the toolchains
// the build modules agree on, and the version of each mesh package it uses (candidatePackages), by the rules Recompute
// applies to a module of the trunk.
func (ix *Indexer) candidateModule(ctx context.Context, m, dir string, pv delivery.Preview, held map[string]delivery.Module, members map[string]string) delivery.CandidateModule {
pm, ok := pv.Modules[dir]
if !ok && dir == "" {
pm, ok = pv.Modules[delivery.RootPath]
}
if !ok && dir == delivery.RootPath {
pm, ok = pv.Modules[""]
}
if !ok {
return delivery.CandidateModule{None2: NonePreviewNoModule}
}
out := delivery.CandidateModule{Manifest: pm.Manifest}
if ix.Manifests == nil {
out.None2 = NonePreviewNoManifest
return out
}
value, _, found, err := ix.Manifests.Get(ctx, pm.Manifest)
if err != nil || !found {
out.None2 = NonePreviewNoManifest
return out
}
kept, err := delivery.DecodeManifest(value)
if err != nil {
out.None2 = NonePreviewNoManifest
return out
}
// The preview carries the directory's raw inputs (its tree, the named paths' objects, the lock hashes): the indexer
// computes the named tree (ADR 0593 §2). The version line's packages are the trunk module's, unless an older build
// module still names them.
trunk := held[m]
packages := fingerprint.OtherPackages(pm.Packages, pv.Repository)
if len(pm.Packages) == 0 && trunk.Inputs2 != nil {
packages = trunk.Inputs2.Packages
}
in := inputsOfNamed(m, kept.JSON, pm.Tree, pm.Locks, pm.Paths, packages, ix.baseFP(ctx))
if in.Kept == nil {
out.None2 = in.None2
return out
}
if in.None2 != "" && in.None2 != delivery.ToolchainNoReport && in.None2 != None2Pending {
out.None2 = in.None2 // a context, or a base with no fingerprint
return out
}
tools := ix.agreed()
toolchains := map[string]delivery.Toolchain2{}
for _, artifact := range slices.Sorted(maps.Keys(in.Languages)) {
t := tools[in.Languages[artifact]]
if t.none != "" {
out.None2 = t.none
return out
}
toolchains[artifact] = t.tc
}
inputs := *in.Kept // no builder input (ADR 0530 §1)
var fp, none string
var versions delivery.PackageVersions
if trunk.Inputs2 != nil && len(trunk.Inputs2.Imports) > 0 {
inputs.Imports, inputs.ImportForm = trunk.Inputs2.Imports, trunk.Inputs2.ImportForm
}
// The import form against the group's own heads of the packages it publishes (ADR 0573 §4, ADR 0593 §2), as the trunk
// computes it once merged, the trunk's held fingerprint standing where nothing the build was built from changed.
published := ix.candidatePublished(ctx, members, held)
if len(inputs.Imports) > 0 && len(fingerprint.Resolve(pv.Repository, inputs.Imports, published).Missing) == 0 {
f := formOf(m, pv.Repository, &inputs, toolchains, trunk, held, published, nil)
fp, none, versions = f.fp, f.none, f.versions
} else {
inputs.Imports, inputs.ImportForm = nil, ""
versions = candidatePackages(inputs.Packages, held, members)
in := inputsFrom(m, inputs, toolchains)
if len(inputs.Packages) > 0 {
in.Packages = map[string]string{}
for _, p := range inputs.Packages {
in.Packages[p] = versions[p]
}
}
fp, none = fingerprint.Of(in)
}
if fp == "" {
out.None2 = none
return out
}
out.FP2, out.Packages = fp, versions
if len(inputs.Paths) > 0 {
out.Paths = inputs.Paths // the builder's fetch for the candidate names them (ADR 0499 clause 4)
}
return out
}
// candidatePackages is the version each package of a candidate's module is built against (novox/hq ADR 0573 §2, §4): the
// head of the candidate's pull request whose repository publishes it (members, by repository), else its latest published
// version, as the trunk's modules hold it. A package with neither is left out, and the fingerprint is then none.
func candidatePackages(packages []string, held map[string]delivery.Module, members map[string]string) delivery.PackageVersions {
versions, _ := fingerprint.HeldVersionsOf(packages, held)
for _, p := range packages {
repository, _, _ := delivery.PackageRepository(p)
if head := members[repository]; head != "" {
if versions == nil {
versions = delivery.PackageVersions{}
}
versions[p] = head
}
}
return versions
}