438 lines
15 KiB
Go
438 lines
15 KiB
Go
package index
|
|
|
|
// **A group try's candidate** (issue 1921; novox/hq ADR 0593 §7): a try of several repositories at exact commits has no pull
|
|
// request, so the forge bridge sends the indexer no message for it; yet the node-engine's compose-candidate and the checker's
|
|
// manifest validator read the indexer's candidate of a group. The indexer therefore watches the checker's state tries (the
|
|
// checker keeps it; this module reads it) and, for a try that is not narrowed to a level or to some tests, writes
|
|
// `candidates.<try id>`: one pull per member (number 0), its modules and manifests taken from the merge preview the try's
|
|
// fetch wrote, its manifests kept and judged with the seat definitions the members carry, as a take-in judges a revision's.
|
|
// The fingerprints come with the previews (RecomputeCandidates). The candidate is deleted when the try ends. `try-<digits>`
|
|
// is the reserved group name of a try: the bridge's open message never takes it out, and the checker never checks it.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
|
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
|
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
|
"git.novox.be/novox/mesh-sdk/go/keys"
|
|
)
|
|
|
|
// TryCandidateTools: the tools that read, drop and recover a try's candidate.
|
|
const (
|
|
TryCandidatesTool = "indexer_try_candidates"
|
|
DropTryCandidateTool = "indexer_drop_try_candidate"
|
|
RecoverTriesTool = "indexer_recover_tries"
|
|
// TryRecoveryState is the indexer's state of how the last recovery of the open tries went: the bucket
|
|
// mesh-delivery-indexer_try-recovery, one key (TryRecoveryKey).
|
|
TryRecoveryState = "try-recovery"
|
|
TryRecoveryBucket = Module + "_" + TryRecoveryState
|
|
TryRecoveryKey = "state"
|
|
)
|
|
|
|
// TryRecovery is the value of the try-recovery state: whether the candidates of the open tries were recovered at the
|
|
// indexer's start or by hand, and when it began to fail, why.
|
|
type TryRecovery struct {
|
|
V int `json:"v"`
|
|
Done bool `json:"done"`
|
|
Since time.Time `json:"since,omitzero"`
|
|
Why string `json:"why,omitempty"`
|
|
At time.Time `json:"at"`
|
|
}
|
|
|
|
// TryMember is one repository of a try: its exact commit and the trunk commit it is merged onto.
|
|
type TryMember struct{ Repository, Head, Trunk string }
|
|
|
|
// StartTry takes a whole try in, as the checker said it started (checkermsg.Started): its members at their commits are kept, and
|
|
// its candidate is written once every member's preview is held. The checker's state is never read (ADR 0596).
|
|
func (ix *Indexer) StartTry(ctx context.Context, id string, members []TryMember) error {
|
|
ix.tryMu.Lock()
|
|
defer ix.tryMu.Unlock()
|
|
ix.retryRecovery(ctx)
|
|
return ix.startTry(ctx, id, members)
|
|
}
|
|
|
|
// retryRecovery runs the recovery again on a try event when the last one failed: the failure stays said until one succeeds.
|
|
func (ix *Indexer) retryRecovery(ctx context.Context) {
|
|
if ix.recoveryFailed.Load() && ix.OpenTries != nil {
|
|
_ = ix.recoverNow(ctx)
|
|
}
|
|
}
|
|
|
|
func (ix *Indexer) startTry(ctx context.Context, id string, members []TryMember) error {
|
|
if !meshcatalogclient.IsTryGroup(id) || len(members) == 0 {
|
|
return fmt.Errorf("%q with %d members is no try to start", id, len(members))
|
|
}
|
|
ix.mu.Lock()
|
|
if ix.tries == nil {
|
|
ix.tries = map[string][]TryMember{}
|
|
}
|
|
ix.tries[id] = slices.Clone(members)
|
|
ix.mu.Unlock()
|
|
return ix.writeTryCandidate(ctx, id)
|
|
}
|
|
|
|
// EndTry takes a try out, as the checker said it ended (checkermsg.Ended), and deletes its candidate.
|
|
func (ix *Indexer) EndTry(ctx context.Context, id string) error {
|
|
ix.tryMu.Lock()
|
|
defer ix.tryMu.Unlock()
|
|
ix.retryRecovery(ctx)
|
|
return ix.endTry(ctx, id)
|
|
}
|
|
|
|
func (ix *Indexer) endTry(ctx context.Context, id string) error {
|
|
ix.mu.Lock()
|
|
delete(ix.tries, id)
|
|
ix.mu.Unlock()
|
|
if !meshcatalogclient.IsTryGroup(id) {
|
|
return nil
|
|
}
|
|
return ix.dropTryCandidate(ctx, id)
|
|
}
|
|
|
|
// RecoverTries is, at the indexer's start, what the events it missed would have said: the candidates of the tries that wait
|
|
// or run now are written, and the candidate of a try the checker no longer lists is deleted. The checker's tool lists them
|
|
// (OpenTries); its bucket is not read. Nothing to recover from when no part is wired to ask.
|
|
//
|
|
// A recovery that fails is said loudly and kept (the try-recovery state, read by indexer_try_candidates) until one succeeds:
|
|
// the events are still heard after it, and indexer_recover_tries runs it again by hand.
|
|
//
|
|
// A recovery and a try event never overlap (tryMu): a sweep that deleted the candidate a start event wrote after the call to the
|
|
// checker would leave a running try with none.
|
|
func (ix *Indexer) RecoverTries(ctx context.Context) error {
|
|
ix.tryMu.Lock()
|
|
defer ix.tryMu.Unlock()
|
|
return ix.recoverNow(ctx)
|
|
}
|
|
|
|
func (ix *Indexer) recoverNow(ctx context.Context) error {
|
|
if ix.OpenTries == nil || ix.Store == nil {
|
|
return nil
|
|
}
|
|
err := ix.recoverTries(ctx)
|
|
ix.recoveryFailed.Store(err != nil)
|
|
ix.noteRecovery(ctx, err)
|
|
return err
|
|
}
|
|
|
|
// noteRecovery writes how a recovery went: failing keeps the time it began to fail, and the journal says it each time.
|
|
func (ix *Indexer) noteRecovery(ctx context.Context, err error) {
|
|
now := ix.now()
|
|
r := TryRecovery{V: 1, Done: err == nil, At: now.UTC()}
|
|
if err != nil {
|
|
r.Why, r.Since = err.Error(), now.UTC()
|
|
ix.logf("RECOVERY OF THE TRIES NOT DONE: %v (the try candidates may be stale or missing until indexer_recover_tries or the next start succeeds)", err)
|
|
}
|
|
if ix.TryRecovery == nil {
|
|
return
|
|
}
|
|
for range writeTries {
|
|
cur, rev, found, gerr := ix.TryRecovery.Get(ctx, TryRecoveryKey)
|
|
if gerr != nil {
|
|
ix.logf("the try-recovery state cannot be read: %v", gerr)
|
|
return
|
|
}
|
|
if err != nil && found {
|
|
var was TryRecovery
|
|
if json.Unmarshal(cur, &was) == nil && !was.Done && !was.Since.IsZero() {
|
|
r.Since = was.Since
|
|
}
|
|
}
|
|
data, _ := json.Marshal(r)
|
|
var werr error
|
|
if found {
|
|
werr = ix.TryRecovery.Update(ctx, TryRecoveryKey, data, rev)
|
|
} else {
|
|
werr = ix.TryRecovery.Create(ctx, TryRecoveryKey, data)
|
|
}
|
|
if werr == nil {
|
|
return
|
|
}
|
|
if !errors.Is(werr, ErrConflict) {
|
|
ix.logf("the try-recovery state cannot be written: %v", werr)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// RecoverTriesByHand runs the recovery now (indexer_recover_tries), a hand act said in the journal.
|
|
func (ix *Indexer) RecoverTriesByHand(ctx context.Context, by, why string) TakenIn {
|
|
r := TakenIn{Module: "tries"}
|
|
ix.logf("recovery of the tries asked by %s (%s)", by, why)
|
|
if ix.OpenTries == nil {
|
|
r.Answer = "the indexer is wired to list no open try: nothing to recover from"
|
|
return r
|
|
}
|
|
if err := ix.RecoverTries(ctx); err != nil {
|
|
r.Answer = "recovery not done: " + err.Error()
|
|
return r
|
|
}
|
|
r.Stored, r.Answer = true, "the candidates of the open tries are recovered"
|
|
return r
|
|
}
|
|
|
|
func (ix *Indexer) recoverTries(ctx context.Context) error {
|
|
open, err := ix.OpenTries(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("the open tries cannot be listed, so a missed try event is not recovered: %w", err)
|
|
}
|
|
live := map[string]bool{}
|
|
for _, o := range open {
|
|
if !o.Whole || len(o.Members) == 0 {
|
|
continue
|
|
}
|
|
live[o.ID] = true
|
|
var ms []TryMember
|
|
for _, m := range o.Members {
|
|
ms = append(ms, TryMember{m.Repository, m.Head, m.Trunk})
|
|
}
|
|
if err := ix.startTry(ctx, o.ID, ms); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
keys, err := ix.Store.Keys(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, k := range keys {
|
|
if meshcatalogclient.IsTryGroup(k) && !live[k] {
|
|
if err := ix.endTry(ctx, k); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteTryCandidates writes the candidate of every live try that has none yet and whose previews are all there: after a
|
|
// preview was written, the try that waited for it.
|
|
func (ix *Indexer) WriteTryCandidates(ctx context.Context) error {
|
|
ix.mu.Lock()
|
|
ids := make([]string, 0, len(ix.tries))
|
|
for id := range ix.tries {
|
|
ids = append(ids, id)
|
|
}
|
|
ix.mu.Unlock()
|
|
slices.Sort(ids)
|
|
for _, id := range ids {
|
|
if err := ix.writeTryCandidate(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ix *Indexer) dropTryCandidate(ctx context.Context, id string) error {
|
|
if ix.Store == nil {
|
|
return nil
|
|
}
|
|
key, err := meshcatalogclient.CandidatesKey(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return write(ctx, ix.Store, key, func(_ []byte, found bool) ([]byte, bool, error) { return nil, found, nil })
|
|
}
|
|
|
|
// DropTryCandidate deletes a try's candidate by hand (indexer_drop_try_candidate); a live try gets it again at its next
|
|
// change.
|
|
func (ix *Indexer) DropTryCandidate(ctx context.Context, id, by, why string) TakenIn {
|
|
r := TakenIn{Module: id}
|
|
switch {
|
|
case !meshcatalogclient.IsTryGroup(id):
|
|
r.Answer = id + " is no try's group, try-<digits>: nothing dropped"
|
|
default:
|
|
if err := ix.dropTryCandidate(ctx, id); err != nil {
|
|
r.Answer = "the candidate of " + id + " could not be dropped: " + err.Error()
|
|
} else {
|
|
r.Stored, r.Answer = true, "the candidate of "+id+" is dropped by "+by+" ("+why+")"
|
|
ix.logf("%s", r.Answer)
|
|
}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// writeTryCandidate writes the candidate of a live try when it has none at its members' heads and every member's merge
|
|
// preview is held.
|
|
func (ix *Indexer) writeTryCandidate(ctx context.Context, id string) error {
|
|
if ix.Store == nil || ix.Read == nil {
|
|
return nil
|
|
}
|
|
ix.mu.Lock()
|
|
members := slices.Clone(ix.tries[id])
|
|
ix.mu.Unlock()
|
|
if len(members) == 0 {
|
|
return nil
|
|
}
|
|
key, err := meshcatalogclient.CandidatesKey(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
slices.SortFunc(members, func(a, b TryMember) int { return strings.Compare(a.Repository, b.Repository) })
|
|
heads := make([]meshdeliveryclient.Head, len(members))
|
|
for i, m := range members {
|
|
heads[i] = meshdeliveryclient.Head{Repository: m.Repository, Head: m.Head}
|
|
}
|
|
hash, err := meshdeliveryclient.HeadsHash(heads)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if value, _, found, err := ix.Store.Get(ctx, key); err == nil && found {
|
|
if c, err := DecodeCandidate(value); err == nil && c.Heads == hash {
|
|
return nil // fingerprints follow the previews (RecomputeCandidates)
|
|
}
|
|
}
|
|
previews := make([]meshdeliverychecker.PreviewsByRepositoryHeadTrunkValue, len(members))
|
|
for i, m := range members {
|
|
pk, err := PreviewKey(m.Repository, m.Head, m.Trunk)
|
|
if err != nil {
|
|
return fmt.Errorf("the try %s: %w", id, err)
|
|
}
|
|
ix.mu.Lock()
|
|
pv, ok := ix.previews[pk]
|
|
ix.mu.Unlock()
|
|
if !ok {
|
|
return nil // its fetch has not written the preview yet: this is called again when it does
|
|
}
|
|
previews[i] = pv
|
|
}
|
|
held, err := ix.heldModules(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pulls := make([]meshcatalog.CandidatesByGroupValuePullsItem, len(members))
|
|
var entries []PendingEntry
|
|
over := map[string]string{}
|
|
for i, m := range members {
|
|
pull := meshcatalog.CandidatesByGroupValuePullsItem{Repository: heads[i].Repository, Head: heads[i].Head, Modules: []string{}}
|
|
// defs are every module directory's manifest of the member at its head, the unchanged ones too: a contracts module whose
|
|
// seat.json changed while its module.json did not carries definitions all the same (ADR 0636 §4).
|
|
defs := map[string]string{}
|
|
for _, dir := range slices.Sorted(mapKeys(previews[i].Modules)) {
|
|
pm := previews[i].Modules[dir]
|
|
name, unchanged := "", false
|
|
for n, h := range held {
|
|
if strings.EqualFold(h.Repository, m.Repository) && h.Path == forgeDir(dir) {
|
|
name, unchanged = n, h.Manifest == pm.Manifest
|
|
break
|
|
}
|
|
}
|
|
if unchanged {
|
|
if raw := ix.keptManifest(ctx, pm.Manifest); raw != "" {
|
|
defs[dir] = raw
|
|
}
|
|
continue // the manifest the trunk holds: the try does not touch the module
|
|
}
|
|
rr, err := ix.Read(ctx, m.Repository, m.Head, forgeDir(dir))
|
|
if err != nil {
|
|
return fmt.Errorf("the try %s: reading the manifest of %s at %.12s: %w", id, dir, m.Head, err)
|
|
}
|
|
raw := rr.Manifests[forgeDir(dir)]
|
|
if raw == "" {
|
|
ix.logf("the try %s: %s holds no manifest at %.12s, so no module of it is taken", id, dir, m.Head)
|
|
continue
|
|
}
|
|
module := manifestModule(raw)
|
|
if module == "" {
|
|
module = name
|
|
}
|
|
if module == "" {
|
|
module = path.Base(strings.Trim(dir, "/."))
|
|
}
|
|
digest, err := ix.keepManifest(ctx, module, raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if digest != pm.Manifest {
|
|
ix.logf("the try %s: the manifest of %s read at %.12s is not the one its preview names, left", id, dir, m.Head)
|
|
continue
|
|
}
|
|
if pull.Fps == nil {
|
|
pull.Fps = map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{}
|
|
}
|
|
pull.Modules = append(pull.Modules, module)
|
|
pull.Fps[module] = meshcatalog.CandidatesByGroupValuePullsItemFpsValue{Manifest: digest, None2: meshcatalogclient.NoneNoPreview}
|
|
defs[dir] = raw
|
|
over[module] = digest
|
|
}
|
|
pulls[i] = pull
|
|
entries = append(entries, PendingEntry{Forge: forgemsg.Forge{Repository: m.Repository, Merge: m.Head, Manifests: defs}})
|
|
}
|
|
// The manifests are judged with the seat definitions the members carry (seatsof.go, ADR 0636 §4), in one pass that takes the
|
|
// seats. Their verdicts are the verdicts state's, keyed by the manifest's digest, as a pull request's candidate's are
|
|
// (judgeDigests in ApplyMessage): nothing reads them but the engines' composition of that digest and the module
|
|
// value of a trunk module with the same digest.
|
|
seats, err := ix.revisionSeats(ctx, entries)
|
|
if err != nil {
|
|
return fmt.Errorf("the try %s: reading the seat definitions of its members: %w", id, err)
|
|
}
|
|
ix.judgeDigestsWith(ctx, seats, over, "the try "+id)
|
|
c := meshcatalog.CandidatesByGroupValue{V: keys.Version, Group: id, Heads: hash, Pulls: pulls}
|
|
c.Fpset = meshcatalogclient.FingerprintSet(c.Pulls)
|
|
ix.withPreviews(ctx, &c)
|
|
if err := ix.refuseDisagreement(c); err != nil {
|
|
return err
|
|
}
|
|
data, err := EncodeCandidate(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return write(ctx, ix.Store, key, func(_ []byte, _ bool) ([]byte, bool, error) { return data, false, nil })
|
|
}
|
|
|
|
// keptManifest is the text of the manifest the indexer keeps under a digest, or empty.
|
|
func (ix *Indexer) keptManifest(ctx context.Context, digest string) string {
|
|
if ix.Manifests == nil || digest == "" {
|
|
return ""
|
|
}
|
|
key, err := meshcatalogclient.ManifestsKey(digest)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
value, _, found, err := ix.Manifests.Get(ctx, key)
|
|
if err != nil || !found {
|
|
return ""
|
|
}
|
|
m, err := meshcatalogclient.DecodeManifest(value)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return m.Manifest
|
|
}
|
|
|
|
func mapKeys[V any](m map[string]V) func(func(string) bool) {
|
|
return func(yield func(string) bool) {
|
|
for k := range m {
|
|
if !yield(k) {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// forgeDir is a preview's directory as the forge reader spells it: the root as "".
|
|
func forgeDir(dir string) string {
|
|
if dir == "." || dir == "/" {
|
|
return ""
|
|
}
|
|
return strings.Trim(dir, "/")
|
|
}
|
|
|
|
// heldAt says the trunk holds a module of the repository in that directory.
|
|
func heldAt(held map[string]meshcatalog.ModulesByModuleValue, repository, dir string) bool {
|
|
for _, h := range held {
|
|
if strings.EqualFold(h.Repository, repository) && h.Path == forgeDir(dir) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|