diff --git a/go/seatlist/bootstrap/node-engine.json b/go/seatlist/bootstrap/node-engine.json index 20ed8a1..541d242 100644 --- a/go/seatlist/bootstrap/node-engine.json +++ b/go/seatlist/bootstrap/node-engine.json @@ -674,6 +674,30 @@ ], "description": "what this machine's engine reads of a manifest (ADR 0618 §3)" } + }, + { + "name": "seat-list", + "description": "The seat list this engine holds and what it waits for (novox/hq ADR 0636 §7): each seat's source (trunk or bootstrap) and digest, the seats held from the bootstrap alone, the definitions refused, the last error and whether the seat mesh-catalog has answered. Read-only.", + "callers": "the operator and the healers", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "$ref": "#/defs/SeatList" + } + }, + { + "name": "seat-list-again", + "description": "Make this engine read the seat list from the seat mesh-catalog now (novox/hq ADR 0636 §7), the recovery of a list unread; it answers what it holds afterwards, and the error when the read failed. A read that changed nothing still ends the engine's wait.", + "callers": "the operator and the healers", + "input": { + "type": "object", + "properties": {} + }, + "answer": { + "$ref": "#/defs/SeatList" + } } ], "events": [ @@ -1069,6 +1093,109 @@ } ], "description": "each node's health as its engine reports it" + }, + { + "name": "findings", + "template": "state", + "writer": "holder", + "key-forms": [ + { + "key": "", + "value": { + "type": "object", + "description": "one thing an observer sees wrong now", + "properties": { + "v": { + "type": "integer", + "description": "layout version, 1" + }, + "key": { + "type": "string", + "description": "scope.id.token" + }, + "kind": { + "type": "string" + }, + "subject": { + "$ref": "#/defs/FindingSubject" + }, + "severity": { + "type": "string", + "enum": [ + "urgent", + "warning" + ] + }, + "summary": { + "type": "string", + "description": "one line" + }, + "headline": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "resolved": { + "type": "string" + }, + "needs": { + "type": "string" + }, + "actions": { + "type": "array", + "items": { + "$ref": "#/defs/FindingAction" + } + }, + "evidence": { + "type": "array", + "description": "newest first, at most 10", + "items": { + "$ref": "#/defs/FindingEvidence" + } + }, + "source": { + "type": "string", + "description": "the row, probe or event that saw it" + }, + "resolver": { + "type": "string", + "description": "self, operator, agent or healer:" + }, + "ref": { + "type": "string", + "description": "the key's last part when it is not the kind" + }, + "observer": { + "type": "string", + "description": "the module that wrote it" + }, + "run": { + "type": "string", + "description": "what of its own saw it last" + }, + "at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "v", + "key", + "kind", + "subject", + "severity", + "summary", + "source", + "observer", + "at" + ] + }, + "description": "one finding of the observer that wrote it" + } + ], + "description": "the findings this node's engine raises about itself, one key per finding, in the layout of the seat mesh-conditions (its findings state): a key written raises the condition of the same key, a key deleted clears it (novox/hq ADR 0564 §2, ADR 0636 §1); today `machine..seat-list-unread`" } ], "defs": { @@ -1412,6 +1539,165 @@ "said" ], "description": "the answer of a read-only judgement" + }, + "SeatListHeld": { + "type": "object", + "properties": { + "seat": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "trunk", + "bootstrap" + ] + }, + "digest": { + "type": "string", + "description": "the digest of the definition held; empty for a bootstrapped one" + } + }, + "required": [ + "seat", + "source" + ] + }, + "SeatList": { + "type": "object", + "description": "what an engine holds of the seat list (novox/hq ADR 0636 §7)", + "properties": { + "status": { + "type": "object", + "properties": { + "heard": { + "type": "boolean", + "description": "the seat mesh-catalog has answered" + }, + "held": { + "type": "array", + "items": { + "$ref": "#/defs/SeatListHeld" + } + }, + "waiting": { + "type": "array", + "items": { + "type": "string" + }, + "description": "the seats held from the bootstrap alone" + }, + "refused": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "a trunk definition that does not parse, by seat: the one held stands" + }, + "last-error": { + "type": "string" + }, + "revision": { + "type": "integer" + } + }, + "required": [ + "heard", + "held" + ] + }, + "health": { + "type": "string", + "description": "the one line the engine says of it; empty when nothing waits" + }, + "error": { + "type": "string", + "description": "why the read failed, for seat-list-again" + } + }, + "required": [ + "status", + "health" + ] + }, + "FindingSubject": { + "type": "object", + "description": "what the condition is about", + "properties": { + "scope": { + "type": "string", + "description": "machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module" + }, + "id": { + "type": "string", + "description": "its id within the scope" + }, + "machine": { + "type": "string", + "description": "the machine it concerns, when there is one" + }, + "also": { + "type": "array", + "description": "the other machines it concerns", + "items": { + "type": "string" + } + } + }, + "required": [ + "scope", + "id" + ] + }, + "FindingAction": { + "type": "object", + "description": "one answer the operator may give from a notification", + "properties": { + "label": { + "type": "string" + }, + "verb": { + "type": "string", + "description": "the seat verb the operator channel calls" + }, + "machine": { + "type": "string" + }, + "arguments": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "level": { + "type": "string", + "enum": [ + "acknowledge", + "approve" + ] + } + }, + "required": [ + "label", + "verb" + ] + }, + "FindingEvidence": { + "type": "object", + "description": "one observation, as it was said", + "properties": { + "at": { + "type": "string", + "format": "date-time" + }, + "said": { + "type": "string" + } + }, + "required": [ + "at", + "said" + ] } } } diff --git a/go/seatlist/seatlist.go b/go/seatlist/seatlist.go index 105de8b..9d0dd06 100644 --- a/go/seatlist/seatlist.go +++ b/go/seatlist/seatlist.go @@ -112,6 +112,7 @@ type Reader struct { refused map[string]string // seats whose trunk definition cannot be parsed, with why: the one that stands stays // what the last ask of the seat came to (Status) spoken bool + ready chan struct{} // closed once the first read of the seat has come to an end, answered or not revision uint64 lastErr string } @@ -128,7 +129,9 @@ type Held struct { // are the seats whose trunk definition did not parse (the one held stands), LastError is why the last ask failed, and // Revision is the seat's state revision of the last listing. type Status struct { - Heard bool `json:"heard"` + Heard bool `json:"heard"` + // Reading says the first read of the seat has not come to an end yet. + Reading bool `json:"reading,omitempty"` Held []Held `json:"held"` Waiting []string `json:"waiting,omitempty"` Refused map[string]string `json:"refused,omitempty"` @@ -140,7 +143,7 @@ type Status struct { func (r *Reader) Status() Status { r.mu.Lock() defer r.mu.Unlock() - st := Status{Heard: r.spoken, LastError: r.lastErr, Revision: r.revision, Held: []Held{}} + st := Status{Heard: r.spoken, Reading: !r.isReady(), LastError: r.lastErr, Revision: r.revision, Held: []Held{}} for seat := range r.defs { h := Held{Seat: seat, Source: SourceTrunk, Digest: r.digests[seat]} if r.boot[seat] { @@ -163,6 +166,8 @@ func (r *Reader) Status() Status { // Health is Status as the one line a module's health says: empty when the seat has been heard and nothing waits. func (st Status) Health() string { switch { + case st.Reading: + return fmt.Sprintf("the seat list is held from the bootstrap (%d seats) and nothing new is acted on: the first read of the seat %s has not ended", len(st.Held), Seat) case !st.Heard: return fmt.Sprintf("the seat list is held from the bootstrap (%d seats) and nothing new is acted on: the seat %s has not answered%s", len(st.Held), Seat, withError(st.LastError)) @@ -179,15 +184,16 @@ func withError(e string) string { return " (" + e + ")" } -// Start returns a reader holding the bootstrap, asks the seat for every definition and follows its two events. A first -// ask the seat did not answer is no failure: the reader holds the bootstrap, acts on nothing new (MayAct) and reads again -// on the seat's next event or on Reload. Start returns the subscription's error alone, which the host may ask again by -// calling Start again on a new reader. +// Start returns at once a reader holding the bootstrap, following the seat's two events, and reads the seat for every +// definition on a goroutine of its own, so a slow seat holds nobody's start (ADR 0636 §1). Until that read has come to an +// end the reader acts on nothing new (MayAct) and Status says it is reading; Ready is closed then. A first read the seat +// did not answer is no failure: the reader holds the bootstrap and reads again on the seat's next event or on Reload. +// Start returns the subscription's error alone, which the host may ask again by calling Start again on a new reader. func Start(cfg Config) (*Reader, error) { if cfg.Bus == nil { return nil, errors.New("seatlist: no bus to read the seat list through") } - r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, digests: map[string]string{}, boot: map[string]bool{}, refused: map[string]string{}} + r := &Reader{cfg: cfg, defs: map[string]seatdef.Definition{}, digests: map[string]string{}, boot: map[string]bool{}, refused: map[string]string{}, ready: make(chan struct{})} for _, d := range cfg.Bootstrap { r.defs[d.Seat] = d r.boot[d.Seat] = true @@ -197,10 +203,26 @@ func Start(cfg Config) (*Reader, error) { return r, fmt.Errorf("seatlist: following %s: %w", ev, err) } } - _ = r.Reload() + go func() { + _ = r.Reload() + close(r.ready) + }() return r, nil } +// Ready is closed once the first read of the seat, which Start began, has come to an end, whether the seat answered or +// not: a test or a host that needs the reader's first state waits on it. +func (r *Reader) Ready() <-chan struct{} { return r.ready } + +func (r *Reader) isReady() bool { + select { + case <-r.ready: + return true + default: + return false + } +} + // Seats are the held seats in reading order, as catalogue takes them. func (r *Reader) Seats() []catalogue.Seat { r.mu.Lock() diff --git a/go/seatlist/seatlist_integration_test.go b/go/seatlist/seatlist_integration_test.go index 236fe7a..7c50833 100644 --- a/go/seatlist/seatlist_integration_test.go +++ b/go/seatlist/seatlist_integration_test.go @@ -54,6 +54,7 @@ func TestAReaderStartsFromTheBootstrapAndReadsTheSeatOnceItAnswers(t *testing.T) if err != nil { t.Fatal(err) } + <-r.Ready() if st := r.Status(); st.Heard || st.LastError == "" || !r.MayAct("node-engine") || r.MayAct("node-files") { t.Fatalf("with the seat silent: %+v, may act on node-files: %v", st, r.MayAct("node-files")) } diff --git a/go/seatlist/seatlist_test.go b/go/seatlist/seatlist_test.go index 079ad98..c01ecc4 100644 --- a/go/seatlist/seatlist_test.go +++ b/go/seatlist/seatlist_test.go @@ -16,11 +16,15 @@ import ( type fakeOwner struct { table map[string]string // seat -> definition JSON, trunk down bool - asked []string // "key seat" + gate chan struct{} // when set, an ask waits for it to be closed + asked []string // "key seat" handlers map[string]func(string, json.RawMessage) error } func (f *fakeOwner) Ask(key string, body any) (json.RawMessage, error) { + if f.gate != nil { + <-f.gate + } args, _ := body.(map[string]any) seat, _ := args["seat"].(string) f.asked = append(f.asked, key+" "+seat) @@ -82,6 +86,7 @@ func TestTheSeatListIsReadFromTheSeatAndFollowsItsEvents(t *testing.T) { if err != nil { t.Fatal(err) } + <-r.Ready() if owner.handlers[Pattern(ChangedEvent)] == nil || owner.handlers[Pattern(RemovedEvent)] == nil || Pattern(ChangedEvent) != "mesh-catalog.seat-definition-changed" { t.Fatalf("subscribed to %v", owner.handlers) } @@ -157,6 +162,7 @@ func TestTheBootstrapIsHeldAndNothingNewIsActedOnUntilTheSeatAnswers(t *testing. if err != nil { t.Fatal(err) } + <-r.Ready() if got := len(r.Definitions()); got != len(boot) { t.Fatalf("the reader holds %d definitions, the bootstrap has %d", got, len(boot)) } @@ -199,6 +205,7 @@ func TestTheTwoToolsReadTheHeldListAndReadItAgainForTheirCallers(t *testing.T) { if err != nil { t.Fatal(err) } + <-r.Ready() callers := []string{stdio.AnyPerson, "mesh-healers"} tools := Tools(r, "seat_list", "seat_list_again", callers) out, err := tools[0].Run(map[string]any{}) @@ -226,6 +233,7 @@ func TestAReaderStartedWhileTheSeatWasSilentReadsTheWholeListAtItsFirstEvent(t * if err != nil { t.Fatal(err) } + <-r.Ready() if r.Status().Heard || r.MayAct("node-b") { t.Fatal("the silent seat was heard") } @@ -245,6 +253,7 @@ func TestAReaderStartedWhileTheSeatWasSilentIsHeardAtItsChangedEvent(t *testing. if err != nil { t.Fatal(err) } + <-r.Ready() if r.Status().Heard || r.MayAct("node-files") { t.Fatal("the silent seat must leave the reader unheard") } @@ -257,3 +266,22 @@ func TestAReaderStartedWhileTheSeatWasSilentIsHeardAtItsChangedEvent(t *testing. t.Fatalf("after the changed event: %+v, node-b held %v: want heard and the whole list read", st, r.Has("node-b")) } } + +// ADR 0636 §1: Start returns at once whatever the seat does; until the first read has ended the reader acts on nothing new +// and says it is reading. +func TestStartReturnsAtOnceAndTheReaderSaysItIsReadingUntilTheSeatAnswers(t *testing.T) { + owner := &fakeOwner{gate: make(chan struct{}), table: map[string]string{"node-b": def("node-b", "node", "put")}} + r, err := Start(Config{Bus: owner, Bootstrap: Bootstrapped()}) + if err != nil { + t.Fatal(err) + } + st := r.Status() + if !st.Reading || st.Heard || !strings.Contains(st.Health(), "first read") || r.MayAct("node-b") || !r.MayAct("node-engine") { + t.Fatalf("while the seat is slow: %+v", st) + } + close(owner.gate) + <-r.Ready() + if st := r.Status(); st.Reading || !st.Heard || !r.MayAct("node-b") { + t.Fatalf("after the read: %+v", st) + } +}