diff --git a/go/views/findings.go b/go/views/findings.go index db3cfa67..f301f689 100644 --- a/go/views/findings.go +++ b/go/views/findings.go @@ -1,6 +1,7 @@ package views import ( + "cmp" "fmt" "strings" "time" @@ -13,6 +14,9 @@ const ( TooLargeKind = "view-too-large" UnsaidKind = "view-unsaid" UnansweredKind = "first-view-unanswered" + // ViewWaitsKind is a part's finding of a view it has waited for past WaitsBound (design 47 *A part with no view says it + // waits*). + ViewWaitsKind = "view-waits" ) // Finding is what a views part says is wrong with a state it owns or follows, in plain fields: Kind and ID name it (a module-scoped @@ -73,3 +77,15 @@ func UnansweredFinding(reader string, s Status, at time.Time) Finding { func finding(observer, kind, id, summary, headline, explanation string, at time.Time) Finding { return Finding{Observer: observer, Kind: kind, ID: id, Summary: summary, Headline: headline, Explanation: explanation, At: at.UTC()} } + +// ViewWaitsFinding is a part's finding of the view it has waited for since s.WaitingSince (design 47): it names the +// part, the owner and the revision, and clears when the part holds it. +func ViewWaitsFinding(part string, s Status, at time.Time) Finding { + id := part + "." + s.Owner + "." + s.State + summary := fmt.Sprintf("%s waits for %s's view %s since %s and acts on nothing new until it holds it; it holds %s", + part, s.Owner, s.WaitsFor, s.WaitingSince.UTC().Format(time.RFC3339), cmp.Or(s.View, "none")) + return finding(part, ViewWaitsKind, id, summary, + fmt.Sprintf("%s waits for a view of %s", part, s.Owner), + fmt.Sprintf("%s keeps running what it runs and takes up nothing new until %s's view %s reaches it. Look at %s: is it running, and has it "+ + "said that revision? %s's first views tool says what it holds and waits for.", part, s.Owner, s.WaitsFor, s.Owner, part), at) +} diff --git a/go/views/follow.go b/go/views/follow.go index ebdd3df2..ac14a5ac 100644 --- a/go/views/follow.go +++ b/go/views/follow.go @@ -30,6 +30,10 @@ type Follower struct { // nothing. Raise func(Status) Clear func(Status) + // RaiseWaits and ClearWaits are the part's view-waits finding: written past WaitsBound of a WaitFor, cleared when it + // Holds the view. Nil says nothing. + RaiseWaits func(Status) + ClearWaits func(Status) // Changed is told of each key the picture changes (deleted: the key is gone), after the change is held, with when the // owner's bus wrote it: the event's time, or for a key a first view brought the time its answer says (Answer.Written; // zero when the owner does not say it, and for a key the first view no longer holds). @@ -87,6 +91,76 @@ type Status struct { // Why is the last reason it asked (start, a gap and which, a hand act) or why the last ask failed. Why string `json:"why,omitempty"` Error string `json:"error,omitempty"` + // View is the revision of the revision view the part holds from this first view, as its seat's client names it + // (design 47 *A part with no view says it waits*); WaitsFor the one it waits for and acts on nothing new until it + // holds, since WaitingSince; WaitsRaised says its view-waits finding is written (past WaitsBound). + View string `json:"view,omitempty"` + WaitsFor string `json:"waits-for,omitempty"` + WaitingSince time.Time `json:"waiting-since,omitzero"` + WaitsRaised bool `json:"waits-raised,omitempty"` +} + +// WaitsBound is how long a part waits for a view before it writes its finding view-waits (design 47): the bound the seat +// list's readers keep, so an owner down for a restart raises nothing. +const WaitsBound = 5 * time.Minute + +// waitBound waits d within ctx: true when d passed, false when ctx ended first. A variable so a test ends it at once. +var waitBound = func(ctx context.Context, d time.Duration) bool { + b, cancel := context.WithTimeout(ctx, d) + defer cancel() + <-b.Done() + return ctx.Err() == nil +} + +// WaitFor says the part waits for view, a revision of the revision view its seat's client names, and acts on nothing +// new until it holds it (Holds): the status says what and since when, and if it still waits for it past WaitsBound, +// RaiseWaits is told (the part writes view-waits, ViewWaitsFinding). Waiting for the view it waits for already changes +// nothing, not even since when. +func (f *Follower) WaitFor(ctx context.Context, view string) { + f.mu.Lock() + f.init() + if f.st.WaitsFor == view || view == "" { + f.mu.Unlock() + return + } + f.st.WaitsFor, f.st.WaitingSince, f.st.WaitsRaised = view, f.now(), false + s := f.st + f.mu.Unlock() + f.logf("views: %s waits for %s's view %s, from %s", f.Tool, f.Owner, view, s.WaitingSince.Format(time.RFC3339)) + f.say(s) + go func() { + if !waitBound(ctx, WaitsBound) { + return + } + f.mu.Lock() + raise := f.st.WaitsFor == view && !f.st.WaitsRaised + if raise { + f.st.WaitsRaised = true + } + s := f.st + f.mu.Unlock() + if raise { + f.logf("views: %s has waited for %s's view %s since %s", f.Tool, f.Owner, view, s.WaitingSince.Format(time.RFC3339)) + if f.RaiseWaits != nil { + f.RaiseWaits(s) + } + f.say(s) + } + }() +} + +// Holds says the part holds view now: it waits no more, and a raised view-waits is cleared (ClearWaits). +func (f *Follower) Holds(view string) { + f.mu.Lock() + f.init() + raised := f.st.WaitsRaised + f.st.View, f.st.WaitsFor, f.st.WaitingSince, f.st.WaitsRaised = view, "", time.Time{}, false + s := f.st + f.mu.Unlock() + if raised && f.ClearWaits != nil { + f.ClearWaits(s) + } + f.say(s) } func (f *Follower) now() time.Time { diff --git a/go/views/status.go b/go/views/status.go index 962bde88..e8e6986a 100644 --- a/go/views/status.go +++ b/go/views/status.go @@ -101,7 +101,8 @@ func StatusTools(reader, read, again string, st stdio.KeptState, acts HandActs, Description: fmt.Sprintf("%s's first views of the owners' states it follows (novox/hq ADR 0615): per owner and state, "+ "the tool it asks, the revision its picture holds now (events taken in included), the highest revision it heard and why "+ "it is behind it, if it is, when it asked and was answered, how many asks (by hand among them) and whether its "+ - "condition (unanswered past two minutes) is raised.", reader), + "condition (unanswered past two minutes) is raised; and the revision of the revision view it holds, the one it waits "+ + "for and acts on nothing new until it holds, since when, and whether its view-waits finding (past five minutes) is written.", reader), Input: map[string]any{"type": "object", "properties": map[string]any{}}, Run: func(map[string]any) (any, error) { keys, err := st.Keys() diff --git a/go/views/views_test.go b/go/views/views_test.go index fabddf1a..59cbc2ee 100644 --- a/go/views/views_test.go +++ b/go/views/views_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "runtime" "slices" "strings" "sync" @@ -299,6 +300,60 @@ func TestViews(t *testing.T) { t.Errorf("said %v after %d asks", *said, f.Status().Asks) } }) + // W1.4 (design 47 *A part with no view says it waits*): the status says the view held, the one waited for and since + // when; past the bound the part's view-waits is raised once, and holding the view clears it. + t.Run("a part that waits for a view says so, raises view-waits past its bound, and holding it clears that", func(t *testing.T) { + ended := make(chan bool) + kept := waitBound + waitBound = func(context.Context, time.Duration) bool { return <-ended } + defer func() { waitBound = kept }() + var raised, cleared, said []Status + var mu sync.Mutex + f := &Follower{Owner: "o", Tool: "o.revision", State: "catalog", Now: now, + RaiseWaits: func(s Status) { mu.Lock(); raised = append(raised, s); mu.Unlock() }, + ClearWaits: func(s Status) { mu.Lock(); cleared = append(cleared, s); mu.Unlock() }, + Said: func(s Status) { mu.Lock(); said = append(said, s); mu.Unlock() }} + f.Holds("trunk.11") + f.WaitFor(context.Background(), "trunk.12") + f.WaitFor(context.Background(), "trunk.12") // the same view again: nothing new, no second bound + if s := f.Status(); s.View != "trunk.11" || s.WaitsFor != "trunk.12" || !s.WaitingSince.Equal(now()) || s.WaitsRaised { + t.Fatalf("status = %+v", s) + } + ended <- true + for i := 0; i < 1000 && !f.Status().WaitsRaised; i++ { + runtime.Gosched() + } + mu.Lock() + if len(raised) != 1 || raised[0].WaitsFor != "trunk.12" { + t.Errorf("raised = %+v", raised) + } + mu.Unlock() + fd := ViewWaitsFinding("mesh-delivery-builder", f.Status(), now()) + if fd.Kind != ViewWaitsKind || fd.ID != "mesh-delivery-builder.o.catalog" || !strings.Contains(fd.Summary, "waits for o's view trunk.12") || + !strings.Contains(fd.Summary, "it holds trunk.11") { + t.Errorf("finding = %+v", fd) + } + f.Holds("trunk.12") + if s := f.Status(); s.View != "trunk.12" || s.WaitsFor != "" || s.WaitsRaised { + t.Errorf("after holding: %+v", s) + } + mu.Lock() + if len(cleared) != 1 || len(said) == 0 || said[len(said)-1].View != "trunk.12" { + t.Errorf("cleared %d, last said %+v", len(cleared), said) + } + mu.Unlock() + f.WaitFor(context.Background(), "trunk.13") + f.Holds("trunk.13") + ended <- true // the bound of a view now held raises nothing + for i := 0; i < 100; i++ { + runtime.Gosched() + } + mu.Lock() + if len(raised) != 1 { + t.Errorf("a view held before its bound raised: %+v", raised) + } + mu.Unlock() + }) t.Run("an event heard while an ask runs is taken again after its answer", func(t *testing.T) { var f *Follower f, _ = follower(func(ctx context.Context, after string) (Answer, error) {