Files
mesh-sdk/go/views/reader.go
T
jschoubben 57836416fe G V-5, V-6: a views reader writes its findings through the part's writer; a writer owns kinds and judges an owner's form
WriterFindings replaces the reader's own OwnFindings; the writer's statement
carries kinds, deletes only keys of them and keeps the reader's open findings
beside observe's; owner and waiting-on of none of the three forms are refused.

Builds: design 47 G §639, §580, §678

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 20:26:21 +01:00

638 lines
22 KiB
Go

package views
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"sort"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/keys"
)
// Reader is a module's pictures of the owners' states it used to read (novox/hq ADR 0615 §1): it
// asks each owner's read tool for the first view, subscribes the owner's views family, and answers the module's own
// `Keys` and `Get` of a state from the picture, by the name the module's `reads` used to carry (`<owner>.<state>`).
// A module in a stdio tools bundle declares `invokes` for the tools and `consumes` for the events, and names no `reads`
// of these owners any more. Pictures are kept in memory only: the owner's state is the truth, and a restart asks again.
type Reader struct {
// Module is the reading module, named in the findings it writes.
Module string
// Findings writes and clears this module's finding of an unanswered first view (its own state `findings`); nil
// says it in the log only.
Findings FindingWriter
Logf func(string, ...any)
// Changed is told of each key a followed state changes, after the picture holds it (state is `<owner>.<state>`).
Changed func(state, key string, value json.RawMessage, deleted bool)
// Acts records a re-read by hand before it is asked (nil records nothing
// and the re-read is refused).
Acts HandActs
// Now, Bound and Wait are for tests: zero are the clock, Bound and five seconds.
Now func() time.Time
Bound time.Duration
Wait time.Duration
mu sync.Mutex
followers map[string]*Follower // by "<owner>.<state>"
order []string
byOwner map[string]map[string]*Follower // owner -> state -> follower
watchers map[string][]func(key string, value []byte, gone bool)
// adopted are the followers a seat's client made (Adopt), by "<owner>.<state>": their events are a seat's, heard by
// the client's own handler, not an owner's views family.
adopted map[string]adoption
// events is, of each per-branch view (FollowBranch), by "<owner>.<state>.<branch>", the state as its owner's events name it (§521).
events map[string]string
// After Start (design 47 G §532): the reader's life, the event patterns whose subscription is taken (heard) or being
// asked (asking), and of each first view begun, the cancel of its asks, by "<owner>.<state>".
ctx context.Context
started bool
heard map[string]bool
asking map[string]bool
begun map[string]context.CancelFunc
}
// subscribe is how the reader asks the runtime for an event pattern; a variable so a test hears without a runtime.
var subscribe = stdio.Subscribe
// adoption is how an adopted follower's events are heard: the seat's event, by its local name, and the client's handler.
type adoption struct {
seat, event string
hear func(context.Context, []byte) error
}
// local is an adopted follower's event as the stdio runtime subscribes it: one subscription per event with every
// further part, `<seat>.<event>.>` (design 47 G §532, §521).
func (a adoption) local() string { return a.seat + "." + a.event + ".>" }
func (r *Reader) logf(format string, args ...any) {
if r.Logf != nil {
r.Logf(format, args...)
}
}
// Follow adds a state of an owner to follow, asked through the owner's read tool (`<module>.<tool>`, as the reader's
// `invokes` names it), with `values` so the answer is the first view. Called after Start, the view takes its first view
// at once and then follows (design 47 G §532).
func (r *Reader) Follow(owner, state, tool string) *Follower {
f := r.follow(owner, state, tool, "")
r.begin(owner + "." + f.State)
return f
}
// follow is Follow without beginning it: event, when given, is the state as the owner's events name it (FollowBranch).
func (r *Reader) follow(owner, state, tool, event string) *Follower {
r.mu.Lock()
defer r.mu.Unlock()
if r.followers == nil {
r.followers, r.byOwner = map[string]*Follower{}, map[string]map[string]*Follower{}
}
name := owner + "." + state
f := &Follower{Owner: owner, Tool: tool, State: state, Logf: r.Logf, Now: r.Now, Bound: r.Bound, Wait: r.Wait}
f.Ask = func(_ context.Context, after string) (Answer, error) {
body := map[string]any{"values": true}
if after != "" {
body["after"] = after
}
raw, err := stdio.Ask(tool, body)
if err != nil {
return Answer{}, err
}
return DecodeAnswer(raw)
}
r.wireLocked(name, f, nil)
if event != "" {
if r.events == nil {
r.events = map[string]string{}
}
r.events[name] = event
}
return f
}
// BranchState is the name of a per-branch state's view: `<state>.<branch>`, the branch escaped as a key's name is (design
// 47 G §479), so a reader names the view `<owner>.<state>.<branch>`. A state that is not per branch keeps its name.
func BranchState(state, branch string) string { return state + "." + keys.EscapeName(branch) }
// FollowBranch is Follow for a state kept per branch: the view of one branch is named `<owner>.<state>.<branch>`
// (BranchState), so a reader follows several branches of one state, each its own view and its own row of
// `<module>_first_views` (design 47 G §479). Called after Start, the view takes its first view at once and then follows
// (§532).
func (r *Reader) FollowBranch(owner, state, branch, tool string) *Follower {
f := r.follow(owner, BranchState(state, branch), tool, state)
r.begin(owner + "." + f.State)
return f
}
// Adopt takes a follower a seat's client made (its Ask a seat's verb, such as the catalog half's view) as one of the
// reader's first views, so the client's callers write no glue: the reader sets its conditions (the unanswered first view
// and view-waits, in the reader's findings), its log and clock, and hands each change to Watch, Changed and the
// follower's own Changed, set before; Keys, Get, Values, Watch, Statuses and the two tools name it `<owner>.<state>`.
// Its events are the seat's event (`<seat>.<event>`, as the reader's `consumes` names it), which Start subscribes and
// hands to hear, the client's handler; a state followed already is refused. Adopted after Start, as a part adopts a
// watched branch added while it runs, the view takes its first view at once and then follows (design 47 G §532).
func (r *Reader) Adopt(f *Follower, seat, event string, hear func(context.Context, []byte) error) error {
if f == nil || f.Owner == "" || f.State == "" || seat == "" || event == "" || hear == nil {
return errors.New("views: an adopted follower names its owner and state, and its seat's event and handler")
}
if err := r.adopt(f, seat, event, hear); err != nil {
return err
}
r.begin(f.Owner + "." + f.State)
return nil
}
func (r *Reader) adopt(f *Follower, seat, event string, hear func(context.Context, []byte) error) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.followers == nil {
r.followers, r.byOwner = map[string]*Follower{}, map[string]map[string]*Follower{}
}
name := f.Owner + "." + f.State
if r.followers[name] != nil {
return fmt.Errorf("views: %s is followed already", name)
}
if f.Logf == nil {
f.Logf = r.Logf
}
if f.Now == nil {
f.Now = r.Now
}
if f.Bound == 0 {
f.Bound = r.Bound
}
if f.Wait == 0 {
f.Wait = r.Wait
}
r.wireLocked(name, f, f.Changed)
if r.adopted == nil {
r.adopted = map[string]adoption{}
}
r.adopted[name] = adoption{seat: seat, event: event, hear: hear}
return nil
}
// wireLocked makes f one of the reader's first views under name: its conditions are the reader's, and each change goes
// to the watchers, Changed and own (the follower's own Changed, or nil). The caller holds mu.
func (r *Reader) wireLocked(name string, f *Follower, own func(string, json.RawMessage, bool, time.Time)) {
// A view dropped writes no finding after its drop cleared them (Drop).
held := func() bool { r.mu.Lock(); defer r.mu.Unlock(); return r.followers[name] == f }
f.Raise = func(s Status) {
if held() {
r.raise(s)
}
}
f.Clear = func(s Status) { r.clear(s) }
f.RaiseWaits = func(s Status) {
if held() {
r.waits(s, true)
}
}
f.ClearWaits = func(s Status) { r.waits(s, false) }
f.Changed = func(key string, value json.RawMessage, deleted bool, at time.Time) {
r.mu.Lock()
ws := append([]func(string, []byte, bool){}, r.watchers[name]...)
r.mu.Unlock()
for _, w := range ws {
w(key, value, deleted)
}
if r.Changed != nil {
r.Changed(name, key, value, deleted)
}
if own != nil {
own(key, value, deleted, at)
}
}
r.followers[name] = f
r.order = append(r.order, name)
if r.byOwner[f.Owner] == nil {
r.byOwner[f.Owner] = map[string]*Follower{}
}
r.byOwner[f.Owner][f.State] = f
}
// waits writes (raised) or clears the reader's view-waits finding of s (design 47 *A part with no view says it waits*).
func (r *Reader) waits(s Status, raised bool) {
if raised {
r.logf("%s has waited for %s's view %s since %s", r.Module, s.Owner, s.WaitsFor, s.WaitingSince.Format(time.RFC3339))
}
if r.Findings == nil {
return
}
f := ViewWaitsFinding(r.Module, s, r.nowTime())
var err error
if raised {
err = r.Findings.Put(f)
} else {
err = r.Findings.Delete(f)
}
if err != nil {
r.logf("the view-waits finding of %s is not written: %v", s.Tool, err)
}
}
func (r *Reader) nowTime() time.Time {
if r.Now != nil {
return r.Now().UTC()
}
return time.Now().UTC()
}
func (r *Reader) raise(s Status) {
r.logf("the first view %s of %s's %s is unanswered past its bound: %s", s.Tool, s.Owner, s.State, s.Error)
if r.Findings == nil {
return
}
if err := r.Findings.Put(UnansweredFinding(r.Module, s, r.nowTime())); err != nil {
r.logf("the finding of the unanswered first view %s could not be written: %v", s.Tool, err)
}
}
func (r *Reader) clear(s Status) {
if r.Findings == nil {
return
}
if err := r.Findings.Delete(UnansweredFinding(r.Module, s, r.nowTime())); err != nil {
r.logf("the finding of the first view %s could not be cleared: %v", s.Tool, err)
}
}
// Start subscribes each owner's views family (the module's `consumes`: `<owner>.views.<state>`) and asks every first
// view until it is answered, each on a goroutine of its own, so a module starts at once and a reader whose owner is down
// waits, saying so (ADR 0615 §6). It returns when the subscriptions are asked. A view followed or adopted after it is
// begun the same way, and Drop ends one (design 47 G §532).
func (r *Reader) Start(ctx context.Context) {
r.mu.Lock()
r.ctx, r.started = ctx, true
names := append([]string(nil), r.order...)
r.mu.Unlock()
for _, name := range names {
r.begin(name)
}
}
// patternLocked is the event pattern a followed state's events come on: one subscription per pattern, the followers of
// a per-branch state sharing the owner's one event, which HearEvent routes to the branch's view by the key's first token
// (§521). The caller holds mu.
func (r *Reader) patternLocked(name string) string {
if a, ok := r.adopted[name]; ok {
return a.local()
}
f := r.followers[name]
if state, ok := r.events[name]; ok {
return f.Owner + "." + EventName(state)
}
return f.Owner + "." + EventName(f.State)
}
// begin starts the first view of name once the reader started: at once when its event pattern is heard, else once its
// subscription is taken, which it asks when none is asked yet, so no event said after the first view is missed.
func (r *Reader) begin(name string) {
r.mu.Lock()
defer r.mu.Unlock()
if !r.started || r.followers[name] == nil {
return
}
pattern := r.patternLocked(name)
if r.heard[pattern] {
r.askLocked(name)
return
}
if r.asking[pattern] {
return
}
if r.asking == nil {
r.asking, r.heard = map[string]bool{}, map[string]bool{}
}
r.asking[pattern] = true
ctx, f := r.ctx, r.followers[name]
handle := r.handler(ctx, pattern)
go func() {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := subscribe(pattern, handle)
if err == nil {
break
}
r.logf("not hearing %s yet for %s's %s (%v); asking again in %s", pattern, f.Owner, f.State, err, wait)
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
}
r.mu.Lock()
defer r.mu.Unlock()
r.heard[pattern] = true
for _, n := range r.order {
if r.patternLocked(n) == pattern {
r.askLocked(n)
}
}
}()
}
// askLocked asks name's first view on a goroutine of its own, once, until answered or the view is dropped. The caller
// holds mu.
func (r *Reader) askLocked(name string) {
if r.begun[name] != nil {
return
}
if r.begun == nil {
r.begun = map[string]context.CancelFunc{}
}
ctx, cancel := context.WithCancel(r.ctx)
r.begun[name] = cancel
f := r.followers[name]
go func() {
if err := f.Start(ctx); err != nil && ctx.Err() == nil {
r.logf("the first view %s was not answered: %v", f.Tool, err)
}
}()
}
// handler is the handler of one event pattern: the followers on it are read at each event, so a view adopted after the
// subscription hears it and a dropped one no longer does. An event the adopted followers on it hear goes to each
// client's handler; otherwise HearEvent routes it to its state's view.
func (r *Reader) handler(ctx context.Context, pattern string) func(stdio.Envelope) error {
return func(e stdio.Envelope) error {
r.mu.Lock()
var fs []*Follower
for _, n := range r.order {
if r.patternLocked(n) == pattern {
fs = append(fs, r.followers[n])
}
}
adopted := maps.Clone(r.adopted)
r.mu.Unlock()
if h := r.hearAdoptedBy(ctx, fs, adopted); h != nil {
return h(e)
}
return r.hear(ctx, fs)(e)
}
}
// Drop ends the view of state (`<owner>.<state>`), as a part drops a watched branch removed while it runs (design 47 G
// §532, §614): it asks it no more and hears none of its events, its row leaves `<module>_first_views`, and every finding
// it raised (its unanswered first view, its view-waits) is cleared through the reader's findings, the drop and each
// cleared finding said in the log. A state not followed is an error.
func (r *Reader) Drop(state string) error {
r.mu.Lock()
f := r.followers[state]
if f == nil {
r.mu.Unlock()
return fmt.Errorf("%s is not a state this module follows", state)
}
cancel := r.begun[state]
delete(r.followers, state)
delete(r.begun, state)
delete(r.adopted, state)
delete(r.events, state)
delete(r.watchers, state)
r.order = slices.DeleteFunc(r.order, func(n string) bool { return n == state })
if byState := r.byOwner[f.Owner]; byState[f.State] == f {
delete(byState, f.State)
}
r.mu.Unlock()
if cancel != nil {
cancel()
}
r.logf("views: %s drops its view %s", r.Module, state)
s := f.Status()
if s.Raised {
r.clear(s)
r.logf("views: %s clears its finding %s of the dropped view %s", r.Module, UnansweredKind, state)
}
if s.WaitsRaised {
r.waits(s, false)
r.logf("views: %s clears its finding %s of the dropped view %s", r.Module, ViewWaitsKind, state)
}
return nil
}
// hear is the handler of one followed state's events: HearEvent routes it, and a gap asks that first view once more as a
// gap, said in the log.
func (r *Reader) hear(ctx context.Context, fs []*Follower) func(stdio.Envelope) error {
byState := map[string]*Follower{}
for _, f := range fs {
byState[f.State] = f
}
return func(e stdio.Envelope) error {
if err := HearEvent(ctx, byState, e.Body, r.logf); err != nil {
r.logf("views: %s is not a views event, dropped: %v", e.Key, err)
}
return nil
}
}
// hearAdoptedBy is the handler of an event every adopted follower of fs hears, never the first alone (two branches' views
// of the catalog half share one event, design 47 G §532); nil when none of fs is adopted.
func (r *Reader) hearAdoptedBy(ctx context.Context, fs []*Follower, adopted map[string]adoption) func(stdio.Envelope) error {
var heard []func(stdio.Envelope) error
for _, f := range fs {
if a, ok := adopted[f.Owner+"."+f.State]; ok {
heard = append(heard, r.hearAdopted(ctx, a))
}
}
if len(heard) == 0 {
return nil
}
return func(e stdio.Envelope) error {
for _, h := range heard {
_ = h(e)
}
return nil
}
}
// hearAdopted is the handler of an adopted follower's seat event: the client's handler takes it, and what it cannot read is
// said in the log.
func (r *Reader) hearAdopted(ctx context.Context, a adoption) func(stdio.Envelope) error {
return func(e stdio.Envelope) error {
if err := a.hear(ctx, e.Body); err != nil {
r.logf("views: %s is not taken: %v", e.Key, err)
}
return nil
}
}
// Ready is closed once every followed state had its first view.
func (r *Reader) Ready() <-chan struct{} {
r.mu.Lock()
fs := make([]*Follower, 0, len(r.followers))
for _, name := range r.order {
fs = append(fs, r.followers[name])
}
r.mu.Unlock()
done := make(chan struct{})
go func() {
for _, f := range fs {
<-f.Ready()
}
close(done)
}()
return done
}
// Keys is the keys of a followed state (`<owner>.<state>`); a state not followed is an error, never a read of it.
func (r *Reader) Keys(state string) ([]string, error) {
f, err := r.picture(state)
if err != nil {
return nil, err
}
vals := f.Values()
keys := make([]string, 0, len(vals))
for k := range vals {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}
// Get is one key of a followed state.
func (r *Reader) Get(state, key string) ([]byte, bool, error) {
f, err := r.picture(state)
if err != nil {
return nil, false, err
}
v, ok := f.Get(key)
return v, ok, nil
}
// Values is every key of a followed state, by key.
func (r *Reader) Values(state string) (map[string][]byte, error) {
f, err := r.picture(state)
if err != nil {
return nil, err
}
out := map[string][]byte{}
for k, v := range f.Values() {
out[k] = v
}
return out, nil
}
func (r *Reader) picture(state string) (*Follower, error) {
r.mu.Lock()
f := r.followers[state]
r.mu.Unlock()
if f == nil {
return nil, fmt.Errorf("%s is not a state this module follows", state)
}
select {
case <-f.Ready():
return f, nil
default:
return nil, fmt.Errorf("no first view of %s yet: %s has not answered", state, f.Tool)
}
}
// Watch is a followed state as a module's store watches one: it returns once the first view was answered and every
// current value was handed to handle, and then hands every change for as long as the process runs (gone is a key
// deleted). A change heard while the current values are handed over may be handed twice; a handler is idempotent.
func (r *Reader) Watch(state string, handle func(key string, value []byte, gone bool)) error {
r.mu.Lock()
f := r.followers[state]
if f == nil {
r.mu.Unlock()
return fmt.Errorf("%s is not a state this module follows", state)
}
r.mu.Unlock()
<-f.Ready()
r.mu.Lock()
if r.watchers == nil {
r.watchers = map[string][]func(string, []byte, bool){}
}
r.watchers[state] = append(r.watchers[state], handle)
r.mu.Unlock()
for k, v := range f.Values() {
handle(k, v, false)
}
return nil
}
// Follows says whether the state is one this reader follows.
func (r *Reader) Follows(state string) bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.followers[state] != nil
}
// Statuses is each followed state's status, in the order they were added.
func (r *Reader) Statuses() []Status {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]Status, 0, len(r.order))
for _, name := range r.order {
out = append(out, r.followers[name].Status())
}
return out
}
// Tools are the reader's two tools (design 25 § 12.2): `<prefix>_first_views` says where each first view stands and
// `<prefix>_first_view_again` asks one again by hand (a hand act, counted in the status).
func (r *Reader) Tools(prefix string) []stdio.Tool {
return []stdio.Tool{
{Name: prefix + "_first_views",
Description: "Where each first view this module keeps stands (novox/hq ADR 0615): the owner and its tool, the revision the picture holds, when it was asked and answered, how many asks (by hand among them) and whether its condition is raised. (r)",
Run: func(map[string]any) (any, error) { return map[string]any{"views": r.Statuses()}, nil }},
{Name: prefix + "_first_view_again",
Description: "Ask a first view again by hand, for the state named (<owner>.<state>), or every one with none: a hand act, for a picture the operator doubts. Answers where it stands after. (a)",
Input: map[string]any{"type": "object", "properties": map[string]any{
"state": map[string]any{"type": "string", "description": "<owner>.<state>; none asks every first view"}}},
Run: func(args map[string]any) (any, error) {
want, _ := args["state"].(string)
want = strings.TrimSpace(want)
r.mu.Lock()
var fs []*Follower
for _, name := range r.order {
if want == "" || want == name {
fs = append(fs, r.followers[name])
}
}
r.mu.Unlock()
if len(fs) == 0 {
return nil, fmt.Errorf("%q is not a state this module follows", want)
}
// A hand act, recorded before it is done; one that cannot be recorded is
// said and nothing is asked.
why := "the operator asked"
if err := recordHandAct(r.Acts, HandAct{What: "asked a first view again by hand (" + r.Module + ")", Why: why,
Cause: "first-view-again", Verb: "first-view-again"}); err != nil {
return nil, fmt.Errorf("the hand act was not recorded, so nothing was asked again: %w", err)
}
for _, f := range fs {
if err := f.Again(context.Background(), why); err != nil {
return nil, err
}
}
return map[string]any{"views": r.Statuses()}, nil
}},
}
}
// WriterFindings writes a views reader's findings through the part's mesh-sdk writer, as kinds of the reader's own
// (design 47 G §639, §580): the Findings of a reader, never a writer of its own. Body is the part's: it lays a Finding
// out as the finding of the seat `mesh-conditions` (its scope, severity, source and subject are the seat's contract's,
// not this package's); the writer's statement covers the reader's open findings beside its observe's.
type WriterFindings struct {
Writer *Writer
Body func(Finding) FindingBody
}
// Put writes one finding.
func (o WriterFindings) Put(f Finding) error {
return o.Writer.Write(context.Background(), o.Body(f))
}
// Delete clears one finding.
func (o WriterFindings) Delete(f Finding) error {
b := o.Body(f)
return o.Writer.Clear(context.Background(), b.Scope, b.ID, b.Kind, "the part no longer observes it")
}