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 (`.`). 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 c.Explanation = "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." c.Needs = "a look at " + fv.Owner + "'s runtime of its own: is it running and connected?" 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" c.Explanation = occ.NeedsYou + "a step. 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)." c.Needs = "let the owner write its state again from its sources (" + step + "), then grants_first_view_again" 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 } // 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 } }