| index 3d9eaed8c..b12e49087 100644 |
| --- a/internal/layout/pass.go |
| +++ b/internal/layout/pass.go |
| @@ -152,16 +152,22 @@ func ready(rt registry.Route) bool { return rt.Status == "" || rt.Status == "rea |
| // in mode (design §8.2 steps 1 to 9). Rows are visited in host order so a |
| // log reads the same way twice. A host Recover reported is not visited |
| // again: recovery already converged it, or refused it. |
| +// |
| +// Recovery runs first and ungated, so a failing isolation probe never |
| +// leaves a crashed move fenced; its legs toward nested ask the gate. The |
| +// fresh moves to nested wait for the gate as a whole. The gate is asked at |
| +// most once per pass, and both share the answer. |
| func (p *Pass) Converge(ctx context.Context, mode Mode) []Result { |
| if mode != ModeNested && mode != ModeFlat { |
| return []Result{{Outcome: "refused", Reason: fmt.Sprintf("layout mode %q is not nested or flat", mode)}} |
| } |
| - if mode == ModeNested && p.NestedOK != nil { |
| - if err := p.NestedOK(); err != nil { |
| - return []Result{{Outcome: "refused", Reason: "isolation not ready: " + err.Error()}} |
| + gate := p.nestedGate() |
| + out := p.recover(ctx, mode, gate) |
| + if mode == ModeNested { |
| + if err := gate(); err != nil { |
| + return append(out, Result{Outcome: "refused", Reason: "isolation not ready: " + err.Error()}) |
| } |
| } |
| - out := p.Recover(ctx, mode) |
| seen := map[string]bool{} |
| for _, r := range out { |
| seen[r.Host] = true |
| @@ -217,12 +223,22 @@ func (p *Pass) migrateHost(ctx context.Context, host string, routes []registry.R |
| if err := Preflight(plan, routes); err != nil { |
| return Result{host, "refused", err.Error()} |
| } |
| + // A host whose database is not on this box cannot move. Finding that |
| + // out must not cost it a stop, and the pass directories and a |
| + // destination check, on every pass. The same check under the fence |
| + // stays the authority. |
| + if db := plan.Artifacts[0]; db.Required { |
| + if ok, err := lstatPresent(db.Src); err != nil || !ok { |
| + return Result{host, "refused", fmt.Sprintf("db is missing at %s", db.Src)} |
| + } |
| + } |
| gen := p.generation() |
| if r, fence := p.quiesce(ctx, host, gen); fence != fenceOurs { |
| return r |
| } |
| defer p.release(host, gen) |
| - return p.shift(host, mode) |
| + r, _ := p.shift(host, mode, nil) |
| + return r |
| } |
| |
| // fenceState is who holds a host's fence once quiesce returns. |
| @@ -289,51 +305,63 @@ func (p *Pass) release(host string, gen uint64) { |
| // classified can be stale: a move cut can have started, or the row been |
| // unflagged, re-added or migrated. Every return before the journal is |
| // written leaves nothing changed. |
| -func (p *Pass) shift(host string, mode Mode) Result { |
| +// |
| +// prior is a completed journal recovery is still holding the host with, |
| +// or nil. That journal is this pass's own, so it does not count as the |
| +// host being fenced here, and it is removed only immediately before the |
| +// new journal's exclusive write: until then it keeps the host fenced, so |
| +// a refusal anywhere in between leaves the host held, not released. |
| +// replaced reports whether prior was removed, and so whether the host's |
| +// fence is now the new journal (or nothing, after a completed move). |
| +func (p *Pass) shift(host string, mode Mode, prior *Journal) (res Result, replaced bool) { |
| fresh, err := p.Registry.Get(host) |
| if err != nil { |
| - return Result{host, "refused", "row: " + err.Error()} |
| + return Result{host, "refused", "row: " + err.Error()}, false |
| } |
| if fresh == nil { |
| - return Result{host, "refused", "row vanished under the fence"} |
| + return Result{host, "refused", "row vanished under the fence"}, false |
| } |
| if !ready(*fresh) { |
| - return Result{host, "refused", "status " + fresh.Status + " under the fence"} |
| + return Result{host, "refused", "status " + fresh.Status + " under the fence"}, false |
| } |
| if !activator.ActivatorManaged(*fresh) { |
| - return Result{host, "refused", "no longer activator-managed under the fence"} |
| + return Result{host, "refused", "no longer activator-managed under the fence"}, false |
| } |
| routes, err := p.Registry.List() |
| if err != nil { |
| - return Result{host, "refused", "list routes: " + err.Error()} |
| + return Result{host, "refused", "list routes: " + err.Error()}, false |
| } |
| want, from := activator.LayoutNested, activator.LayoutFlat |
| if mode == ModeFlat { |
| want, from = activator.LayoutFlat, activator.LayoutNested |
| } |
| - switch layout, reason := p.Classify(*fresh, routes); layout { |
| + in := p.layoutInputs(routes) |
| + if prior != nil { |
| + in.Journal = func(h string) bool { return h != host && JournalPresent(p.DataDir, h) } |
| + } |
| + switch layout, reason := activator.Classify(*fresh, in); layout { |
| case want: |
| - return Result{host, "unchanged", ""} |
| + return Result{host, "unchanged", ""}, false |
| case from: |
| default: |
| - return Result{host, "refused", fmt.Sprintf("%s under the fence: %s", layout, reason)} |
| + return Result{host, "refused", fmt.Sprintf("%s under the fence: %s", layout, reason)}, false |
| } |
| plan, err := PlanFor(p.DataDir, p.socketDir(), host, mode) |
| if err != nil { |
| - return Result{host, "refused", err.Error()} |
| + return Result{host, "refused", err.Error()}, false |
| } |
| if err := Preflight(plan, routes); err != nil { |
| - return Result{host, "refused", err.Error()} |
| + return Result{host, "refused", err.Error()}, false |
| } |
| // Both directions: a revert renames out of the raw directory, so it |
| // must be a real directory too, and it is cheaper to find a planted |
| // link here than after the journal has fenced the host. |
| if err := EnsureDirs(p.DataDir, p.socketDir(), host, p.SocketsGID); err != nil { |
| - return Result{host, "refused", err.Error()} |
| + return Result{host, "refused", err.Error()}, false |
| } |
| aside, err := DestinationCheck(plan, p.now) |
| if err != nil { |
| - return Result{host, "refused", err.Error()} |
| + return Result{host, "refused", err.Error()}, false |
| } |
| present := make([]bool, len(plan.Artifacts)) |
| for i, a := range plan.Artifacts { |
| @@ -344,31 +372,52 @@ func (p *Pass) shift(host string, mode Mode) Result { |
| } |
| ok, err := lstatPresent(a.Src) |
| if err != nil { |
| - return Result{host, "refused", err.Error()} |
| + return Result{host, "refused", err.Error()}, false |
| } |
| if a.Required && !ok { |
| - return Result{host, "refused", fmt.Sprintf("%s is missing at %s", a.Name, a.Src)} |
| + return Result{host, "refused", fmt.Sprintf("%s is missing at %s", a.Name, a.Src)}, false |
| } |
| present[i] = ok |
| plan.Artifacts[i].Present = ok |
| } |
| - var res Result |
| - p.serialize(func() { res = p.commit(plan, plan.Journal(present), aside) }) |
| + p.serialize(func() { res, replaced = p.commit(plan, plan.Journal(present), aside, prior) }) |
| if res.Outcome == "migrated" || res.Outcome == "reverted" { |
| p.reload() |
| } |
| - return res |
| + return res, replaced |
| } |
| |
| // commit is steps 6 to 9 up to the journal's removal. The caller reloads |
| // routing afterwards, outside Serialize's lock and still under the fence. |
| // Every return after the journal is written leaves it for Recover, and the |
| // journal keeps the host fenced until then. |
| -func (p *Pass) commit(plan *Plan, j *Journal, aside []string) Result { |
| +func (p *Pass) commit(plan *Plan, j *Journal, aside []string, prior *Journal) (Result, bool) { |
| host := plan.Host |
| + replaced := false |
| + if prior != nil { |
| + if err := RemoveJournal(p.DataDir, host); err != nil { |
| + return Result{host, "refused", err.Error()}, false |
| + } |
| + replaced = true |
| + } |
| if err := WriteJournal(p.DataDir, j); err != nil { |
| - return Result{host, "refused", err.Error()} |
| + // Put the held journal back: without it, or the new one, nothing |
| + // would keep the host from being released at an endpoint that is |
| + // not the intent. |
| + if replaced { |
| + if rerr := WriteJournal(p.DataDir, prior); rerr != nil { |
| + return Result{host, "refused", fmt.Sprintf("%v; restoring the held journal: %v", err, rerr)}, true |
| + } |
| + } |
| + return Result{host, "refused", err.Error()}, false |
| } |
| + r := p.commitWritten(plan, j, aside) |
| + return r, replaced |
| +} |
| + |
| +// commitWritten is commit after the journal is on disk. |
| +func (p *Pass) commitWritten(plan *Plan, j *Journal, aside []string) Result { |
| + host := plan.Host |
| if p.abortAt == "after-journal" { |
| return Result{host, "refused", "aborted"} |
| } |
| @@ -452,9 +501,14 @@ func (p *Pass) moveFiles(plan *Plan, j *Journal) error { |
| // §8.2, "Recovery is the journal"). It runs at agent start and at the top |
| // of every Converge. intent is the fleet's recorded layout: "" when none |
| // was ever recorded, IntentUnreadable when the agent could not read it. |
| -// Recovery is never gated on the isolation probe: it is how a host gets |
| -// out of the fenced state. |
| +// Completing a journal is never gated on the isolation probe, since that |
| +// is how a host gets out of the fenced state; a leg after it toward a |
| +// nested intent is (see recoverHost). |
| func (p *Pass) Recover(ctx context.Context, intent Mode) []Result { |
| + return p.recover(ctx, intent, p.nestedGate()) |
| +} |
| + |
| +func (p *Pass) recover(ctx context.Context, intent Mode, gate func() error) []Result { |
| entries, err := os.ReadDir(journalDir(p.DataDir)) |
| if errors.Is(err, fs.ErrNotExist) { |
| return nil |
| @@ -483,7 +537,6 @@ func (p *Pass) Recover(ctx context.Context, intent Mode) []Result { |
| byHost[rt.Host] = rt |
| } |
| var out []Result |
| - gate := p.nestedGate() |
| for _, host := range names { |
| if ctx.Err() != nil { |
| break |
| @@ -498,7 +551,7 @@ func (p *Pass) Recover(ctx context.Context, intent Mode) []Result { |
| } |
| |
| // nestedGate asks NestedOK at most once, on first use, and remembers the |
| -// answer for the rest of one recovery, as Converge asks it once per pass. |
| +// answer for the rest of one pass. |
| func (p *Pass) nestedGate() func() error { |
| asked := false |
| var answer error |
| @@ -524,14 +577,18 @@ func notActing(err error) string { |
| |
| // recoverHost checks one journal against the row and both derived |
| // endpoints, completes it to its own end under a fence, and then, still |
| -// under that fence, converges the host to intent. Releasing the host |
| -// between the two would let a forged or stale journal run it for a moment |
| -// in a layout the fleet did not ask for (design §3.3). |
| +// under that fence, converges the host to intent. The host is released |
| +// only once it stands at the intent (design §3.3): while the converge leg |
| +// runs, the completed journal stays on disk at done and keeps the host |
| +// fenced, and if the leg refuses, for any reason, it stays there. A flat |
| +// tenant can forge a valid revert journal for a nested sibling; completing |
| +// it costs that sibling a round trip, and if the way back is refused the |
| +// sibling waits fenced for an operator rather than running flat. |
| // |
| // Completing a journal is never gated on the isolation probe; the leg |
| // after it toward a nested intent is, exactly as Converge(ModeNested) is. |
| -// A host the gate stops there is released at its completed endpoint, which |
| -// is then flat and safe to serve. |
| +// That is the one refused leg that releases the host: its completed |
| +// endpoint is then flat, which is safe to serve. |
| // |
| // When another operation's fence already holds the host (the residence |
| // move's cut), the pass completes the journal under that fence, installing |
| @@ -591,50 +648,83 @@ func (p *Pass) recoverHost(ctx context.Context, host string, byHost map[string]r |
| if err != nil { |
| return Result{host, "refused", notActing(err)} |
| } |
| + converge := fence == fenceOurs && (intent == ModeNested || intent == ModeFlat) && intent != j.Mode |
| var holdReason string |
| switch { |
| case fence == fenceHeld && intent != j.Mode: |
| holdReason = fmt.Sprintf("completed to %s under another operation's fence; journal kept at done until that fence is lifted", j.Mode) |
| case intent == IntentUnreadable && j.Mode == ModeFlat: |
| - // A flat tenant can forge a valid revert journal for a nested |
| - // host, and releasing it flat would run it unisolated until an |
| - // intent is read. |
| + // Releasing a completed revert flat before the intent is known is |
| + // the forged-revert window again. |
| holdReason = "completed to flat; journal kept at done until the layout intent can be read" |
| } |
| + remove := holdReason == "" && !converge |
| var res Result |
| - p.serialize(func() { res = p.complete(plan, j, atFrom, holdReason) }) |
| - if res.Outcome == "recovered" || res.Outcome == "deferred" { |
| - // Both flipped the row (or found it flipped); a held host is |
| - // still fenced by its journal, but routing should say where it is. |
| - p.reload() |
| + p.serialize(func() { res = p.complete(plan, j, atFrom, remove) }) |
| + if res.Outcome != "recovered" { |
| + return res |
| } |
| - if res.Outcome != "recovered" || fence == fenceHeld || intent == "" || intent == IntentUnreadable || intent == j.Mode { |
| + // The row flipped (or was found flipped). A host still held by its |
| + // journal is refused wakes by that, but routing should say where it is. |
| + p.reload() |
| + if holdReason != "" { |
| + p.logf("layout: %s: held -> %s", host, holdReason) |
| + return Result{host, "deferred", holdReason} |
| + } |
| + if !converge { |
| return res |
| } |
| if intent == ModeNested { |
| - if err := gate(); err != nil { |
| - p.logf("layout: %s: released -> %s; not converging to nested: isolation not ready: %v", host, j.Mode, err) |
| - return Result{host, "recovered", fmt.Sprintf("completed to %s; not converging to nested: isolation not ready: %v", j.Mode, err)} |
| + if gerr := gate(); gerr != nil { |
| + var rerr error |
| + p.serialize(func() { rerr = RemoveJournal(p.DataDir, host) }) |
| + if rerr != nil { |
| + return Result{host, "refused", rerr.Error()} |
| + } |
| + p.logf("layout: %s: released -> %s; not converging to nested: isolation not ready: %v", host, j.Mode, gerr) |
| + return Result{host, "recovered", fmt.Sprintf("completed to %s; not converging to nested: isolation not ready: %v", j.Mode, gerr)} |
| } |
| } |
| - next := p.shift(host, intent) |
| - switch next.Outcome { |
| - case "migrated", "reverted": |
| + next, replaced := p.shift(host, intent, j) |
| + switch { |
| + case next.Outcome == "migrated" || next.Outcome == "reverted": |
| return Result{host, "recovered", fmt.Sprintf("completed to %s, then converged to %s", j.Mode, intent)} |
| - case "unchanged": |
| - return res |
| + case !replaced: |
| + reason := fmt.Sprintf("completed to %s; journal kept at done: converging to %s: %s %s", j.Mode, intent, next.Outcome, next.Reason) |
| + p.logf("layout: %s: held -> %s", host, reason) |
| + return Result{host, "deferred", reason} |
| } |
| return Result{host, next.Outcome, fmt.Sprintf("completed to %s; converging to %s: %s", j.Mode, intent, next.Reason)} |
| } |
| |
| // complete runs the journal's remaining phases (design §8.2's recovery |
| -// list). A done journal whose row is still at from is refused rather than |
| -// removed: a crash cannot produce it, since done is written only after the |
| -// row's swap committed, and removing it would release a row that names the |
| -// files' old place. A non-empty holdReason completes everything but the |
| -// journal's removal and reports the host deferred with that reason. |
| -func (p *Pass) complete(plan *Plan, j *Journal, atFrom bool, holdReason string) Result { |
| +// list), and removes the journal only when remove is set; otherwise it |
| +// stops at done and the journal keeps holding the host. |
| +// |
| +// Two states a crash cannot produce are refused rather than acted on. An |
| +// artifact the journal records absent that exists at either end: the host |
| +// has been fenced since the plan was made, so nothing recorded absent can |
| +// have appeared, and moving the rest without it would leave it behind (a |
| +// forged journal can use exactly that to strand a database file). And a |
| +// done journal whose row is still at from: done is written only after the |
| +// row's swap committed, and releasing it would leave a row that names the |
| +// files' old place. |
| +func (p *Pass) complete(plan *Plan, j *Journal, atFrom, remove bool) Result { |
| host, phase := plan.Host, j.Phase |
| + for i, a := range plan.Artifacts { |
| + if a.Name == "socket" || j.Artifacts[i].Present { |
| + continue |
| + } |
| + for _, path := range []string{a.Src, a.Dst} { |
| + present, err := lstatPresent(path) |
| + if err != nil { |
| + return Result{host, "refused", notActing(err)} |
| + } |
| + if present { |
| + return Result{host, "refused", fmt.Sprintf("journal records %s absent, but it exists at %s; not acting", a.Name, path)} |
| + } |
| + } |
| + } |
| switch j.Phase { |
| case PhaseFiles: |
| if err := p.moveFiles(plan, j); err != nil { |
| @@ -660,12 +750,10 @@ func (p *Pass) complete(plan *Plan, j *Journal, atFrom bool, holdReason string) |
| default: |
| return Result{host, "refused", fmt.Sprintf("journal phase %q unknown; not acting", j.Phase)} |
| } |
| - if holdReason != "" { |
| - p.logf("layout: %s: held -> %s", host, holdReason) |
| - return Result{host, "deferred", holdReason} |
| - } |
| - if err := RemoveJournal(p.DataDir, host); err != nil { |
| - return Result{host, "refused", err.Error()} |
| + if remove { |
| + if err := RemoveJournal(p.DataDir, host); err != nil { |
| + return Result{host, "refused", err.Error()} |
| + } |
| } |
| p.logf("layout: %s: recovered -> %s (journal phase was %s)", host, j.Mode, phase) |
| return Result{host, "recovered", string(j.Mode)} |