Files
mesh-delivery/modules/mesh-grants/internal/grants/firstviews.go
T
jschoubben 4f7ceb61ea mesh-grants: a first view's condition the keeper did not take is said again at the next pass
The raise and clear of first-view-unanswered dropped a failed publish (_ = say). The binding comment
named the bus module's own stream and 30 days; the part hears the seat's events on the shared stream, 7 days.

Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig
2026-11-04 17:08:23 +01:00

491 lines
20 KiB
Go

package grants
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"sort"
"strings"
"sync"
"time"
occ "git.novox.be/novox/mesh-contracts/go/clients/operatorchannelclient"
oc "git.novox.be/novox/mesh-contracts/go/seats/operatorchannel"
"git.novox.be/novox/mesh-sdk/go/delivery"
"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 placement 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 indexer'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 placement, wants, manifests,
// modules and verdicts).
var FirstViews = []FirstView{
{Owner: AssignerModule, Tool: AssignerModule + ".placement", State: "placement", Read: ReadPlacement},
{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, `placement`, 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 placement, wants, manifests, modules, verdicts, or none for all", state)
}
return asked, nil
}
// VerdictEvents are the indexer'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{IndexerModule + "." + delivery.ManifestAcceptedEvent, IndexerModule + "." + delivery.ManifestRefusedEvent}
// HearVerdict takes one of the indexer'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 delivery.ManifestVerdict
var digest string
var revision uint64
switch event {
case delivery.ManifestAcceptedEvent:
var a delivery.ManifestAccepted
if err := json.Unmarshal(data, &a); err != nil {
return fmt.Errorf("%s does not read: %w", event, err)
}
verdict = delivery.ManifestVerdict{V: delivery.Version, Module: a.Module, State: delivery.VerdictAccepted, Judge: a.Judge}
digest, revision = a.Digest, a.Revision
case delivery.ManifestRefusedEvent:
var r delivery.ManifestRefused
if err := json.Unmarshal(data, &r); err != nil {
return fmt.Errorf("%s does not read: %w", event, err)
}
verdict = delivery.ManifestVerdict{V: delivery.Version, Module: r.Module, State: delivery.VerdictRefused, Judge: r.Judge, Problems: r.Problems}
digest, revision = r.Digest, 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 := delivery.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 := delivery.DecodeManifestVerdict(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 delivery.ManifestVerdict) 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 placement, 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
}
}