Files
mesh-delivery/modules/mesh-delivery-indexer/internal/index/candidates.go
T
jschoubben 677f94e901
mesh/delivery-group group fix/1279-consumers-from-now rejected: a member's own check failed
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 677f94e9012b merged into trunk 1325c8b452c8 beside mesh-controller#400@a0c971ce — fail: its merge-check.sh failed: --- FAIL: TestBridg…
mesh/delivery superseded: a newer head of the same pull request
Say which pull requests are open and drop every other candidate, so the candidates hold only open pull requests (issue 1279, ADR 0443)
2026-10-19 21:56:42 +02:00

231 lines
8.0 KiB
Go

package index
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// ErrConflict is what a Store says when a write found the key changed or created since it was read.
var ErrConflict = errors.New("the key changed since it was read")
// ErrUnreadable wraps what says a message cannot be indexed, ever: it is never tried again.
var ErrUnreadable = errors.New("unreadable message")
// Store is the candidates bucket: a compare-and-set key-value store.
type Store interface {
// Get is the key's value and revision; found is false when there is none.
Get(ctx context.Context, key string) (value []byte, revision uint64, found bool, err error)
// Create writes a key that must not exist (ErrConflict when it does).
Create(ctx context.Context, key string, value []byte) error
// Update writes the key if its revision is still revision (ErrConflict when not).
Update(ctx context.Context, key string, value []byte, revision uint64) error
// Delete removes the key if its revision is still revision (ErrConflict when not).
Delete(ctx context.Context, key string, revision uint64) error
// Keys is every key the store holds now; none is an empty list, not an error.
Keys(ctx context.Context) ([]string, error)
}
// trunkLike are the branches that are no group: a pull request from one is nobody's delivery group (the rule of
// mesh-catalog modules/mesh-delivery/cmd/mesh-delivery/holder.go:321).
var trunkLike = map[string]bool{"main": true, "master": true, "trunk": true, "": true}
// writeTries bounds the reads again after a conflict; a message that still conflicts is left to the server to deliver again.
const writeTries = 8
// Indexer writes candidates.<group> from the forge bridge's messages.
type Indexer struct {
Store Store // candidates
// Modules is the modules bucket; nil writes no fingerprints.
Modules Store
Builds *Builds
Logf func(format string, args ...any)
}
func (ix *Indexer) logf(format string, args ...any) {
if ix.Logf != nil {
ix.Logf(format, args...)
}
}
// Apply takes one message of the bridge's stream FORGE into the group of its branch. A pull message adds or moves the
// pull request, a closed one or a merged message takes it out; a group left with none is deleted. A change is written
// by compare-and-set on the revision read, and read again on a conflict. A lone pull request is a group of one. A
// merged message also writes the fingerprint of each module of its repository (modules.go).
func (ix *Indexer) Apply(ctx context.Context, f delivery.Forge) error {
if f.Kind == delivery.ForgeOpen {
return ix.applyOpen(ctx, f)
}
if f.Kind != delivery.ForgePull && f.Kind != delivery.ForgeMerged {
return nil
}
if f.Kind == delivery.ForgeMerged && ix.Modules != nil {
if err := ix.applyMerged(ctx, f); err != nil {
return err
}
}
if trunkLike[f.Branch] {
ix.logf("%s#%d: its branch %q is no delivery group, so no candidate", f.Repository, f.Number, f.Branch)
return nil
}
key, err := delivery.CandidatesKey(f.Branch)
if err != nil {
return fmt.Errorf("%w: %s#%d: %v", ErrUnreadable, f.Repository, f.Number, err)
}
return write(ctx, ix.Store, key, func(value []byte, found bool) ([]byte, bool, error) {
var cur *delivery.Candidate
if found {
c, err := delivery.DecodeCandidate(value)
if err != nil {
return nil, false, fmt.Errorf("candidates.%s holds what is not a candidate: %w", key, err)
}
cur = &c
}
next := ix.next(cur, f)
switch {
case next == nil && cur == nil, next != nil && cur != nil && delivery.SameCandidate(*cur, *next):
return nil, false, nil
case next == nil:
return nil, true, nil
}
data, err := delivery.EncodeCandidate(*next)
return data, false, err
})
}
// applyOpen takes an open message into the candidates (novox/hq ADR 0443 §5): every pull request it does not name is
// taken out of its group, and a group left with none is deleted. What the message names is left as it is. A pull request
// opened after the bridge read the forge is heard after this message and added again by its own.
func (ix *Indexer) applyOpen(ctx context.Context, f delivery.Forge) error {
named := make(map[delivery.OpenPull]bool, len(f.Open))
for _, p := range f.Open {
named[p] = true
}
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 {
return nil, false, fmt.Errorf("candidates.%s holds what is not a candidate: %w", key, err)
}
kept := slices.DeleteFunc(slices.Clone(c.Pulls), func(p delivery.CandidatePull) bool {
return !named[delivery.OpenPull{Repository: p.Repository, Number: p.Number}]
})
switch {
case len(kept) == len(c.Pulls):
return nil, false, nil
case len(kept) == 0:
ix.logf("%s: no pull request of the group is open, so no candidate", key)
return nil, true, nil
}
heads := make([]delivery.Head, len(kept))
for i, p := range kept {
heads[i] = p.Head
}
hash, err := delivery.HeadsHash(heads)
if err != nil {
return nil, false, fmt.Errorf("%w: candidates.%s: %v", ErrUnreadable, key, err)
}
c.Pulls, c.HeadsHash = kept, hash
data, err := delivery.EncodeCandidate(c)
return data, false, err
})
if err != nil {
return err
}
}
return nil
}
// write changes one key of a store by compare-and-set: compute is given the value read (found says there is one) and
// answers the new value, or del to remove the key, or neither for no change. A conflict reads again, up to writeTries.
func write(ctx context.Context, st Store, key string, compute func(value []byte, found bool) (next []byte, del bool, err error)) error {
for try := 0; try < writeTries; try++ {
value, revision, found, err := st.Get(ctx, key)
if err != nil {
return fmt.Errorf("reading %s: %w", key, err)
}
next, del, err := compute(value, found)
if err != nil {
return err
}
switch {
case next == nil && !del:
return nil
case del:
err = st.Delete(ctx, key, revision)
case !found:
err = st.Create(ctx, key, next)
default:
err = st.Update(ctx, key, next, revision)
}
if errors.Is(err, ErrConflict) {
continue
}
if err != nil {
return fmt.Errorf("writing %s: %w", key, err)
}
return nil
}
return fmt.Errorf("%s changed under every one of %d tries", key, writeTries)
}
// next is the group after the message; nil when the group is left with no pull request.
func (ix *Indexer) next(cur *delivery.Candidate, f delivery.Forge) *delivery.Candidate {
var pulls []delivery.CandidatePull
var base uint64
if cur != nil {
pulls, base = slices.Clone(cur.Pulls), cur.Base
}
at := slices.IndexFunc(pulls, func(p delivery.CandidatePull) bool { return p.Repository == f.Repository && p.Number == f.Number })
if f.Kind == delivery.ForgeMerged || f.Action == delivery.PullClosed {
if at < 0 {
return cur
}
pulls = slices.Delete(pulls, at, at+1)
} else {
entry := delivery.CandidatePull{Head: delivery.Head{Repository: f.Repository, Number: f.Number, Head: f.Head}, Modules: []string{}}
switch {
case f.DirsKnown:
for _, dir := range f.Dirs {
if m := ix.Builds.ModuleOf(f.Repository, dir); !slices.Contains(entry.Modules, m) {
entry.Modules = append(entry.Modules, m)
}
}
case at >= 0 && pulls[at].Head.Head == f.Head:
// Its directories are not known now, for the head the group already holds: what it knew stands.
entry.Modules = pulls[at].Modules
}
if at >= 0 {
pulls[at] = entry
} else {
pulls = append(pulls, entry)
}
}
if len(pulls) == 0 {
return nil
}
slices.SortFunc(pulls, func(a, b delivery.CandidatePull) int {
if c := strings.Compare(a.Repository, b.Repository); c != 0 {
return c
}
return a.Number - b.Number
})
heads := make([]delivery.Head, len(pulls))
for i, p := range pulls {
heads[i] = p.Head
}
hash, _ := delivery.HeadsHash(heads)
return &delivery.Candidate{V: delivery.Version, Group: f.Branch, Base: base, HeadsHash: hash, Pulls: pulls}
}