package main import ( "context" "encoding/json" "errors" "fmt" "slices" "sort" "strings" "sync" "time" "git.novox.be/novox/mesh-sdk/go/catalogue" "github.com/nats-io/nats.go" "github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/link" ) // **A backup before the send** (novox/hq ADR 0404 decisions 2 and 3, design 47, issue 1065). Once a delivery plan // has started and before it asks its first tier, the controller asks the node-backup holder of every machine that // runs a moved module with an item protected by a backup to back that module up now, and asks nothing of a builder // and sends nothing until every one of them answered done. The plan keeps the record (inventory.PlanBackups). // backupsWithin is how long a backup is given from its ask to say it is done, and backupsPollEvery how soon the plan // is looked at again while it waits: variables so a test waits for neither. var ( backupsWithin = 10 * time.Minute backupsPollEvery = 10 * time.Second ) // night is one module's last night as a holder's `backed-up` says it (lastNight). type night struct { OK bool `json:"ok"` At time.Time `json:"at"` Error string `json:"error,omitempty"` } // askBackupNow asks a machine's backup holder to back a module up now; the holder answers when it has started. A // variable so a test needs no bus. An error is the holder's own words when it refused, or why it could not be asked. var askBackupNow = func(ctx context.Context, machine, module string) error { return onTheBus(func(conn *nats.Conn) error { answer, err := link.AskSeatTool(ctx, conn, catalogue.BackupSeat, "now", machine, map[string]any{"module": module}, 30*time.Second) if err != nil { return &backupNotAsked{fmt.Errorf("%s's backup holder was not asked: %w", machine, err)} } if answer.Error != "" { return fmt.Errorf("%s", answer.Error) } return nil }) } // backupNotAsked is an ask that could not be made, as opposed to one the holder refused: the step is tried again. type backupNotAsked struct{ error } func (e *backupNotAsked) Unwrap() error { return e.error } // readLastNights reads a machine's holder's `backed-up` by module: how each module's last backup went. A variable // so a test needs no bus. var readLastNights = func(ctx context.Context, machine string) (map[string]*night, error) { var out map[string]*night err := onTheBus(func(conn *nats.Conn) error { answer, err := link.AskSeatTool(ctx, conn, catalogue.BackupSeat, "backed-up", machine, map[string]any{}, 10*time.Second) if err != nil { return err } if answer.Error != "" { return fmt.Errorf("%s", answer.Error) } var read []struct { Module string `json:"module"` LastNight *night `json:"lastNight"` } if err := json.Unmarshal(answer.Result, &read); err != nil { return fmt.Errorf("its answer is not readable: %w", err) } out = map[string]*night{} for _, m := range read { out[m.Module] = m.LastNight } return nil }) return out, err } // backupFacts are what the step needs of the mesh: the modules with an item protected by a backup, where each // runs, and which machines hold node-backup. type backupFacts struct { backedUp map[string][]string // module → machines running it holder map[string]bool } // backupFactsFrom reads the facts from the catalogue's entries and the seat holdings. A module is backed up when // one of its own items is (catalogue.DataItem.BackedUp: never a cache, `backup: none` or redundancy alone). A // module whose policy records (ADR 0377) is decided per machine after the build, so every machine running it is // asked: a superset costs time and never leaves a send without its restore point. func backupFactsFrom(entries []inventory.Entry, held []catalogue.Held) backupFacts { f := backupFacts{backedUp: map[string][]string{}, holder: map[string]bool{}} for _, e := range entries { for _, it := range e.Manifest.DataItems() { if it.BackedUp() { f.backedUp[e.Manifest.Module] = slices.Clone(e.On) break } } } for _, h := range held { if s, known := catalogue.SeatNamed(h.Claim); known && s.Name == catalogue.BackupSeat { f.holder[h.Node] = true } } return f } // loadBackupFacts reads the facts from the inventory. The holders are read as `seats` reads them, from the world // every machine's composition makes (theRestOfTheMesh): the seat_holding table keeps one holder of a node seat, and // node-backup is held on every machine. func loadBackupFacts(ctx context.Context, inv *inventory.Inventory) (backupFacts, error) { return loadBackupFactsFrom(inv.Catalogued, func() ([]catalogue.Held, error) { shelf, err := inv.Catalogue(ctx) if err != nil { return nil, err } world, err := restOfTheMeshOf(ctx, inv, shelf, "") if err != nil { return nil, err } return world.Held, nil }, ctx) } // loadBackupFactsFrom is loadBackupFacts given where the entries and the holdings come from. A source that fails // fails the step, to be tried again: a failure never reads as a machine with no holder, which would send without a // backup. func loadBackupFactsFrom(entries func(context.Context) ([]inventory.Entry, error), held func() ([]catalogue.Held, error), ctx context.Context) (backupFacts, error) { es, err := entries(ctx) if err != nil { return backupFacts{}, fmt.Errorf("the backups of the plan could not be worked out: %w", err) } h, err := held() if err != nil { return backupFacts{}, fmt.Errorf("the backups of the plan could not be worked out: who holds %s: %w", catalogue.BackupSeat, err) } return backupFactsFrom(es, h), nil } // stepBackups takes one step of a plan's backups and says whether the plan may go on to ask its first tier. It // asks what is not asked, reads how the asked ones went, and records it all in p.Backups; it waits (false, nil) // while any is pending, and stays stopped (false, nil) once one failed, which part B holds and says. A plan whose // moved modules have no backed-up item keeps no record and goes on at once. A holder that cannot be asked is tried again // within the bound and then failed; an error is left for what the step itself cannot do. func stepBackups(ctx context.Context, p *inventory.Plan, facts backupFacts, now time.Time) (bool, error) { proceed, err := stepBackupsOnce(ctx, p, facts, now) if err == nil { noteBackups(p) } return proceed, err } func stepBackupsOnce(ctx context.Context, p *inventory.Plan, facts backupFacts, now time.Time) (bool, error) { if p.Backups == nil { b := wantedBackups(*p, facts) if b == nil { return true, nil } p.Backups = b } b := p.Backups switch b.State { case inventory.BackupDone: return true, nil case inventory.BackupFailed: return false, nil } nights := map[string]map[string]*night{} for i := range b.Asked { a := &b.Asked[i] if a.State != inventory.BackupPending { continue } if a.AskedAt == nil { asked := now if a.TriedAt == nil { a.TriedAt = &asked } if err := askBackupNow(ctx, a.Machine, a.Module); err != nil { var unasked *backupNotAsked if errors.As(err, &unasked) { // A holder off the bus is tried again within the bound, by the look and never by the // "tried again" error path; past it the backup is failed and the plan held. a.Words = "could not be asked: " + err.Error() if now.Sub(*a.TriedAt) >= backupsWithin { a.State = inventory.BackupFailed } continue } a.State, a.Words = inventory.BackupFailed, err.Error() a.AskedAt, a.TriedAt = &asked, nil continue } a.AskedAt, a.TriedAt, a.Words = &asked, nil, "" continue } if _, read := nights[a.Machine]; !read { n, err := readLastNights(ctx, a.Machine) if err != nil { n = nil // not read now: the bound below still ends the wait } nights[a.Machine] = n } if n := nights[a.Machine][a.Module]; n != nil && n.At.After(*a.AskedAt) { if n.OK { a.State, a.Words = inventory.BackupDone, "" } else { a.State, a.Words = inventory.BackupFailed, n.Error if a.Words == "" { a.Words = "the backup failed and did not say why" } } continue } if now.Sub(*a.AskedAt) >= backupsWithin { a.State = inventory.BackupFailed a.Words = "did not answer in " + wordsOfBound(backupsWithin) } } b.State = backupsStateOf(b.Asked) switch b.State { case inventory.BackupDone: return true, nil case inventory.BackupPending: lookAgainForBackups(p.ID) } return false, nil } // wantedBackups is the record of backups a plan has to take, or nil for none: one entry per machine with a holder // running a moved module that has a backed-up item, and the machines with none. func wantedBackups(p inventory.Plan, facts backupFacts) *inventory.PlanBackups { var asked []inventory.PlanBackup noHolder := map[string]bool{} for _, tier := range p.Tiers { for _, module := range tier { for _, machine := range facts.backedUp[module] { if !facts.holder[machine] { noHolder[machine] = true continue } asked = append(asked, inventory.PlanBackup{Machine: machine, Module: module, State: inventory.BackupPending}) } } } if len(asked) == 0 && len(noHolder) == 0 { return nil } sort.SliceStable(asked, func(i, j int) bool { if asked[i].Machine != asked[j].Machine { return asked[i].Machine < asked[j].Machine } return asked[i].Module < asked[j].Module }) b := &inventory.PlanBackups{Asked: asked} for m := range noHolder { b.NoHolder = append(b.NoHolder, m) } sort.Strings(b.NoHolder) b.State = backupsStateOf(asked) return b } // backupsStateOf is the whole of the backups' states: failed if any failed, done if all are (or none are asked). func backupsStateOf(asked []inventory.PlanBackup) string { state := inventory.BackupDone for _, a := range asked { switch a.State { case inventory.BackupFailed: return inventory.BackupFailed case inventory.BackupPending: state = inventory.BackupPending } } return state } func wordsOfBound(d time.Duration) string { if d == 10*time.Minute { return "ten minutes" } if d%time.Minute == 0 { return fmt.Sprintf("%d minutes", int(d/time.Minute)) } return d.String() } var ( backupLookMu sync.Mutex backupLooks = map[string]bool{} ) // lookAgainForBackups has the serving controller look at the plans again soon, once per waiting plan: a backup ending // is no event the controller hears, so the wait is looked at by a timer, never by blocking the hold on the plans. func lookAgainForBackups(plan string) { backupLookMu.Lock() if backupLooks[plan] { backupLookMu.Unlock() return } backupLooks[plan] = true backupLookMu.Unlock() time.AfterFunc(backupsPollEvery, func() { backupLookMu.Lock() delete(backupLooks, plan) backupLookMu.Unlock() askToLook() }) } // backupsFirst is the step advanceOnce takes before it asks a walk's first tier: whether the plan may go on. func backupsFirst(ctx context.Context, inv *inventory.Inventory, p *inventory.Plan) (bool, error) { facts := backupFacts{} if p.Backups == nil { f, err := loadBackupFacts(ctx, inv) if err != nil { return false, err } facts = f } return stepBackups(ctx, p, facts, time.Now().UTC()) } // backupsNote begins the note of a plan that waits on or is held by its backups. const backupsNote = "backups: " // noteBackups says in the plan's note what its backups wait on, or, held, which backup failed and in what words and // the verb that asks again; a note of its own that the backups ended is cleared. Not the "tried again" note of a // failed step: a held plan is not retried (novox/hq ADR 0404 decision 4). func noteBackups(p *inventory.Plan) { if p.Backups == nil { return } var pending, failed []string for _, a := range p.Backups.Asked { switch a.State { case inventory.BackupPending: pending = append(pending, a.Module+" on "+a.Machine) case inventory.BackupFailed: failed = append(failed, fmt.Sprintf("%s on %s: %s", a.Module, a.Machine, a.Words)) } } switch p.Backups.State { case inventory.BackupPending: p.Note = backupsNote + "waiting for the backup of " + strings.Join(pending, ", ") case inventory.BackupFailed: p.Note = backupsNote + "held — the backup of " + strings.Join(failed, "; ") + "; nothing is asked or sent. " + "`delivery-backups-again` asks it again" default: if strings.HasPrefix(p.Note, backupsNote) { p.Note = "" } } } // waitingOnBackups says a plan has not asked its first tier because its backups are pending or failed: no tier // late, as S3 and the lines of `plans` read it. func waitingOnBackups(p inventory.Plan) bool { return p.Open() && p.Release == nil && p.Tier == 0 && !askedAny(p) && p.Backups != nil && p.Backups.State != inventory.BackupDone } // askBackupsAgain clears the failed backups of a held plan, so the next step asks those again and no other. It // says which. Refused for a plan with no failed backup. func askBackupsAgain(p *inventory.Plan, by, why string) ([]inventory.PlanBackup, error) { if !p.Open() || p.Backups == nil || p.Backups.State != inventory.BackupFailed { return nil, fmt.Errorf("%s has no failed backup: there is nothing to ask again", p.ID) } var again []inventory.PlanBackup for i := range p.Backups.Asked { a := &p.Backups.Asked[i] if a.State != inventory.BackupFailed { continue } again = append(again, *a) a.State, a.AskedAt, a.TriedAt, a.Words = inventory.BackupPending, nil, nil, "" } p.Backups.State = backupsStateOf(p.Backups.Asked) p.Note = backupsNote + "asked again by " + by + ": " + why return again, nil } // backupsAgain is askBackupsAgain for the plan with this id, kept: the verb `delivery-backups-again`. func backupsAgain(ctx context.Context, inv *inventory.Inventory, id, by, why string) (inventory.Plan, []inventory.PlanBackup, error) { release, err := inv.HoldPlans(ctx, true) if err != nil { return inventory.Plan{}, nil, err } defer release() p, err := inv.PlanByID(ctx, id) if err != nil { return inventory.Plan{}, nil, err } again, err := askBackupsAgain(&p, by, why) if err != nil { return p, nil, err } if err := inv.SavePlan(ctx, &p); err != nil { return p, nil, err } return p, again, nil } // kindBackupHeld is a delivery plan held for a backup that failed or stayed silent; backupHeldProbe its row. const ( kindBackupHeld = "backup-held" backupHeldProbe = "DK" ) // observeBackupHolds is one condition per plan held for its backups, with the backups' own words as its evidence. func observeBackupHolds(plans []inventory.Plan) []conditions.Observation { var out []conditions.Observation for _, p := range plans { if !p.Open() || p.Backups == nil || p.Backups.State != inventory.BackupFailed { continue } var said, machines []string for _, a := range p.Backups.Asked { if a.State == inventory.BackupFailed { said = append(said, fmt.Sprintf("%s on %s: %s", a.Module, a.Machine, a.Words)) machines = append(machines, a.Machine) } } out = append(out, conditions.Observation{Scope: conditions.ScopePlan, ID: p.ID, Token: "backup-held", Kind: kindBackupHeld, Severity: conditions.Urgent, Resolver: conditions.ResolverOperator, Machine: machines[0], Also: machines[1:], Summary: fmt.Sprintf("the delivery %s is held: %d of its backups failed or did not answer, so nothing is "+ "asked or sent", p.ID, len(said)), Said: strings.Join(said, "; ")}) } return out } // probeBackupHeld reads the open plans for the ones held for their backups. func probeBackupHeld(ctx context.Context, d *doctor) ([]conditions.Observation, error) { plans, err := d.open.inventory.OpenPlans(ctx) if err != nil { return nil, err } return observeBackupHolds(plans), nil }