Files
mesh-delivery/modules/mesh-grants/internal/grants/firstviews.go
T
jschoubben 160fca6a47
mesh/delivery merged: source.main moved to novox/mesh-delivery@7d8e2060a740
mesh/check checking 160fca6a4767 on 7d8e2060a740
Say in grants' log how long each first-view page took (issue 1966, recovery)
The wants page failed 226 times on a call's bound with nothing in the log to
show how far from it the answer was.

Claude-Session: https://claude.ai/code/session_01NcbgfFfQ4Z9UE5jK32PmE5
2026-11-08 23:30:28 +01:00

513 lines
21 KiB
Go

package grants
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"sort"
"strings"
"sync"
"time"
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
"git.novox.be/novox/mesh-sdk/go/keys"
"git.novox.be/novox/mesh-sdk/go/views"
)
// **The grants part's first views** (novox/hq ADR 0615 §1, §4 to §6, ADR 0621, ADR 0622; design 25 § 12.2; DLV-F4r,
// DLV-R1). The part reads the assigner's assignments and wants and the indexer's manifests, modules and verdicts no longer
// from their buckets, but by each owner's first-view tool, asked once at its start, then from the owner's views family
// alone (each event one changed key with its revision), asking once more only on a gap in revisions, never on a timer.
// The verdicts alone are followed on the seat mesh-catalog's manifest-accepted and manifest-refused, which carry the revision
// (ADR 0622 §2), not on its views family (HearVerdict).
// Started while an owner is down it keeps asking and never reads the owner's state; past the bound (two minutes) it
// raises FirstViewUnanswered naming the owner and the tool, cleared by the answer.
//
// **An answer empty at revision 0 drops no login**: until an owner answers above revision 0 the state reads as a failed
// read, so the part keeps the user list it has in force, and FirstViewEmpty stands, naming the owner and the operator's
// step (the owner writes its state again from its sources, then grants_first_view_again); an answer above 0 clears it.
// FirstView is one owner's state the part reads by its first view: the owner, the tool its `invokes` names, the state,
// and the read it stands for in the part's inputs (`<owner>.<state>`).
type FirstView struct {
Owner string `json:"owner"`
Tool string `json:"tool"`
State string `json:"state"`
Read string `json:"read"`
}
// The owners.
const (
AssignerModule = "mesh-delivery-assigner"
IndexerModule = "mesh-delivery-indexer"
)
// FirstViews are the five (ADR 0615 §3 as ADR 0622 §1 amends it: the grants part's assignments, wants, manifests,
// modules and verdicts).
var FirstViews = []FirstView{
{Owner: AssignerModule, Tool: AssignerModule + ".assignments", State: "assignments", Read: ReadAssignments},
{Owner: AssignerModule, Tool: AssignerModule + ".wants", State: "wants", Read: ReadWants},
{Owner: IndexerModule, Tool: IndexerModule + ".manifests", State: "manifests", Read: ReadManifests},
{Owner: IndexerModule, Tool: IndexerModule + ".modules", State: "modules", Read: ReadModules},
{Owner: IndexerModule, Tool: IndexerModule + ".verdicts", State: "verdicts", Read: ReadVerdicts},
}
// The conditions of the first views, each naming the owner and its state.
const (
FirstViewUnanswered = "mesh-grants.first-view-unanswered"
FirstViewEmpty = "mesh-grants.first-view-empty"
)
// KeyFirstViews is the key of the part's own state user-list where its process keeps what each first view holds, which
// grants_first_views reads.
const KeyFirstViews = "first-views"
// ErrFirstViewNotYet is a read of a first view not answered above revision 0: the part composes as from a failed read.
var ErrFirstViewNotYet = errors.New("not answered above revision 0 yet")
// Views is the part's reads of the five owners' states through their first views: a Reader and a Watcher for those
// states, every other read going to Rest.
type Views struct {
// Followers are the five, by the read they stand for.
Followers map[string]*views.Follower
// Rest reads every other state, and Watching watches it (nil: watched by nobody, as in a tool).
Rest Reader
Watching Watcher
said *untilSaid
}
// NewViews makes the five followers: ask calls an owner's tool (through the part's own account), say says a condition,
// changed wakes a pass. Started by Start.
func NewViews(ask func(tool string) func(context.Context, string) (views.Answer, error), say func(oc.Condition) error,
changed func(), logf func(string, ...any)) *Views {
v := &Views{Followers: map[string]*views.Follower{}}
sayer := &untilSaid{say: say, logf: logf, unsaid: map[string]oc.Condition{}}
v.said = sayer
for _, fv := range FirstViews {
fv := fv
f := &views.Follower{Owner: fv.Owner, Tool: fv.Tool, State: fv.State, Ask: ask(fv.Tool), Logf: logf,
Changed: func(string, json.RawMessage, bool, time.Time) { changed() }}
f.Raise = func(s views.Status) {
sayer.Say(unansweredCondition(fv, s, true))
changed()
}
f.Clear = func(s views.Status) {
sayer.Say(unansweredCondition(fv, s, false))
changed()
}
v.Followers[fv.Read] = f
}
return v
}
// untilSaid says a first view's condition; one the condition keeper did not take is logged and kept, and said again at
// the part's next pass (SayUnsaid), until it lands or a newer word on the same key replaces it: a raise or a clear is
// never dropped on a failed publish, and nothing waits on a timer for it.
type untilSaid struct {
say func(oc.Condition) error
logf func(string, ...any)
mu sync.Mutex
unsaid map[string]oc.Condition
}
// Say says c; when it fails it is kept to be said again.
func (u *untilSaid) Say(c oc.Condition) {
if u.say == nil {
return
}
err := u.say(c)
u.mu.Lock()
defer u.mu.Unlock()
if err == nil {
delete(u.unsaid, c.Key)
return
}
u.unsaid[c.Key] = c
if u.logf != nil {
u.logf("the condition %s (%s) could not be said: %v; said again at the next pass", c.Key, c.State, err)
}
}
// SayUnsaid says again every condition not taken yet: the part's pass calls it.
func (v *Views) SayUnsaid() {
if v == nil || v.said == nil {
return
}
u := v.said
u.mu.Lock()
pending := make([]oc.Condition, 0, len(u.unsaid))
for _, k := range slices.Sorted(maps.Keys(u.unsaid)) {
pending = append(pending, u.unsaid[k])
}
u.mu.Unlock()
for _, c := range pending {
u.mu.Lock()
current, still := u.unsaid[c.Key]
u.mu.Unlock()
if still && current.State == c.State {
u.Say(c)
}
}
}
// Start asks each first view on a goroutine of its own, until it is answered or ctx ends; each answer wakes a pass.
func (v *Views) Start(ctx context.Context, changed func()) {
for _, f := range v.Followers {
f := f
go func() {
if f.Start(ctx) == nil {
changed()
}
}()
}
}
// Following says the follower of the owner's views event state, or nil.
func (v *Views) Following(owner, state string) *views.Follower {
for _, fv := range FirstViews {
if fv.Owner == owner && fv.State == state {
return v.Followers[fv.Read]
}
}
return nil
}
// ready is the follower of read, answered above revision 0; nil and why when it is not one of the five, or not yet.
func (v *Views) ready(read string) (*views.Follower, bool, error) {
f, ok := v.Followers[read]
if !ok {
return nil, false, nil
}
select {
case <-f.Ready():
default:
return f, true, fmt.Errorf("%s by %s: %w", read, f.Tool, ErrFirstViewNotYet)
}
if s := f.Status(); s.Revision == 0 {
return f, true, fmt.Errorf("%s by %s answered empty at revision 0: %w", read, f.Tool, ErrFirstViewNotYet)
}
return f, true, nil
}
// Get reads one key: from its first view for the five, else from Rest.
func (v *Views) Get(state, key string) ([]byte, bool, error) {
f, mine, err := v.ready(state)
if !mine {
return v.Rest.Get(state, key)
}
if err != nil {
return nil, false, err
}
raw, ok := f.Get(key)
return raw, ok, nil
}
// Keys lists a state's keys: from its first view for the five, else from Rest.
func (v *Views) Keys(state string) ([]string, error) {
f, mine, err := v.ready(state)
if !mine {
return v.Rest.Keys(state)
}
if err != nil {
return nil, err
}
values := f.Values()
out := make([]string, 0, len(values))
for k := range values {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// Watch watches Rest's states; the five are followed by their first views and events, never watched.
func (v *Views) Watch(state string, changed func(key string, value []byte), ended func(error)) error {
if _, mine := v.Followers[state]; mine || v.Watching == nil {
return nil
}
return v.Watching.Watch(state, changed, ended)
}
// Statuses are what each first view holds now, in FirstViews' order.
func (v *Views) Statuses() []views.Status {
out := make([]views.Status, 0, len(FirstViews))
for _, fv := range FirstViews {
out = append(out, v.Followers[fv.Read].Status())
}
return out
}
// Again asks the first view of one state (its state name, `assignments`, or its tool) once more, or every one when state is
// empty, each on a goroutine of its own; it answers which it asked, or refuses a state that is none of the five.
func (v *Views) Again(ctx context.Context, state, why string) ([]string, error) {
var asked []string
for _, fv := range FirstViews {
if state != "" && state != fv.State && state != fv.Tool && state != fv.Read {
continue
}
f := v.Followers[fv.Read]
go func() { _ = f.Again(ctx, why) }()
asked = append(asked, fv.Tool)
}
if len(asked) == 0 {
return nil, fmt.Errorf("%q is no first view of the grants part: one of assignments, wants, manifests, modules, verdicts, or none for all", state)
}
return asked, nil
}
// VerdictEvents are the seat mesh-catalog's two events the part follows the verdicts on after their first view (ADR 0622 §2), as its
// manifest's consumes name them.
var VerdictEvents = []string{meshcatalog.Seat + "." + meshcatalog.EventManifestAccepted, meshcatalog.Seat + "." + meshcatalog.EventManifestRefused}
// HearVerdict takes one of the seat mesh-catalog's manifest-accepted or manifest-refused (event, its name) into the verdicts' first
// view, as one change of the verdicts state at the revision it carries (ADR 0622 §2): the manifest's digest its key, the
// verdict it says its value. A revision past the one after the picture's is a gap, and so is an event carrying no
// revision (an indexer that does not say it) or one saying no manifest above the picture's revision (the empty accepted
// verdict of a module leaving the trunk): the first view is asked once more on its own goroutine, said in the log. A
// revision already in the picture is passed over. Data that does not read is answered as an error, and nothing is asked.
func (v *Views) HearVerdict(ctx context.Context, event string, data []byte, logf func(string, ...any)) error {
f := v.Followers[ReadVerdicts]
if f == nil {
return nil
}
var verdict meshcatalog.VerdictsByDigestValue
var digest string
var revision uint64
switch event {
case meshcatalog.EventManifestAccepted:
var a meshcatalog.ManifestAcceptedEvent
if err := json.Unmarshal(data, &a); err != nil {
return fmt.Errorf("%s does not read: %w", event, err)
}
verdict = meshcatalog.VerdictsByDigestValue{V: keys.Version, Module: a.Module, State: meshcatalogclient.VerdictAccepted, Judge: a.Judge}
digest, revision = a.Digest, uint64(a.Revision)
case meshcatalog.EventManifestRefused:
var r meshcatalog.ManifestRefusedEvent
if err := json.Unmarshal(data, &r); err != nil {
return fmt.Errorf("%s does not read: %w", event, err)
}
verdict = meshcatalog.VerdictsByDigestValue{V: keys.Version, Module: r.Module, State: meshcatalogclient.VerdictRefused, Judge: r.Judge, Problems: r.Problems}
digest, revision = r.Digest, uint64(r.Revision)
default:
return fmt.Errorf("%q is not a verdict event of the indexer", event)
}
held := f.Status()
gap := func(why string) error {
logf("views: a gap in %s (%s): its first view %s is asked once more", f.State, why, f.Tool)
go func() { _ = f.Gap(ctx, why) }()
return nil
}
if revision == 0 {
return gap(fmt.Sprintf("the indexer's %s of %s carries no revision", event, verdict.Module))
}
key, err := meshcatalogclient.VerdictsKey(digest)
if err != nil {
if revision <= held.Revision {
return nil
}
return gap(fmt.Sprintf("the indexer's %s of %s at revision %d names no manifest", event, verdict.Module, revision))
}
// A revision below the picture's is a gap (ADR 0615 §5): the verdict events carry no mark of when the indexer's verdicts
// were made, so one made again shows only as revisions starting low. A redelivery of a verdict the picture already
// holds for its manifest is passed over, so a consumer's backlog after a restart asks nothing.
if revision < held.Revision {
if raw, found := f.Get(key); found {
if was, err := meshcatalogclient.DecodeVerdict(raw); err == nil && sameVerdict(was, verdict) {
return nil
}
}
return gap(fmt.Sprintf("the indexer's %s of %s at revision %d, below the picture's %d: its verdicts were made again, or this verdict was missed",
event, verdict.Module, revision, held.Revision))
}
value, err := json.Marshal(verdict)
if err != nil {
return err
}
if g, why := f.Apply(views.Event{V: 1, Owner: f.Owner, State: f.State, Key: key, Revision: revision, Created: held.Created,
Value: value, At: time.Now().UTC()}); g {
return gap(why)
}
return nil
}
// sameVerdict says two verdicts of one manifest say the same: its module, accepted or refused, the judge and the problems.
func sameVerdict(a, b meshcatalog.VerdictsByDigestValue) bool {
return a.Module == b.Module && a.State == b.State && a.Judge == b.Judge && slices.Equal(a.Problems, b.Problems)
}
// unansweredCondition is FirstViewUnanswered for one first view, raised or cleared.
func unansweredCondition(fv FirstView, s views.Status, raised bool) oc.Condition {
c := oc.Condition{Key: FirstViewUnanswered + "." + fv.Owner + "." + fv.State, Subject: oc.ConditionSubject{Scope: "module", ID: Module}}
if !raised {
c.State = occ.StateCleared
c.Resolved = fv.Tool + " answered at revision " + fmt.Sprint(s.Revision)
return c
}
c.State, c.Severity = occ.StateRaised, occ.SeverityWarning
c.Summary = fmt.Sprintf("the grants part has no first view of %s's %s: %s has not answered since %s (%s)", fv.Owner, fv.State,
fv.Tool, s.Asked.Format(time.RFC3339), strings.TrimSpace(s.Error))
c.Headline = "The grants part waits for " + fv.Owner
needsYou(&c, "a look at "+fv.Owner+"'s runtime of its own: is it running and connected?", "The grants part reads "+fv.Owner+
"'s "+fv.State+" only through "+fv.Tool+", and keeps asking it; it never reads the state itself, and keeps what it "+
"composed meanwhile (novox/hq ADR 0615 §6). The answer clears this.")
return c
}
// emptyCondition is FirstViewEmpty for one first view: raised while it answered empty at revision 0, with the
// operator's step, cleared by an answer above 0.
func emptyCondition(fv FirstView, raised bool) oc.Condition {
c := oc.Condition{Key: FirstViewEmpty + "." + fv.Owner + "." + fv.State, Subject: oc.ConditionSubject{Scope: "module", ID: Module}}
if !raised {
c.State = occ.StateCleared
c.Resolved = fv.Tool + " answered above revision 0"
return c
}
step := "the indexer takes each module in again (indexer_take_in)"
if fv.Owner == AssignerModule {
step = "the assigner composes the wants again at its next composition"
}
c.State, c.Severity = occ.StateRaised, occ.SeverityWarning
c.Summary = fmt.Sprintf("%s answered %s's %s empty at revision 0, so the grants part keeps the user list in force", fv.Tool, fv.Owner, fv.State)
c.Headline = fv.Owner + "'s " + fv.State + " is empty"
needsYou(&c, "let the owner write its state again from its sources ("+step+"), then grants_first_view_again", "The owner holds "+
"nothing of "+fv.State+" (its state rebuilt by a recreate that could not keep the data, or a fresh mesh), so the grants "+
"part drops no login and keeps what it holds; a login revoked meanwhile stays until the owner's first change "+
"(novox/hq design 25 § 12.2).")
return c
}
// firstViewsSaid is what the part last said and kept of its first views (sayFirstViews).
type firstViewsSaid struct {
mu sync.Mutex
empty map[string]bool
known bool
}
// FirstViewsState is the value of KeyFirstViews: each first view's status, as the part's process last kept it.
type FirstViewsState struct {
V int `json:"v"`
Views []views.Status `json:"views"`
}
// sayFirstViews keeps the first views' statuses in the part's own state (for grants_first_views) when they change, and
// raises or clears FirstViewEmpty for each answered at revision 0. Each pass calls it; nil Views is none.
func (p *Part) sayFirstViews(res *Result) {
if p.Views == nil {
return
}
statuses := p.Views.Statuses()
for i := range statuses {
statuses[i].Asked, statuses[i].Answered = statuses[i].Asked.Truncate(time.Second), statuses[i].Answered.Truncate(time.Second)
}
data, _ := json.Marshal(FirstViewsState{V: 1, Views: statuses})
p.changed(res, StateUserList, KeyFirstViews, data)
p.fvSaid.mu.Lock()
defer p.fvSaid.mu.Unlock()
if p.fvSaid.empty == nil {
p.fvSaid.empty = map[string]bool{}
}
for i, fv := range FirstViews {
s := statuses[i]
empty := !s.Answered.IsZero() && s.Revision == 0
if p.fvSaid.known && p.fvSaid.empty[fv.Read] == empty {
continue
}
if s.Answered.IsZero() && !p.fvSaid.known {
continue // not answered yet: nothing to say of its emptiness
}
if p.Say == nil || p.Say(emptyCondition(fv, empty)) == nil {
p.fvSaid.empty[fv.Read] = empty
}
}
p.fvSaid.known = true
}
// FirstViewsOf answers the tool grants_first_views: what the part's process last kept of each first view (owner, tool,
// state, revision, when asked and answered, asks, hand acts, whether its condition is raised).
func (p *Part) FirstViewsOf() (any, error) {
raw, rev, found, err := p.Store.Get(StateUserList, KeyFirstViews)
if err != nil {
return nil, fmt.Errorf("its own %s %s cannot be read: %w", StateUserList, KeyFirstViews, err)
}
if !found {
return map[string]any{"views": []any{}, "said": "the part's process has kept no first view yet: it has not passed since it started"}, nil
}
var st FirstViewsState
if err := json.Unmarshal(raw, &st); err != nil {
return nil, fmt.Errorf("its own %s %s does not read: %w", StateUserList, KeyFirstViews, err)
}
return map[string]any{"views": st.Views, "revision": rev, "first-views": FirstViews}, nil
}
// AskFirstViewAgain answers the tool grants_first_view_again: it asks the part's process to ask one first view (by its
// state, or every one when state is empty) once more now, and records the hand act through record (the seat
// mesh-hand-acts); a state that is none of the five is refused before anything is asked.
func (p *Part) AskFirstViewAgain(caller, state, why string, record func(what, why string) error) (any, error) {
which := AllFirstViews
if state != "" {
which = ""
for _, fv := range FirstViews {
if state == fv.State || state == fv.Tool || state == fv.Read {
which = fv.State
}
}
if which == "" {
return nil, fmt.Errorf("%q is no first view of the grants part: one of assignments, wants, manifests, modules, verdicts, or none for all", state)
}
}
if strings.TrimSpace(why) == "" {
return nil, errors.New("grants_first_view_again needs why: it is recorded as a hand act")
}
if record != nil {
if err := record("grants_first_view_again "+which, why); err != nil {
return nil, fmt.Errorf("the hand act could not be recorded, so nothing was asked: %w", err)
}
}
ask, _ := json.Marshal(Ask{By: caller, At: p.now(), FirstView: which})
rev, err := p.Store.Put(StateUserList, KeyAsked, ask)
if err != nil {
return nil, fmt.Errorf("the ask could not be written: %w", err)
}
return map[string]any{"asked": rev, "first-view": which,
"says": "the part's process asks it once more at this ask; grants_first_views shows its answer"}, nil
}
// TimedAsk is ask, saying in the log how long each page took and how many keys it brought, or after how long it failed:
// a first view that never answers (issue 1966: the wants page outgrew the call's bound) shows how far from its bound it is.
func TimedAsk(tool string, ask func(context.Context, string) (views.Answer, error), now func() time.Time,
logf func(string, ...any)) func(context.Context, string) (views.Answer, error) {
return func(ctx context.Context, after string) (views.Answer, error) {
start := now()
a, err := ask(ctx, after)
took := now().Sub(start).Round(time.Millisecond)
switch {
case logf == nil:
case err != nil:
logf("[mesh-grants] the first view %s, the page after %q, failed after %s: %v", tool, after, took, err)
default:
logf("[mesh-grants] the first view %s, the page after %q, took %s: %d keys at revision %d, next %q", tool, after, took,
len(a.Values), a.Revision, a.Next)
}
return a, err
}
}
// Unwrapped is a call whose answer is read as the tool's own text where the runtime wraps it as content (unwrap), as
// a first view's answer is decoded (views.AskVia).
func Unwrapped(call func(key string, body any) (json.RawMessage, error)) func(key string, body any) (json.RawMessage, error) {
return func(key string, body any) (json.RawMessage, error) {
raw, err := call(key, body)
if err != nil {
return nil, err
}
return unwrap(raw), nil
}
}