enginesim: the fence and the launch gate, I1 (#335 plan 1)
The edge's serving map, fenced counter and box-wide admission epoch; the launch gate on every activator path, adoption's set-before, wakes, systemd's own starts; INV-P and INV-I1; the 14 Task 2 rules, each with a witness (gate-adopt only with set-before-repoint, which reaches the same gate earlier); TestWitnesses in hostlife's shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+1178
−7
internal/sim/engines/edge_test.go+222 −0internal/sim/engines/invariants_test.go+42 −0internal/sim/engines/launch_test.go+369 −0internal/sim/engines/rules_test.go+19 −1internal/sim/engines/witness_test.go+476 −0internal/sim/engines/world_test.go+50 −6
diff --git a/internal/sim/engines/edge_test.go b/internal/sim/engines/edge_test.go| new file mode 100644 |
| index 000000000..b6404892d |
| --- /dev/null |
| +++ b/internal/sim/engines/edge_test.go |
| @@ -0,0 +1,222 @@ |
| +package engines |
| + |
| +import "sort" |
| + |
| +// The edge side of the internal mark (spec §5.1 "Setting the mark: the |
| +// serving barrier", "What the edge does with it"; §5.9 box-wide fence; §6.3). |
| +// |
| +// The registry holds the mark (the intent); the edge serves the map its last |
| +// successful reload swapped in, and publishes the registry change it has |
| +// fenced. A launch trusts only the fence. |
| + |
| +// loadEdge swaps the registry into the edge's map: rows by value (a later |
| +// registry edit does not leak into the served map) and their marks. |
| +func (b *box) loadEdge() { |
| + b.EdgeRows = make(map[string]row, len(b.Rows)) |
| + b.Serving = make(map[string]mark, len(b.Rows)) |
| + for h, r := range b.Rows { |
| + c := *r |
| + c.PathTo = make(map[string]string, len(r.PathTo)) |
| + for p, to := range r.PathTo { |
| + c.PathTo[p] = to |
| + } |
| + b.EdgeRows[h] = c |
| + b.Serving[h] = b.Mark[h] |
| + } |
| +} |
| + |
| +// groupHosts is every registry row of group g, sorted. |
| +func (w *world) groupHosts(b *box, g string) []string { |
| + var out []string |
| + for h := range b.Rows { |
| + if w.group(b, h) == g { |
| + out = append(out, h) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// groupMark is the registry's mark of group g: internal only if every row of |
| +// the group is, NULL if any row is NULL or the group has no row, else public. |
| +func (w *world) groupMark(b *box, g string) mark { |
| + hosts := w.groupHosts(b, g) |
| + if len(hosts) == 0 { |
| + return markNull |
| + } |
| + all := markInternal |
| + for _, h := range hosts { |
| + switch b.Mark[h] { |
| + case markNull: |
| + return markNull |
| + case markPublic: |
| + all = markPublic |
| + } |
| + } |
| + return all |
| +} |
| + |
| +// writeMark writes mark m on every registry row of group g: one registry |
| +// change, so Wrote counts it. A change that sets the group to 1 is the one a |
| +// fence must reach (SetAt). |
| +func (w *world) writeMark(b *box, g string, m mark) { |
| + before := w.groupMark(b, g) |
| + b.Wrote++ |
| + for _, h := range w.groupHosts(b, g) { |
| + b.Mark[h] = m |
| + } |
| + if m == markInternal && before != markInternal { |
| + b.SetAt[g] = b.Wrote |
| + } |
| + w.logf("box%d: registry change %d: %s marked %v", b.ID, b.Wrote, g, m) |
| +} |
| + |
| +// fenced: the edge's fenced counter has reached the registry change that set |
| +// group g's mark to 1, and no later change has cleared it (I1's "fenced"). |
| +func (w *world) fenced(b *box, g string) bool { |
| + return w.groupMark(b, g) == markInternal && b.Fenced >= b.SetAt[g] |
| +} |
| + |
| +// reload is the edge's registry poll. A failed reload swaps nothing and |
| +// advances nothing. A successful one swaps the map; if any row went from not |
| +// internal to internal it bumps the box's admission epoch and cancels every |
| +// public request admitted under an older one (including those waiting in the |
| +// activator's queue); then the fenced counter reaches the registry. |
| +func (w *world) reload(b *box) bool { |
| + if b.ReloadFails { |
| + if !on("fence-needs-reload") { |
| + b.Fenced = b.Wrote |
| + w.logf("box%d: reload failed; fenced %d anyway", b.ID, b.Fenced) |
| + return false |
| + } |
| + w.logf("box%d: reload failed; nothing swapped, fenced stays %d", b.ID, b.Fenced) |
| + return false |
| + } |
| + old := b.Serving |
| + b.loadEdge() |
| + changed := map[string]bool{} |
| + for h, m := range b.Serving { |
| + if m == markInternal && old[h] != markInternal { |
| + changed[h] = true |
| + } |
| + } |
| + if len(changed) > 0 { |
| + b.Epoch++ |
| + if on("fence-drains-admitted") { |
| + kept := b.Admitted[:0] |
| + for _, r := range b.Admitted { |
| + // box-wide: every older public request on any row, deleted |
| + // rows included (spec §5.9); without it, only the rows the |
| + // change marked |
| + if r.Public && r.Epoch < b.Epoch && (on("fence-box-wide") || changed[r.Row]) { |
| + w.logf("box%d: fence cancels request %d (row %s, epoch %d)", b.ID, r.ID, r.Row, r.Epoch) |
| + continue |
| + } |
| + kept = append(kept, r) |
| + } |
| + b.Admitted = kept |
| + } |
| + } |
| + b.Fenced = b.Wrote |
| + w.logf("box%d: reload: map swapped, epoch %d, fenced %d", b.ID, b.Epoch, b.Fenced) |
| + return true |
| +} |
| + |
| +// admitPublic is the edge's internal check for a request with no caller: it |
| +// resolves the alias, the path map and the minted row to the final row, and |
| +// refuses when that row is internal or NULL (spec §5.1: the check runs on the |
| +// final resolved upstream's route). A static row answers without a proxy, so |
| +// nothing is admitted. Otherwise the request is admitted under the box's |
| +// epoch, holding the final row's backend. |
| +func (w *world) admitPublic(b *box, host, path string) *req { |
| + asked, ok := b.EdgeRows[host] |
| + if !ok { |
| + w.logf("box%d: public %s%s: unknown host", b.ID, host, path) |
| + return nil |
| + } |
| + final := asked |
| + if asked.Alias != "" { |
| + if final, ok = b.EdgeRows[asked.Alias]; !ok { |
| + w.logf("box%d: public %s%s: alias target %s unknown", b.ID, host, path, asked.Alias) |
| + return nil |
| + } |
| + } |
| + if to, ok := final.PathTo[path]; ok { |
| + if final, ok = b.EdgeRows[to]; !ok { |
| + w.logf("box%d: public %s%s: path target %s unknown", b.ID, host, path, to) |
| + return nil |
| + } |
| + } |
| + checked := final |
| + if !on("mark-on-final-upstream") { |
| + checked = asked |
| + } |
| + if m := b.Serving[checked.Host]; m != markPublic { |
| + w.logf("box%d: public %s%s: internal refusal (%s is %v)", b.ID, host, path, checked.Host, m) |
| + return nil |
| + } |
| + if final.Backend == "" { |
| + w.logf("box%d: public %s%s: answered by the edge (%s)", b.ID, host, path, final.Host) |
| + return nil |
| + } |
| + w.nextID++ |
| + r := &req{ID: w.nextID, Epoch: b.Epoch, Row: checked.Host, Backend: final.Backend, Public: true, Box: b.ID} |
| + b.Admitted = append(b.Admitted, r) |
| + w.logf("box%d: public %s%s admitted as request %d (row %s, backend %s, epoch %d)", b.ID, host, path, r.ID, r.Row, r.Backend, r.Epoch) |
| + return r |
| +} |
| + |
| +// backendGroup is the group whose process listens on backend: the group of |
| +// the instance row that carries it, whatever row the request came in by. |
| +func (w *world) backendGroup(b *box, backend string) string { |
| + hosts := make([]string, 0, len(b.Rows)) |
| + for h := range b.Rows { |
| + hosts = append(hosts, h) |
| + } |
| + sort.Strings(hosts) |
| + for _, h := range hosts { |
| + if r := b.Rows[h]; r.Kind == "instance" && r.Backend == backend { |
| + return w.group(b, h) |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// deliver resumes an admitted request: unless the fence cancelled it, its |
| +// proxy reaches the newest process of the group listening on its backend. |
| +// INV-P: a public request never reaches a process whose build declares |
| +// doc_engine. |
| +func (w *world) deliver(r *req) { |
| + b := w.boxes[r.Box] |
| + idx := -1 |
| + for i, x := range b.Admitted { |
| + if x == r { |
| + idx = i |
| + } |
| + } |
| + if idx < 0 { |
| + w.logf("box%d: request %d was cancelled; nothing delivered", b.ID, r.ID) |
| + return |
| + } |
| + b.Admitted = append(b.Admitted[:idx], b.Admitted[idx+1:]...) |
| + r.Held = false |
| + g := w.backendGroup(b, r.Backend) |
| + var p *proc |
| + for _, x := range b.Procs { |
| + if x.Group == g && g != "" { |
| + p = x |
| + } |
| + } |
| + if p == nil { |
| + w.logf("box%d: request %d: no process on %s; wake failure answer", b.ID, r.ID, r.Backend) |
| + return |
| + } |
| + r.Reached = p.ID |
| + w.logf("box%d: request %d delivered to process %d (%s v%d)", b.ID, r.ID, p.ID, p.Group, p.Ver) |
| + if r.Public && w.builds[p.Ver].Declares { |
| + w.fail("INV-P", "box%d: public request %d (row %s) delivered to process %d of %s running v%d, which declares doc_engine", b.ID, r.ID, r.Row, p.ID, p.Group, p.Ver) |
| + } |
| + if p.Ver == b.Live[g] { |
| + b.Good[g] = p.Ver // the live build answered: it is the good version now |
| + } |
| +} |
diff --git a/internal/sim/engines/invariants_test.go b/internal/sim/engines/invariants_test.go| index a1e618777..0cc849ce2 100644 |
| --- a/internal/sim/engines/invariants_test.go |
| +++ b/internal/sim/engines/invariants_test.go |
| @@ -2,6 +2,7 @@ package engines |
| import ( |
| "fmt" |
| + "sort" |
| "strings" |
| ) |
| @@ -24,3 +25,44 @@ func invariantOf(violation string) string { |
| } |
| return violation |
| } |
| + |
| +// The invariants of Task 2 (spec §5.1, §6.3): |
| +// |
| +// - INV-P (event, recorded in deliver): a public request is never delivered |
| +// to a process whose build declares doc_engine. |
| +// - INV-I1 (state, checked at every step boundary): no process of a |
| +// declaring build runs while its group is not fenced as internal. The |
| +// check reads the fact the fence stands for, the edge's served map (no |
| +// row of the group is served public), not the fenced counter a launch |
| +// reads: the counter is what a broken fence gets wrong. A row the edge |
| +// has not loaded, or serves NULL, is refused, so it does not count. |
| + |
| +// check runs the state invariants. doStep calls it after every action; a |
| +// witness calls it at each of its step boundaries. |
| +func (w *world) check() { |
| + for _, b := range w.boxes { |
| + hosts := make([]string, 0, len(b.EdgeRows)) |
| + for h := range b.EdgeRows { |
| + hosts = append(hosts, h) |
| + } |
| + sort.Strings(hosts) |
| + for _, p := range b.Procs { |
| + if !w.builds[p.Ver].Declares { |
| + continue |
| + } |
| + for _, h := range hosts { |
| + if edgeGroup(b, h) == p.Group && b.Serving[h] == markPublic { |
| + w.fail("INV-I1", "box%d: process %d of %s runs v%d, which declares doc_engine, while the edge serves %s public", b.ID, p.ID, p.Group, p.Ver, h) |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// edgeGroup is group over the edge's loaded rows. |
| +func edgeGroup(b *box, host string) string { |
| + if r, ok := b.EdgeRows[host]; ok && r.Kind == "instance" && r.Owner != "" { |
| + return r.Owner |
| + } |
| + return host |
| +} |
diff --git a/internal/sim/engines/launch_test.go b/internal/sim/engines/launch_test.go| new file mode 100644 |
| index 000000000..7d781928e |
| --- /dev/null |
| +++ b/internal/sim/engines/launch_test.go |
| @@ -0,0 +1,369 @@ |
| +package engines |
| + |
| +import ( |
| + "fmt" |
| + "sort" |
| +) |
| + |
| +// The launch gate (spec §5.1 "The invariant", "Setting the mark", "Starts |
| +// systemd makes on its own"). |
| +// |
| +// I1: a process of a build whose manifest declares doc_engine starts only |
| +// after the edge has fenced its group as internal. Every activator launch |
| +// path ends in the gate; systemd's own starts (Restart=always, socket |
| +// activation) execute the live link and pass no gate, so they are safe only |
| +// because adoption gates before it repoints and nothing else moves the link. |
| + |
| +// fenceWaitTicks is the gate's bound: 30 s of the edge's reloads, one per |
| +// tick. |
| +const fenceWaitTicks = 3 |
| + |
| +// gateRule names the rule that gates a launch path; systemd and socket |
| +// starts are not activator launches and have none. |
| +var gateRule = map[string]string{ |
| + "adopt": "gate-adopt", |
| + "swap": "gate-swap", |
| + "fallback": "gate-fallback", |
| + "recovery": "gate-recovery", |
| + "seed": "gate-seed", |
| + "cmd": "gate-cmd", |
| + "systemd": "", |
| + "socket": "", |
| +} |
| + |
| +// gateSeesDeclaring is what the gate reads from the build's manifest: its |
| +// declaration, or, when the manifest cannot be read, declaring. |
| +func (w *world) gateSeesDeclaring(ver int) bool { |
| + bd := w.builds[ver] |
| + if !bd.ManifestOK { |
| + return on("unreadable-declares") |
| + } |
| + return bd.Declares |
| +} |
| + |
| +// gate is the launch gate for a build about to start in group g: a build it |
| +// sees as declaring writes 1 if 1 is not already written, then always waits |
| +// for the edge's fence to cover the group's mark, within the bound. False is |
| +// a refused launch (the wake fails). |
| +func (w *world) gate(b *box, g string, ver int, path string) bool { |
| + if !w.gateSeesDeclaring(ver) { |
| + return true |
| + } |
| + wrote := false |
| + if w.groupMark(b, g) != markInternal { |
| + w.writeMark(b, g, markInternal) |
| + wrote = true |
| + } |
| + if !wrote && !on("gate-waits-when-written") { |
| + w.logf("box%d: gate(%s) %s v%d: 1 already written; not waiting", b.ID, path, g, ver) |
| + return true |
| + } |
| + for i := 0; i < fenceWaitTicks && !w.fenced(b, g); i++ { |
| + w.reload(b) |
| + } |
| + if !w.fenced(b, g) { |
| + w.logf("box%d: gate(%s) %s v%d: no fence within the bound; refused", b.ID, path, g, ver) |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// launch starts build ver in group g by path, through the gate where the |
| +// path has one. It returns false when the gate refuses. |
| +func (w *world) launch(b *box, g string, ver int, path string) bool { |
| + rule, known := gateRule[path] |
| + if !known { |
| + panic(fmt.Sprintf("launch: unknown path %q", path)) |
| + } |
| + if rule != "" && on(rule) && !w.gate(b, g, ver, path) { |
| + return false |
| + } |
| + // a group keeps at most three processes: the oldest has exited |
| + n := 0 |
| + for _, p := range b.Procs { |
| + if p.Group == g { |
| + n++ |
| + } |
| + } |
| + if n >= 3 { |
| + for i, p := range b.Procs { |
| + if p.Group == g { |
| + b.Procs = append(b.Procs[:i], b.Procs[i+1:]...) |
| + w.logf("box%d: process %d of %s exited", b.ID, p.ID, g) |
| + break |
| + } |
| + } |
| + } |
| + w.nextID++ |
| + p := &proc{ID: w.nextID, Group: g, Ver: ver, Slice: "inst:" + g} |
| + b.Procs = append(b.Procs, p) |
| + w.logf("box%d: %s started %s v%d as process %d", b.ID, path, g, ver, p.ID) |
| + return true |
| +} |
| + |
| +// stopGroup stops every process of group g. |
| +func (w *world) stopGroup(b *box, g string) { |
| + kept := b.Procs[:0] |
| + for _, p := range b.Procs { |
| + if p.Group != g { |
| + kept = append(kept, p) |
| + } |
| + } |
| + b.Procs = kept |
| +} |
| + |
| +func (w *world) running(b *box, g string) bool { |
| + for _, p := range b.Procs { |
| + if p.Group == g { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// adopt makes ver the live release of group g: set-before (the gate, reached |
| +// before the repoint; on a timeout the link does not move and the mark stays |
| +// 1), then the repoint, then the restart, itself a gated launch. between runs |
| +// after the repoint and before the restart: whatever systemd does there. |
| +func (w *world) adopt(b *box, g string, ver int, between func()) bool { |
| + w.logf("box%d: adopt %s v%d (live v%d)", b.ID, g, ver, b.Live[g]) |
| + if on("set-before-repoint") && !w.gate(b, g, ver, "adopt") { |
| + w.logf("box%d: adopt %s v%d: not repointed this pass", b.ID, g, ver) |
| + return false |
| + } |
| + b.Live[g] = ver |
| + staged := false |
| + for _, v := range b.Staged[g] { |
| + staged = staged || v == ver |
| + } |
| + if !staged { |
| + b.Staged[g] = append(b.Staged[g], ver) |
| + } |
| + if b.Good[g] == 0 { |
| + b.Good[g] = ver |
| + } |
| + w.logf("box%d: live link of %s -> v%d", b.ID, g, ver) |
| + if between != nil { |
| + between() |
| + } |
| + w.stopGroup(b, g) |
| + return w.launch(b, g, ver, "adopt") |
| +} |
| + |
| +// wake is the activator waking group g by its wake plan: "recovery" starts |
| +// the recovery build, "fallback" first fails to start the live build and |
| +// falls back; both choose GoodVersion. The triggering request, if any, is |
| +// delivered afterwards (or finds the fence cancelled it). |
| +func (w *world) wake(b *box, g string, mode string, trigger *req) { |
| + ver := b.Good[g] |
| + if mode == "fallback" { |
| + w.logf("box%d: wake %s: live v%d fails to start; falling back to good v%d", b.ID, g, b.Live[g], ver) |
| + } else { |
| + w.logf("box%d: wake %s: recovery build good v%d", b.ID, g, ver) |
| + } |
| + if ver != 0 { |
| + w.launch(b, g, ver, mode) |
| + } |
| + if trigger != nil { |
| + w.deliver(trigger) |
| + } |
| +} |
| + |
| +// moveLive is anything other than adoption moving group g's live link (a |
| +// rollout tool, a hand repair). Only adoption, through the gate, and |
| +// --public may move a live link. |
| +func (w *world) moveLive(b *box, g string, ver int, who string) bool { |
| + if on("live-link-only-adoption") { |
| + w.logf("box%d: %s may not move the live link of %s; refused", b.ID, who, g) |
| + return false |
| + } |
| + b.Live[g] = ver |
| + w.logf("box%d: %s moved the live link of %s -> v%d", b.ID, who, g, ver) |
| + return true |
| +} |
| + |
| +// systemdRestart is Restart=always: the unit's main process exits and |
| +// systemd starts /opt/carlos/live/<host> again, with no activator. |
| +func (w *world) systemdRestart(b *box, g string) { |
| + if b.Live[g] == 0 { |
| + return |
| + } |
| + w.stopGroup(b, g) |
| + w.launch(b, g, b.Live[g], "systemd") |
| +} |
| + |
| +// socketActivate is carlos-app@.socket starting a stopped unit from the live |
| +// link, with no activator. |
| +func (w *world) socketActivate(b *box, g string) { |
| + if b.Live[g] == 0 || w.running(b, g) { |
| + return |
| + } |
| + w.launch(b, g, b.Live[g], "socket") |
| +} |
| + |
| +// ---------- the random walk ---------- |
| + |
| +func (w *world) pickBox() *box { return w.boxes[w.rng.Intn(len(w.boxes))] } |
| + |
| +// groups is every instance group of b's registry, sorted. |
| +func (w *world) groups(b *box) []string { |
| + seen := map[string]bool{} |
| + var out []string |
| + for h, r := range b.Rows { |
| + if g := w.group(b, h); r.Kind == "instance" && !seen[g] { |
| + seen[g] = true |
| + out = append(out, g) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +func (w *world) pickGroup(b *box) string { |
| + gs := w.groups(b) |
| + if len(gs) == 0 { |
| + return "" |
| + } |
| + return gs[w.rng.Intn(len(gs))] |
| +} |
| + |
| +func (w *world) pickVer() int { return 1 + w.rng.Intn(nBuilds) } |
| + |
| +// randomPublic sends one public request to a host of b's edge map, by its |
| +// own name, an alias or a path map. |
| +func (w *world) randomPublic(b *box) *req { |
| + hosts := make([]string, 0, len(b.EdgeRows)) |
| + for h := range b.EdgeRows { |
| + hosts = append(hosts, h) |
| + } |
| + if len(hosts) == 0 { |
| + return nil |
| + } |
| + sort.Strings(hosts) |
| + h := hosts[w.rng.Intn(len(hosts))] |
| + paths := []string{"/"} |
| + for p := range b.EdgeRows[h].PathTo { |
| + paths = append(paths, p) |
| + } |
| + sort.Strings(paths[1:]) |
| + return w.admitPublic(b, h, paths[w.rng.Intn(len(paths))]) |
| +} |
| + |
| +// heldFor is the oldest held request on b whose proxy reaches group g. |
| +func (w *world) heldFor(b *box, g string) *req { |
| + for _, r := range b.Admitted { |
| + if r.Held && w.backendGroup(b, r.Backend) == g { |
| + return r |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func init() { |
| + actions = append(actions, |
| + action{name: "adopt", weight: 3, run: func(w *world) { |
| + b := w.pickBox() |
| + g := w.pickGroup(b) |
| + ver := w.pickVer() |
| + var between func() |
| + switch w.rng.Intn(3) { |
| + case 1: |
| + between = func() { w.systemdRestart(b, g) } |
| + case 2: |
| + between = func() { |
| + if r := w.randomPublic(b); r != nil { |
| + w.socketActivate(b, w.backendGroup(b, r.Backend)) |
| + w.deliver(r) |
| + } |
| + } |
| + } |
| + w.adopt(b, g, ver, between) |
| + }}, |
| + action{name: "reload", weight: 4, run: func(w *world) { w.reload(w.pickBox()) }}, |
| + action{name: "reload-fails", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + b.ReloadFails = !b.ReloadFails && !w.calm() |
| + w.logf("box%d: reload fails = %v", b.ID, b.ReloadFails) |
| + }}, |
| + action{name: "public-request", weight: 6, run: func(w *world) { |
| + b := w.pickBox() |
| + r := w.randomPublic(b) |
| + if r == nil { |
| + return |
| + } |
| + if len(b.Admitted) <= 8 && w.rng.Intn(2) == 0 { |
| + r.Held = true |
| + w.logf("box%d: request %d held in the activator's queue", b.ID, r.ID) |
| + return |
| + } |
| + w.deliver(r) |
| + }}, |
| + action{name: "resume", weight: 3, run: func(w *world) { |
| + var held []*req |
| + for _, b := range w.boxes { |
| + for _, r := range b.Admitted { |
| + if r.Held { |
| + held = append(held, r) |
| + } |
| + } |
| + } |
| + if len(held) == 0 { |
| + return |
| + } |
| + sort.Slice(held, func(i, j int) bool { return held[i].ID < held[j].ID }) |
| + w.deliver(held[w.rng.Intn(len(held))]) |
| + }}, |
| + action{name: "wake", weight: 3, run: func(w *world) { |
| + b := w.pickBox() |
| + g := w.pickGroup(b) |
| + mode := "recovery" |
| + if w.rng.Intn(2) == 0 { |
| + mode = "fallback" |
| + } |
| + w.stopGroup(b, g) // the group had gone to sleep |
| + w.wake(b, g, mode, w.heldFor(b, g)) |
| + }}, |
| + action{name: "swap", weight: 2, run: func(w *world) { |
| + b := w.pickBox() |
| + g := w.pickGroup(b) |
| + if w.launch(b, g, w.pickVer(), "swap") { |
| + // the incoming generation serves; the outgoing ones stop |
| + newest := b.Procs[len(b.Procs)-1] |
| + kept := b.Procs[:0] |
| + for _, p := range b.Procs { |
| + if p.Group != g || p == newest { |
| + kept = append(kept, p) |
| + } |
| + } |
| + b.Procs = kept |
| + } |
| + }}, |
| + action{name: "seed", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + g := w.pickGroup(b) |
| + w.stopGroup(b, g) |
| + w.launch(b, g, w.pickVer(), "seed") |
| + }}, |
| + action{name: "cmd", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + w.launch(b, w.pickGroup(b), w.pickVer(), "cmd") |
| + }}, |
| + action{name: "systemd-restart", weight: 2, run: func(w *world) { |
| + b := w.pickBox() |
| + w.systemdRestart(b, w.pickGroup(b)) |
| + }}, |
| + action{name: "socket-activation", weight: 2, run: func(w *world) { |
| + b := w.pickBox() |
| + g := w.pickGroup(b) |
| + w.stopGroup(b, g) // the unit exited idle; a connection arrives |
| + w.socketActivate(b, g) |
| + if r := w.heldFor(b, g); r != nil { |
| + w.deliver(r) |
| + } |
| + }}, |
| + action{name: "repoint-live", weight: 1, run: func(w *world) { |
| + b := w.pickBox() |
| + w.moveLive(b, w.pickGroup(b), w.pickVer(), "a rollout tool") |
| + }}, |
| + ) |
| +} |
diff --git a/internal/sim/engines/rules_test.go b/internal/sim/engines/rules_test.go| index e7dcc6500..295b927d9 100644 |
| --- a/internal/sim/engines/rules_test.go |
| +++ b/internal/sim/engines/rules_test.go |
| @@ -35,4 +35,22 @@ func on(rule string) bool { |
| // allRules is the mutation list. Each task appends its rules, in spec order, |
| // with a comment naming the spec section each answers. |
| -var allRules = []string{} |
| +var allRules = []string{ |
| + // Task 2: the fence and the launch gate, I1 (spec §5.1 "The invariant", |
| + // "Setting the mark: the serving barrier", "What the edge does with it", |
| + // "Starts systemd makes on its own"; §5.9 the box-wide fence; §6.3) |
| + "set-before-repoint", // adoption gates before it repoints the live link (R2-1) |
| + "fence-needs-reload", // a failed reload advances nothing (R10-1) |
| + "fence-drains-admitted", // the fence cancels public requests admitted before the swap (R3-1) |
| + "fence-box-wide", // ... on every row of the box, deleted rows included (§5.9, R13-2) |
| + "gate-adopt", // the gate on adoption's restart |
| + "gate-swap", // ... on a swap's incoming generation (R7-1) |
| + "gate-fallback", // ... on a wake plan's fallback build (R9-1) |
| + "gate-recovery", // ... on a wake plan's recovery build (R9-1) |
| + "gate-seed", // ... on a seed |
| + "gate-cmd", // ... on a carlos run command |
| + "gate-waits-when-written", // the gate waits for the fence when 1 is already written (R10-1) |
| + "unreadable-declares", // an unreadable manifest is treated as declaring |
| + "live-link-only-adoption", // nothing but adoption (and --public) moves a live link (R11) |
| + "mark-on-final-upstream", // the edge checks the final resolved upstream's row (R1-2) |
| +} |
diff --git a/internal/sim/engines/witness_test.go b/internal/sim/engines/witness_test.go| new file mode 100644 |
| index 000000000..c3091034e |
| --- /dev/null |
| +++ b/internal/sim/engines/witness_test.go |
| @@ -0,0 +1,476 @@ |
| +package engines |
| + |
| +import ( |
| + "fmt" |
| + "os" |
| + "sort" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Witness scenarios, in hostlife's shape (internal/sim/hostlife/witness_test.go). |
| +// |
| +// The random sweep reaches some interleavings rarely. A witness is one |
| +// fixed, hand-scheduled scenario for one rule: it forces exactly the |
| +// interleaving the rule exists for, as a sequence of protocol steps, and |
| +// draws nothing from the world's RNG. It must hold (no invariant violated) |
| +// with the rules in place, and violate its named invariant when the rule's |
| +// mutation is applied, alone or in the stated combination. |
| +// |
| +// A witness calls w.check() at each step boundary, as doStep does in the |
| +// sweep; event invariants (INV-P at delivery) are recorded inside the step. |
| + |
| +type witness struct { |
| + rule string // the rule it is for |
| + needs string // the mutation set under which it must violate: the rule alone, or a combination the rule belongs to |
| + want string // the failure the scenario names: a substring of the first violation |
| + note string // what it shows |
| + run func(t *testing.T, w *world) |
| +} |
| + |
| +var witnesses = []witness{ |
| + {"set-before-repoint", "set-before-repoint", "INV-P", |
| + "R2-1: adoption of a declaring build over a public route; between the repoint and the restart's gate a public request socket-activates the live unit and is served by the declaring build", wSetBeforeRepoint}, |
| + {"fence-needs-reload", "fence-needs-reload", "INV-I1", |
| + "R10-1: the edge's reload keeps failing; a failed reload that advances the fenced counter lets adoption repoint and start the declaring build under the public map", wFenceNeedsReload}, |
| + {"fence-drains-admitted", "fence-drains-admitted", "INV-P", |
| + "R3-1: a request admitted at 0 waits in the activator's queue across the adoption; a fence that does not drain it lets it resume into the declaring build", wFenceDrainsAdmitted}, |
| + {"fence-box-wide", "fence-box-wide", "INV-P", |
| + "R13-2: a request admitted through a service row aimed at the engine's backend; the row is deleted, the request held across a declaring adoption; a fence scoped to the group's rows leaves it, and it resumes into the engine", wFenceBoxWide}, |
| + {"gate-adopt", "gate-adopt,set-before-repoint", "INV-I1", |
| + "redundant alone: adoption's set-before is the same gate reached earlier, so with it in place the restart's gate finds the group fenced; with both gone adoption starts the declaring build under 0", wGateAdopt}, |
| + {"gate-swap", "gate-swap", "INV-I1", |
| + "R7-1: a generation swap's incoming generation is a declaring build; ungated, it starts under 0", wGateSwap}, |
| + {"gate-fallback", "gate-fallback", "INV-P", |
| + "R9-1: after --public the first wake fails and the wake plan falls back to a declaring GoodVersion; ungated, the triggering public request is served by it", wGateFallback}, |
| + {"gate-recovery", "gate-recovery", "INV-I1", |
| + "R9-1: wake recovery picks a declaring GoodVersion; ungated, it starts under 0", wGateRecovery}, |
| + {"gate-seed", "gate-seed", "INV-I1", |
| + "a seed of a declaring build; ungated, it starts under 0", wGateSeed}, |
| + {"gate-cmd", "gate-cmd", "INV-I1", |
| + "a carlos run command of a declaring build; ungated, it starts under 0", wGateCmd}, |
| + {"gate-waits-when-written", "gate-waits-when-written", "INV-I1", |
| + "R10-1: adoption times out with 1 written and the reload failing; a gate that waits only when it wrote the mark lets wake recovery start a declaring GoodVersion under the public map", wGateWaitsWhenWritten}, |
| + {"unreadable-declares", "unreadable-declares", "INV-I1", |
| + "a declaring build whose manifest cannot be read, launched under 0; read as non-declaring, it starts under 0", wUnreadableDeclares}, |
| + {"live-link-only-adoption", "live-link-only-adoption", "INV-I1", |
| + "R11: something other than adoption moves the live link to a declaring build; systemd's Restart=always starts it, ungated, under 0", wLiveLinkOnly}, |
| + {"mark-on-final-upstream", "mark-on-final-upstream", "INV-P", |
| + "R1-2: public requests through an alias and through a path map whose final upstream is the engine; checked on the requested row, they reach the engine", wMarkOnFinalUpstream}, |
| +} |
| + |
| +// TestWitnesses is the deterministic half of the mutation evidence. |
| +// |
| +// With no mutation (the gate) it proves every witness's claim in full: the |
| +// scenario holds with every rule in place, holds under every proper subset of |
| +// its stated mutation set (so a rule a combination names is shown necessary, |
| +// not assumed), and under the whole set violates the failure the scenario |
| +// names, not merely some invariant. |
| +// |
| +// Under ENGINESIM_MUTATE=m (the harness) it runs the witnesses of the rules |
| +// in m; a witness whose set is within m must violate with its named failure. |
| +// ENGINESIM_WITNESS=rule runs one rule's witnesses with their trace. |
| +func TestWitnesses(t *testing.T) { |
| + only := os.Getenv("ENGINESIM_WITNESS") |
| + if mutated == "" { |
| + ran := 0 |
| + for _, x := range witnesses { |
| + if only != "" && x.rule != only { |
| + continue |
| + } |
| + ran++ |
| + check(t, x, "", false, only != "") |
| + for _, s := range properSubsets(x.needs) { |
| + check(t, x, s, false, only != "") |
| + } |
| + check(t, x, x.needs, true, only != "") |
| + } |
| + if only != "" && ran == 0 { |
| + t.Fatalf("no witness for rule %q", only) |
| + } |
| + return |
| + } |
| + set := mutationSet(mutated) |
| + for _, x := range witnesses { |
| + if only != "" && x.rule != only { |
| + continue |
| + } |
| + if !set[x.rule] { |
| + continue |
| + } |
| + needs := true |
| + for _, m := range strings.Split(x.needs, ",") { |
| + if !set[m] { |
| + needs = false |
| + } |
| + } |
| + if needs { |
| + check(t, x, mutated, true, only != "") |
| + continue |
| + } |
| + // the mutation set is not the witness's: report, enforce nothing |
| + report(t, x, mutated, only != "") |
| + } |
| +} |
| + |
| +// TestWitnessRulesKnown: every witness names a rule in allRules, and so does |
| +// every member of its mutation set. |
| +func TestWitnessRulesKnown(t *testing.T) { |
| + known := map[string]bool{} |
| + for _, r := range allRules { |
| + known[r] = true |
| + } |
| + for _, x := range witnesses { |
| + if !known[x.rule] { |
| + t.Errorf("witness for unknown rule %q", x.rule) |
| + } |
| + for _, m := range strings.Split(x.needs, ",") { |
| + if !known[m] { |
| + t.Errorf("witness %s needs unknown rule %q", x.rule, m) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func report(t *testing.T, x witness, m string, trace bool) { |
| + saved := mutated |
| + mutated = m |
| + defer func() { mutated = saved }() |
| + w := newWitness() |
| + x.run(t, w) |
| + tag := "held" |
| + if len(w.violations) > 0 { |
| + tag = "violated: " + w.violations[0] |
| + } |
| + t.Logf("witness %s under %q (needs %q): %s", x.rule, m, x.needs, tag) |
| + if trace { |
| + dump(t, w) |
| + } |
| +} |
| + |
| +// check runs one witness under mutation m, reports it, and fails the test if |
| +// the outcome is not the stated one: held when no violation is expected, the |
| +// named failure when one is. |
| +func check(t *testing.T, x witness, m string, expectViolation bool, trace bool) { |
| + saved := mutated |
| + mutated = m |
| + defer func() { mutated = saved }() |
| + w := newWitness() |
| + x.run(t, w) |
| + violated := len(w.violations) > 0 |
| + tag := "held" |
| + if violated { |
| + tag = "violated: " + w.violations[0] |
| + } |
| + t.Logf("witness %s under %q (needs %q): %s", x.rule, m, x.needs, tag) |
| + switch { |
| + case expectViolation && !violated: |
| + t.Errorf("witness %s held under %q, which it must violate", x.rule, m) |
| + dump(t, w) |
| + case expectViolation && !namedFailure(w.violations[0], x.want): |
| + t.Errorf("witness %s under %q violated, but not its named failure %q", x.rule, m, x.want) |
| + dump(t, w) |
| + case !expectViolation && violated: |
| + t.Errorf("witness %s violated under %q, where it must hold", x.rule, m) |
| + dump(t, w) |
| + } |
| + if trace { |
| + dump(t, w) |
| + } |
| +} |
| + |
| +// namedFailure: the violation names the witness's failure. want is one |
| +// failure or several separated by "|"; a failure that ends in a digit must not |
| +// run on into another (INV-I1 is not INV-I10). |
| +func namedFailure(v, want string) bool { |
| + for _, f := range strings.Split(want, "|") { |
| + for i := 0; ; { |
| + j := strings.Index(v[i:], f) |
| + if j < 0 { |
| + break |
| + } |
| + end := i + j + len(f) |
| + last := f[len(f)-1] |
| + if !(last >= '0' && last <= '9') || end == len(v) || v[end] < '0' || v[end] > '9' { |
| + return true |
| + } |
| + i = i + j + 1 |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func mutationSet(m string) map[string]bool { |
| + set := map[string]bool{} |
| + for _, x := range strings.Split(m, ",") { |
| + if x != "" { |
| + set[x] = true |
| + } |
| + } |
| + return set |
| +} |
| + |
| +// properSubsets: every non-empty proper subset of a comma-separated set. |
| +func properSubsets(needs string) []string { |
| + xs := strings.Split(needs, ",") |
| + var out []string |
| + for mask := 1; mask < (1<<len(xs))-1; mask++ { |
| + var sub []string |
| + for i, x := range xs { |
| + if mask&(1<<i) != 0 { |
| + sub = append(sub, x) |
| + } |
| + } |
| + out = append(out, strings.Join(sub, ",")) |
| + } |
| + return out |
| +} |
| + |
| +func dump(t *testing.T, w *world) { |
| + for _, line := range w.trace { |
| + t.Log(line) |
| + } |
| + t.Logf("state: %s", w.boxesState()) |
| +} |
| + |
| +// boxesState is a one-line summary of every box, for a failing witness. |
| +func (w *world) boxesState() string { |
| + var out []string |
| + for _, b := range w.boxes { |
| + hosts := make([]string, 0, len(b.Rows)) |
| + for h := range b.Rows { |
| + hosts = append(hosts, h) |
| + } |
| + sort.Strings(hosts) |
| + var marks []string |
| + for _, h := range hosts { |
| + marks = append(marks, fmt.Sprintf("%s=%v/%v", h, b.Mark[h], b.Serving[h])) |
| + } |
| + var procs []string |
| + for _, p := range b.Procs { |
| + procs = append(procs, fmt.Sprintf("%d:%s@v%d", p.ID, p.Group, p.Ver)) |
| + } |
| + var adm []string |
| + for _, r := range b.Admitted { |
| + adm = append(adm, fmt.Sprintf("%d:%s@e%d", r.ID, r.Row, r.Epoch)) |
| + } |
| + out = append(out, fmt.Sprintf("box%d{marks(reg/edge) %v wrote=%d fenced=%d epoch=%d reloadFails=%v live=%v good=%v procs=%v admitted=%v}", |
| + b.ID, marks, b.Wrote, b.Fenced, b.Epoch, b.ReloadFails, b.Live, b.Good, procs, adm)) |
| + } |
| + return strings.Join(out, " ") |
| +} |
| + |
| +// ---------- the fixture ---------- |
| + |
| +// The witness builds: one of each kind, fixed, so no scenario depends on a |
| +// seed's draw. |
| +const ( |
| + vPlain = 1 // reads, does not declare |
| + vEngine = 2 // reads, declares doc_engine |
| + vUnreadable = 3 // declares, but its manifest cannot be read |
| + vBlind = 4 // does not declare, manifest unreadable |
| +) |
| + |
| +func newWitness() *world { |
| + w := newWorld(0, true) |
| + w.builds = map[int]build{ |
| + vPlain: {Ver: vPlain, Declares: false, ManifestOK: true}, |
| + vEngine: {Ver: vEngine, Declares: true, ManifestOK: true}, |
| + vUnreadable: {Ver: vUnreadable, Declares: true, ManifestOK: false}, |
| + vBlind: {Ver: vBlind, Declares: false, ManifestOK: false}, |
| + } |
| + w.logf("witness world: builds %s", w.buildsString()) |
| + return w |
| +} |
| + |
| +// stubPublic stands in for the operator's --public until Task 3's |
| +// operatorPublic: stop every process of the group, write 0, reload. |
| +func (w *world) stubPublic(b *box, g string) { |
| + w.logf("witness: --public %s (stub: stop, write 0, reload)", g) |
| + w.stopGroup(b, g) |
| + w.writeMark(b, g, markPublic) |
| + w.reload(b) |
| + w.check() |
| +} |
| + |
| +// publicEngine sets the engine group on box 0 to a public route serving the |
| +// plain build: live and good are vPlain, the mark is 0 and fenced, and one |
| +// process runs. |
| +func (w *world) publicEngine(t *testing.T) *box { |
| + b := w.boxes[0] |
| + b.Live[eng], b.Good[eng] = vPlain, vPlain |
| + w.stubPublic(b, eng) |
| + if !w.launch(b, eng, vPlain, "seed") { |
| + t.Fatalf("witness: the plain build did not start under 0: %s", w.boxesState()) |
| + } |
| + w.check() |
| + if len(w.violations) > 0 { |
| + t.Fatalf("witness: the fixture violates: %v", w.violations) |
| + } |
| + return b |
| +} |
| + |
| +// ---------- the witnesses ---------- |
| + |
| +// wSetBeforeRepoint (R2-1): adoption repoints the live link to a declaring |
| +// build; before its restart a public request arrives, socket activation |
| +// starts the live unit (no activator, no gate) and serves it. With the mark |
| +// set and fenced before the repoint, the request is refused. |
| +func wSetBeforeRepoint(t *testing.T, w *world) { |
| + b := w.publicEngine(t) |
| + w.stopGroup(b, eng) |
| + w.adopt(b, eng, vEngine, func() { |
| + if r := w.admitPublic(b, engHost, "/"); r != nil { |
| + w.socketActivate(b, eng) |
| + w.deliver(r) |
| + } |
| + }) |
| + w.check() |
| +} |
| + |
| +// wFenceDrainsAdmitted (R3-1): a request admitted at 0 waits in the |
| +// activator's queue while adoption marks, fences, repoints and restarts; it |
| +// then resumes. |
| +func wFenceDrainsAdmitted(t *testing.T, w *world) { |
| + b := w.publicEngine(t) |
| + r := w.admitPublic(b, engHost, "/") |
| + if r == nil { |
| + t.Fatalf("witness: a public request to a public route was refused") |
| + } |
| + r.Held = true |
| + w.adopt(b, eng, vEngine, nil) |
| + w.check() |
| + w.deliver(r) |
| + w.check() |
| +} |
| + |
| +// r10Sequence (R10-1): the edge's reload fails; adoption of a declaring |
| +// build writes 1 and times out without repointing; then wake recovery picks |
| +// the declaring GoodVersion. |
| +func r10Sequence(t *testing.T, w *world) { |
| + b := w.publicEngine(t) |
| + b.Good[eng] = vEngine |
| + b.ReloadFails = true |
| + w.logf("witness: the edge's reload starts failing") |
| + w.adopt(b, eng, vEngine, nil) |
| + w.check() |
| + w.stopGroup(b, eng) |
| + w.wake(b, eng, "recovery", nil) |
| + w.check() |
| +} |
| + |
| +func wFenceNeedsReload(t *testing.T, w *world) { r10Sequence(t, w) } |
| +func wGateWaitsWhenWritten(t *testing.T, w *world) { r10Sequence(t, w) } |
| + |
| +// wFenceBoxWide (R13-2): a service row aimed at the engine's backend admits a |
| +// public request; the row is deleted and the request held; adoption of a |
| +// declaring build fences the group; the request resumes on the proxy it |
| +// already holds. |
| +func wFenceBoxWide(t *testing.T, w *world) { |
| + b := w.publicEngine(t) |
| + const svc = "svc.example" |
| + b.Rows[svc] = &row{Host: svc, Kind: "service", Backend: b.Rows[engHost].Backend, PathTo: map[string]string{}} |
| + b.Mark[svc] = markPublic |
| + w.reload(b) |
| + w.check() |
| + r := w.admitPublic(b, svc, "/") |
| + if r == nil { |
| + t.Fatalf("witness: the service row refused a public request: %s", w.boxesState()) |
| + } |
| + r.Held = true |
| + delete(b.Rows, svc) |
| + delete(b.Mark, svc) |
| + w.logf("witness: row %s deleted; request %d still holds its proxy", svc, r.ID) |
| + w.adopt(b, eng, vEngine, nil) |
| + w.check() |
| + w.deliver(r) |
| + w.check() |
| +} |
| + |
| +// wGateAdopt: adoption of a declaring build with no other start. |
| +func wGateAdopt(t *testing.T, w *world) { |
| + b := w.publicEngine(t) |
| + w.adopt(b, eng, vEngine, nil) |
| + w.check() |
| +} |
| + |
| +// wGateSwap (R7-1), wGateSeed, wGateCmd, wGateRecovery: a declaring build |
| +// started by that path under 0. |
| +func gatePath(path string) func(*testing.T, *world) { |
| + return func(t *testing.T, w *world) { |
| + b := w.publicEngine(t) |
| + if path == "recovery" { |
| + b.Good[eng] = vEngine |
| + w.stopGroup(b, eng) |
| + w.wake(b, eng, "recovery", nil) |
| + } else { |
| + w.launch(b, eng, vEngine, path) |
| + } |
| + w.check() |
| + } |
| +} |
| + |
| +func wGateSwap(t *testing.T, w *world) { gatePath("swap")(t, w) } |
| +func wGateSeed(t *testing.T, w *world) { gatePath("seed")(t, w) } |
| +func wGateCmd(t *testing.T, w *world) { gatePath("cmd")(t, w) } |
| +func wGateRecovery(t *testing.T, w *world) { gatePath("recovery")(t, w) } |
| + |
| +// wGateFallback (R9-1): the group ran a declaring build, was adopted to a |
| +// plain one (the mark stays 1) and cleared with --public; a public request |
| +// wakes it, the first wake fails and the plan falls back to the declaring |
| +// GoodVersion. |
| +func wGateFallback(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + b.Live[eng], b.Good[eng] = vEngine, vEngine |
| + if !w.launch(b, eng, vEngine, "seed") { |
| + t.Fatalf("witness: the engine did not start under its fenced mark: %s", w.boxesState()) |
| + } |
| + w.check() |
| + w.adopt(b, eng, vPlain, nil) |
| + w.check() |
| + b.Good[eng] = vEngine // the plain build has not proved itself yet |
| + w.stubPublic(b, eng) |
| + r := w.admitPublic(b, engHost, "/") |
| + if r == nil { |
| + t.Fatalf("witness: a public request after --public was refused: %s", w.boxesState()) |
| + } |
| + r.Held = true |
| + w.wake(b, eng, "fallback", r) |
| + w.check() |
| +} |
| + |
| +// wUnreadableDeclares: a build that declares but whose manifest cannot be |
| +// read, seeded under 0. |
| +func wUnreadableDeclares(t *testing.T, w *world) { |
| + b := w.publicEngine(t) |
| + w.launch(b, eng, vUnreadable, "seed") |
| + w.check() |
| +} |
| + |
| +// wLiveLinkOnly (R11): something other than adoption moves the live link to |
| +// a declaring build, then systemd restarts the unit from the live link. |
| +func wLiveLinkOnly(t *testing.T, w *world) { |
| + b := w.publicEngine(t) |
| + w.moveLive(b, eng, vEngine, "a rollout tool") |
| + w.check() |
| + w.systemdRestart(b, eng) |
| + w.check() |
| +} |
| + |
| +// wMarkOnFinalUpstream (R1-2): the engine runs fenced as internal; public |
| +// requests arrive through the alias row and through the path map of a static |
| +// row, both of which resolve to the engine's row. |
| +func wMarkOnFinalUpstream(t *testing.T, w *world) { |
| + b := w.boxes[0] |
| + b.Live[eng], b.Good[eng] = vEngine, vEngine |
| + if !w.launch(b, eng, vEngine, "seed") { |
| + t.Fatalf("witness: the engine did not start under its fenced mark: %s", w.boxesState()) |
| + } |
| + w.check() |
| + for _, try := range []struct{ host, path string }{{engAlias, "/"}, {engSite, engSitePath}} { |
| + if r := w.admitPublic(b, try.host, try.path); r != nil { |
| + w.deliver(r) |
| + } |
| + w.check() |
| + } |
| +} |
diff --git a/internal/sim/engines/world_test.go b/internal/sim/engines/world_test.go| index 70b035ee3..ee29dbd9f 100644 |
| --- a/internal/sim/engines/world_test.go |
| +++ b/internal/sim/engines/world_test.go |
| @@ -18,6 +18,18 @@ const ( |
| nBuilds = 4 |
| ) |
| +// The starting world's names. |
| +const ( |
| + eng = "eng" // the engine group (its owner's host; engine names have no dot) |
| + engHost = "eng.acct.apps.example" |
| + engAlias = "docs.example" // alias row on box 0, to engHost |
| + engSite = "www.example" // static row on box 0, path map to engHost |
| + engSitePath = "/convert" |
| + shopAlias = "shop-alias.example" // alias row on box 1, to shop |
| + shopSite = "shop-www.example" // static row on box 1, path map to shop |
| + shopSitePath = "/app" |
| +) |
| + |
| // build is a platform build: whether it declares the internal-engine |
| // protocol, and whether the manifest it writes is in place. |
| type build struct { |
| @@ -35,12 +47,15 @@ type proc struct { |
| Unisolated, OwnPort bool |
| } |
| -// row is a host row of a box's table. Kind is instance, service or static; |
| -// Backend is a socket path or an address, "" for static. |
| +// row is a host row of a box's table. Kind is instance, service, static or |
| +// alias; Backend is a socket path or an address, "" for static and alias. |
| +// An alias row names its target host in Alias and copies its upstream |
| +// (edge.go:929); PathTo maps a path to the host that serves it (edge.go:1364). |
| type row struct { |
| Host, Kind, Owner, Backend string |
| Platform, Grant bool |
| PathTo map[string]string |
| + Alias string |
| } |
| // mark is a row's exposure mark. |
| @@ -65,17 +80,25 @@ func (m mark) String() string { |
| // caller names who a request is on behalf of. |
| type caller struct{ Account, App, Engine string } |
| -// req is a request the box admitted or refused. |
| +// req is a request the box admitted. Row is the row whose mark admitted it; |
| +// Backend the proxy it holds from then on; Reached the process it was |
| +// delivered to (0 for none). |
| type req struct { |
| ID, Epoch int |
| Row, Backend string |
| Public bool |
| Caller *caller |
| Held bool |
| + Box, Reached int |
| } |
| // box is one host of the fleet: box 0 is the engine's home, box 1 a calling |
| // box. |
| +// |
| +// Rows and Mark are the registry (the intent). EdgeRows and Serving are the |
| +// edge's in-memory map, swapped in only by a successful reload. Wrote counts |
| +// registry mark changes, Fenced is the change the edge has fenced, SetAt the |
| +// change that set a group's mark to 1 (spec §5.1, "Setting the mark"). |
| type box struct { |
| ID int |
| Rows map[string]*row |
| @@ -86,6 +109,10 @@ type box struct { |
| Staged map[string][]int |
| Procs []*proc |
| Admitted []*req |
| + |
| + EdgeRows map[string]row |
| + Serving map[string]mark |
| + SetAt map[string]int |
| } |
| type world struct { |
| @@ -98,6 +125,7 @@ type world struct { |
| violations []string |
| residuals []string |
| stats map[string]int |
| + nextID int // process and request ids |
| verbose bool |
| } |
| @@ -121,18 +149,33 @@ func newWorld(seed int64, verbose bool) *world { |
| verbose: verbose, |
| } |
| newBox := func(id int) *box { |
| - return &box{ID: id, Rows: map[string]*row{}, Mark: map[string]mark{}, Live: map[string]int{}, Good: map[string]int{}, Staged: map[string][]int{}} |
| + return &box{ID: id, Rows: map[string]*row{}, Mark: map[string]mark{}, Live: map[string]int{}, Good: map[string]int{}, Staged: map[string][]int{}, |
| + EdgeRows: map[string]row{}, Serving: map[string]mark{}, SetAt: map[string]int{}} |
| } |
| home, calling := newBox(0), newBox(1) |
| w.boxes = []*box{home, calling} |
| // one engine group, eng, with its minted platform row |
| - const engHost = "eng.acct.apps.example" |
| - home.Rows[engHost] = &row{Host: engHost, Kind: "instance", Owner: "eng", Backend: "/run/carlos/eng.sock", Platform: true, PathTo: map[string]string{}} |
| + home.Rows[engHost] = &row{Host: engHost, Kind: "instance", Owner: eng, Backend: "/run/carlos/eng.sock", Platform: true, PathTo: map[string]string{}} |
| home.Mark[engHost] = markInternal |
| // one public app group, shop, on the calling box |
| calling.Rows["shop"] = &row{Host: "shop", Kind: "instance", Backend: "127.0.0.1:9001", PathTo: map[string]string{}} |
| calling.Mark["shop"] = markPublic |
| + // on each box an alias row and a static row with a path map, both |
| + // resolving to the box's group: rows of kinds that serve no release, 0 |
| + // by construction (spec §5.1, "What the edge does with it") |
| + for _, x := range []struct { |
| + b *box |
| + alias, site, path, to string |
| + }{{home, engAlias, engSite, engSitePath, engHost}, {calling, shopAlias, shopSite, shopSitePath, "shop"}} { |
| + x.b.Rows[x.alias] = &row{Host: x.alias, Kind: "alias", Alias: x.to, PathTo: map[string]string{}} |
| + x.b.Rows[x.site] = &row{Host: x.site, Kind: "static", PathTo: map[string]string{x.path: x.to}} |
| + x.b.Mark[x.alias], x.b.Mark[x.site] = markPublic, markPublic |
| + } |
| + // the edges start with the registry loaded |
| + for _, b := range w.boxes { |
| + b.loadEdge() |
| + } |
| for v := 1; v <= nBuilds; v++ { |
| w.builds[v] = build{Ver: v, Declares: w.rng.Intn(2) == 0, ManifestOK: w.rng.Intn(2) == 0} |
| @@ -208,6 +251,7 @@ func (w *world) doStep() { |
| w.stats[a.name]++ |
| w.logf("action %s", a.name) // always traced: the determinism test compares the action sequence |
| a.run(w) |
| + w.check() |
| } |
| // final is the end-of-run oracle: what must hold once the calm steps are |