Files
mesh-sdk/go/views/writer.go
T
jschoubben 3a438786e3 G V-8: the finding writer takes the generated finding type through views.FindingBody and exports CheckFinding
views.FindingBody becomes the interface of the accessors the seat generator emits
(Kind/SetKind per field, one Subject/SetSubject pair); the writer is given the contract's
constructors of the finding, finding-cleared, findings-stated and findings-asked bodies and
holds none of their layouts. WriterFindings takes only the constructor and lays the views
reader's four findings out itself (scope module, id escaped, urgent for view-too-large,
source views, subject the module on its node). views.CheckFinding(b) error is the one check
of the key's parts, the kind and the owner and waiting-on forms, run before every write.

Builds: design 47 G §686, §687, §689, §640, §688, A-1 (.work/47-G/dev2-ledger.md)
Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 21:33:23 +01:00

634 lines
21 KiB
Go

package views
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"regexp"
"slices"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// The finding writer (design 47 G §461, §485, §503): the one writer every observer uses to keep its findings in its own
// state `findings` and to say them as events (§383, §384). It names no seat and no module: the observer hands in the
// subjects of `finding-written`, `finding-cleared`, `findings-stated` and `findings-asked`, which the generated
// contract of the seat that defines them makes from its module name and node (§461, §439).
// The time constants of the writer (design 47 G §383, §485).
const (
// PublishRetryFor is how long an event the bus did not acknowledge is sent again before it is logged as an error.
PublishRetryFor = time.Minute
// ObserveRetryEvery is how often a failing observe is called again until a set is said.
ObserveRetryEvery = time.Minute
)
// FindingBody is one finding as the seat `mesh-conditions` lays it out (design 47 G §686, §640): the writer keeps no copy
// of that layout, it takes the type the seat generator emits through these accessors. A getter and a setter for each field
// the writer reads or fills; the subject is one pair, its fields as plain values in the generated struct's order (design 47
// G A-1). A body also marshals as JSON to its layout, which is how it is kept and said.
type FindingBody interface {
Kind() string
SetKind(string)
Scope() string
SetScope(string)
ID() string
SetID(string)
At() time.Time
SetAt(time.Time)
Since() time.Time
SetSince(time.Time)
Severity() string
SetSeverity(string)
Summary() string
SetSummary(string)
Headline() string
SetHeadline(string)
Source() string
SetSource(string)
Subject() (also []string, id, machine, scope string)
SetSubject(also []string, id, machine, scope string)
Observer() string
SetObserver(string)
}
// ClearedBody is the body of `finding-cleared`, as the generated contract's type takes its fields (design 47 G A-1).
type ClearedBody interface {
SetKind(string)
SetScope(string)
SetID(string)
SetAt(time.Time)
SetWhy(string)
}
// StatedBody is the body of `findings-stated`, as the generated contract's type takes its fields (design 47 G A-1):
// every finding the observer observes now, and each key it cannot judge yet, with its since and its bound (a duration
// such as 5m).
type StatedBody interface {
SetAt(time.Time)
SetAsked(string)
// SetKinds are the kinds the statement covers (design 47 G §580); none means every kind.
SetKinds([]string)
AddFinding(FindingBody)
AddPending(key string, since time.Time, bound string)
}
// AskedBody is the body of `findings-asked` the writer reads: the ask's id and the module asked, none asking every
// observer (design 47 G A-1).
type AskedBody interface {
Ask() string
Module() string
}
// FindingKey is the key of the finding of scope, id and kind: `<scope>.<id>.<kind>` (novox/hq ADR 0564 §2).
func FindingKey(scope, id, kind string) string { return scope + "." + id + "." + kind }
func keyOf(b FindingBody) string { return FindingKey(b.Scope(), b.ID(), b.Kind()) }
// ViewKinds are the finding kinds a views reader writes through the part's writer (design 47 G §639).
var ViewKinds = []string{ViewWaitsKind, UnansweredKind, UnsaidKind, TooLargeKind}
var (
sessionForm = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*@[a-z0-9][a-z0-9-]*$`)
seatForm = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
authorForm = regexp.MustCompile(`^forge:[A-Za-z0-9._-]+$`)
)
// formOfWho refuses a finding's `owner` or `waiting-on` of none of the three forms (design 47 G §678): a session
// (who@node), a seat's name, or a commit's author (forge:<account>); empty is none.
func formOfWho(field, v string) error {
if v == "" || sessionForm.MatchString(v) || seatForm.MatchString(v) || authorForm.MatchString(v) {
return nil
}
return fmt.Errorf("the finding's %s %q is none of a session (who@node), a seat's name or a commit's author (forge:<account>)", field, v)
}
// PendingKey is a key the observer cannot judge yet since its start: its `since` and its bound (a duration such as 5m).
type PendingKey struct {
Key string `json:"key"`
Since time.Time `json:"since"`
Bound string `json:"bound"`
}
// Observed is what an observer's own truth says now: the findings it observes and the keys it cannot judge yet.
type Observed struct {
Findings []FindingBody
Pending []PendingKey
}
// FindingState is the observer's own state `findings` (stdio.State and process.State both are one).
type FindingState interface {
Get(key string) (*stdio.StateEntry, error)
Put(key string, value any) (uint64, error)
Delete(key string) error
Keys() ([]string, error)
}
// WriterSubjects are the subjects of the four events, as the seat's generated contract makes them for the observer's
// module and node.
type WriterSubjects struct {
Written, Cleared, Stated, Asked string
}
// WriterConfig makes a Writer.
type WriterConfig struct {
// Module is the observer's module name: it fills the observer of a finding and tells an ask for another module.
Module string
// State is the observer's own state `findings`.
State FindingState
// Kinds are the finding kinds this writer owns (design 47 G §580): it refuses a Write or a Clear of another kind,
// naming it, its statement carries them as `kinds` and deletes only keys of them. None given, it owns every kind.
// The kinds of a views reader (ViewKinds) are the reader's own and are always the writer's besides.
Kinds []string
// JS is the bus connection's JetStream; Conn, when given, lets the writer hear the connection made again.
JS jetstream.JetStream
Conn *nats.Conn
// Subjects are the four events' subjects.
Subjects WriterSubjects
// NewFinding, NewCleared, NewStated and NewAsked make an empty body of the generated contract's types (design 47 G
// §686, §640, A-1): the writer holds no layout of its own. All four are needed.
NewFinding func() FindingBody
NewCleared func() ClearedBody
NewStated func() StatedBody
NewAsked func() AskedBody
// Node is the machine the module runs on, which a views reader's findings name in their subject (design 47 G §687);
// none given, MESH_NODE.
Node string
// Observe answers the findings the observer observes now and its pending keys, or an error. It is called under the
// writer's one lock, so it must not call Write or Clear.
Observe func(ctx context.Context) (Observed, error)
// Send puts a set larger than one bus message in the observer's sent store and answers what to publish instead
// (stateobjects.Sender.Send); nil sends the body as it is.
Send func(label string, body []byte) ([]byte, error)
// Logf is the observer's journal.
Logf func(string, ...any)
// Now is the clock, Wait pauses for d and answers false when ctx ended; nil are the real ones.
Now func() time.Time
Wait func(ctx context.Context, d time.Duration) bool
// Publish sends one message and answers when the bus acknowledged it, and Listen makes a consumer that delivers only
// the asks published after it is made; nil are JS's.
Publish func(ctx context.Context, subject string, data []byte, id string) error
Listen func(ctx context.Context, subject string, handle func(data []byte)) (stop func(), err error)
}
// Writer keeps an observer's findings and says them (design 47 G §461, §485). Its acts, Write, Clear and a statement,
// run one at a time in the order they are asked.
type Writer struct {
cfg WriterConfig
mu sync.Mutex // the one lock of the acts
seq uint64
ctl sync.Mutex // guards the consumer, the asks and the loop's context
stop func()
asks []string
want bool
wake chan struct{}
run context.Context
end context.CancelFunc
}
// NewWriter is the writer for cfg.
func NewWriter(cfg WriterConfig) (*Writer, error) {
switch {
case cfg.Module == "":
return nil, errors.New("a finding writer needs its module's name")
case cfg.State == nil:
return nil, errors.New("a finding writer needs the state `findings`")
case cfg.Observe == nil:
return nil, errors.New("a finding writer needs the observe function")
case cfg.Subjects.Written == "" || cfg.Subjects.Cleared == "" || cfg.Subjects.Stated == "" || cfg.Subjects.Asked == "":
return nil, errors.New("a finding writer needs the subjects of finding-written, finding-cleared, findings-stated and findings-asked")
case cfg.NewFinding == nil || cfg.NewCleared == nil || cfg.NewStated == nil || cfg.NewAsked == nil:
return nil, errors.New("a finding writer needs the generated contract's constructors of the finding, finding-cleared, findings-stated and findings-asked bodies")
case cfg.JS == nil && (cfg.Publish == nil || cfg.Listen == nil):
return nil, errors.New("a finding writer needs the bus connection's JetStream")
}
if cfg.Node == "" {
cfg.Node = os.Getenv("MESH_NODE")
}
if cfg.Now == nil {
cfg.Now = func() time.Time { return time.Now().UTC() }
}
if cfg.Wait == nil {
cfg.Wait = func(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
}
if cfg.Logf == nil {
cfg.Logf = func(string, ...any) {}
}
if cfg.Publish == nil {
cfg.Publish = jetStreamPublish(cfg.JS)
}
if cfg.Listen == nil {
cfg.Listen = jetStreamListen(cfg.JS)
}
return &Writer{cfg: cfg, wake: make(chan struct{}, 1)}, nil
}
func jetStreamPublish(js jetstream.JetStream) func(context.Context, string, []byte, string) error {
return func(ctx context.Context, subject string, data []byte, id string) error {
pub, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
_, err := js.PublishMsg(pub, &nats.Msg{Subject: subject, Data: data}, jetstream.WithMsgID(id))
return err
}
}
// jetStreamListen makes an ephemeral consumer of its own on the stream that holds subject, from the next message on.
func jetStreamListen(js jetstream.JetStream) func(context.Context, string, func([]byte)) (func(), error) {
return func(ctx context.Context, subject string, handle func([]byte)) (func(), error) {
stream, err := js.StreamNameBySubject(ctx, subject)
if err != nil {
return nil, fmt.Errorf("the stream of %s: %w", subject, err)
}
cons, err := js.CreateConsumer(ctx, stream, jetstream.ConsumerConfig{
FilterSubject: subject, DeliverPolicy: jetstream.DeliverNewPolicy, AckPolicy: jetstream.AckNonePolicy,
InactiveThreshold: 10 * time.Minute,
})
if err != nil {
return nil, fmt.Errorf("a consumer of %s: %w", subject, err)
}
cc, err := cons.Consume(func(m jetstream.Msg) { handle(m.Data()) })
if err != nil {
return nil, fmt.Errorf("consuming %s: %w", subject, err)
}
return cc.Stop, nil
}
}
// Start makes the consumer of `findings-asked` and then states the findings now, and again on each ask and each time the
// connection is made again (design 47 G §384, §485). It returns once the consumer exists; the statements run in the
// writer's own goroutine until ctx ends or Close.
func (w *Writer) Start(ctx context.Context) error {
w.ctl.Lock()
defer w.ctl.Unlock()
if w.run != nil {
return errors.New("the finding writer is started")
}
w.run, w.end = context.WithCancel(ctx)
if err := w.listen(); err != nil {
w.end()
w.run = nil
return err
}
w.asks, w.want = append(w.asks, ""), true
if w.cfg.Conn != nil {
prev := w.cfg.Conn.Opts.ReconnectedCB
w.cfg.Conn.SetReconnectHandler(func(nc *nats.Conn) {
if prev != nil {
prev(nc)
}
w.Reconnected()
})
}
go w.loop(w.run)
w.signal()
return nil
}
// Close ends the writer's consumer and its statements.
func (w *Writer) Close() {
w.ctl.Lock()
defer w.ctl.Unlock()
if w.stop != nil {
w.stop()
w.stop = nil
}
if w.end != nil {
w.end()
}
}
func (w *Writer) listen() error {
stop, err := w.cfg.Listen(w.run, w.cfg.Subjects.Asked, w.heard)
if err != nil {
return err
}
if w.stop != nil {
w.stop()
}
w.stop = stop
return nil
}
// Reconnected is told that the bus connection was made again: the writer makes its consumer again, from the newest ask,
// and states its findings again (design 47 G §485).
func (w *Writer) Reconnected() {
w.ctl.Lock()
defer w.ctl.Unlock()
if w.run == nil || w.run.Err() != nil {
return
}
if err := w.listen(); err != nil {
w.cfg.Logf("the finding writer could not make its consumer of %s again: %v", w.cfg.Subjects.Asked, err)
}
w.asks, w.want = append(w.asks, ""), true
w.signal()
}
func (w *Writer) signal() {
select {
case w.wake <- struct{}{}:
default:
}
}
// heard is an ask: answered when it names no module or this one.
func (w *Writer) heard(data []byte) {
a := w.cfg.NewAsked()
if err := json.Unmarshal(data, a); err != nil {
w.cfg.Logf("an ask for findings could not be read: %v", err)
return
}
if a.Module() != "" && a.Module() != w.cfg.Module {
return
}
w.ctl.Lock()
w.asks, w.want = append(w.asks, a.Ask()), true
w.ctl.Unlock()
w.signal()
}
func (w *Writer) loop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-w.wake:
}
w.ctl.Lock()
asks, want := w.asks, w.want
w.asks, w.want = nil, false
w.ctl.Unlock()
if !want {
continue
}
asked := ""
if len(asks) > 0 {
asked = asks[len(asks)-1]
}
for {
err := w.State(ctx, asked)
if err == nil {
break
}
w.cfg.Logf("the findings could not be stated, no set said, trying again in %s: %v", ObserveRetryEvery, err)
if !w.cfg.Wait(ctx, ObserveRetryEvery) {
return
}
}
}
}
// owns says whether the writer's statement covers kind: every kind when none were given (design 47 G §580), else its
// own and the views reader's.
func (w *Writer) owns(kind string) bool {
return len(w.cfg.Kinds) == 0 || slices.Contains(w.cfg.Kinds, kind) || slices.Contains(ViewKinds, kind)
}
// statedKinds are the kinds a statement names: none when the writer owns every kind.
func (w *Writer) statedKinds() []string {
if len(w.cfg.Kinds) == 0 {
return nil
}
k := append(slices.Clone(w.cfg.Kinds), ViewKinds...)
slices.Sort(k)
return slices.Compact(k)
}
// ownsKey says whether key is one of a kind the writer owns: its last part is the kind (`<scope>.<id>.<kind>`).
func (w *Writer) ownsKey(key string) bool {
if len(w.cfg.Kinds) == 0 {
return true
}
i := strings.LastIndex(key, ".")
return i >= 0 && w.owns(key[i+1:])
}
func (w *Writer) now() time.Time { return w.cfg.Now().UTC() }
// unsaid is a finding as compared to say whether what it says changed: not the time it was last seen, nor what saw it.
func unsaid(f FindingBody) string {
raw, _ := json.Marshal(f)
var m map[string]any
_ = json.Unmarshal(raw, &m)
delete(m, "at")
delete(m, "run")
raw, _ = json.Marshal(m)
return string(raw)
}
func (w *Writer) read(key string) (FindingBody, error) {
e, err := w.cfg.State.Get(key)
if err != nil {
return nil, err
}
if e == nil {
return nil, nil
}
old := w.cfg.NewFinding()
if err := json.Unmarshal(e.Value, old); err != nil {
return nil, fmt.Errorf("the finding %s in the state findings: %w", key, err)
}
return old, nil
}
// CheckFinding refuses a finding the keeper could not make a condition of, naming the field of the first rule it breaks:
// the key's three parts (`scope`, `id` and `kind`, each given, the kind with no dot, since the kind is the key's last
// part), and the forms of `owner` and `waiting-on` (design 47 G §678, §689). The writer refuses such a finding before it
// writes it, and the keeper judges the findings others write with it.
func CheckFinding(b FindingBody) error {
switch {
case strings.TrimSpace(b.Scope()) == "":
return errors.New("the finding's scope is not given")
case strings.TrimSpace(b.ID()) == "":
return errors.New("the finding's id is not given")
case strings.TrimSpace(b.Kind()) == "":
return errors.New("the finding's kind is not given")
case strings.Contains(b.Kind(), "."):
return fmt.Errorf("the finding's kind %q holds a dot, and the kind is the last part of its key", b.Kind())
}
raw, err := json.Marshal(b)
if err != nil {
return fmt.Errorf("the finding could not be read: %w", err)
}
var who struct {
Owner string `json:"owner"`
WaitingOn string `json:"waiting-on"`
}
if err := json.Unmarshal(raw, &who); err != nil {
return fmt.Errorf("the finding could not be read: %w", err)
}
if err := formOfWho("owner", who.Owner); err != nil {
return err
}
return formOfWho("waiting-on", who.WaitingOn)
}
// complete fills what the observer leaves to the writer and keeps the key's `since` where the key exists (design 47 G §384).
func (w *Writer) complete(f FindingBody) (FindingBody, FindingBody, error) {
if err := CheckFinding(f); err != nil {
return f, nil, err
}
if !w.owns(f.Kind()) {
return f, nil, fmt.Errorf("the finding kind %q is not one this writer owns (%v)", f.Kind(), w.cfg.Kinds)
}
old, err := w.read(keyOf(f))
if err != nil {
return f, nil, err
}
if f.Observer() == "" {
f.SetObserver(w.cfg.Module)
}
at := f.At()
if at.IsZero() {
at = w.now()
}
f.SetAt(at.UTC())
since := f.Since()
switch {
case old != nil && !old.Since().IsZero():
since = old.Since()
case since.IsZero():
since = f.At()
}
f.SetSince(since.UTC())
return f, old, nil
}
// Write keeps the finding in its key and says `finding-written` only when what the finding says changed (design 47 G
// §383, §461). The key's `since` stays where the key exists.
func (w *Writer) Write(ctx context.Context, f FindingBody) error {
w.mu.Lock()
defer w.mu.Unlock()
f, old, err := w.complete(f)
if err != nil {
return err
}
if _, err := w.cfg.State.Put(keyOf(f), f); err != nil {
return fmt.Errorf("writing the finding %s: %w", keyOf(f), err)
}
if old != nil && unsaid(old) == unsaid(f) {
return nil
}
raw, _ := json.Marshal(f)
w.say(ctx, w.cfg.Subjects.Written, "finding-written", keyOf(f), raw)
return nil
}
// Clear deletes the finding's key and says `finding-cleared`; a key that does not exist is deleted of nothing and is said
// all the same, so a keeper holding the finding clears it (design 47 G §485).
func (w *Writer) Clear(ctx context.Context, scope, id, kind, why string) error {
w.mu.Lock()
defer w.mu.Unlock()
key := FindingKey(scope, id, kind)
if !w.owns(kind) {
return fmt.Errorf("the finding kind %q is not one this writer owns (%v)", kind, w.cfg.Kinds)
}
if err := w.cfg.State.Delete(key); err != nil {
return fmt.Errorf("clearing the finding %s: %w", key, err)
}
cleared := w.cfg.NewCleared()
cleared.SetKind(kind)
cleared.SetScope(scope)
cleared.SetID(id)
cleared.SetAt(w.now())
cleared.SetWhy(why)
raw, _ := json.Marshal(cleared)
w.say(ctx, w.cfg.Subjects.Cleared, "finding-cleared", key, raw)
return nil
}
// State judges every finding anew with observe, writes each stated finding's key, deletes each key neither stated nor
// pending, and says `findings-stated` (design 47 G §384, §461); asked is the ask it answers, none at a start. An error of
// observe says no set.
func (w *Writer) State(ctx context.Context, asked string) error {
w.mu.Lock()
defer w.mu.Unlock()
obs, err := w.cfg.Observe(ctx)
if err != nil {
return fmt.Errorf("observe: %w", err)
}
keep := map[string]bool{}
body := w.cfg.NewStated()
body.SetAt(w.now())
body.SetAsked(asked)
body.SetKinds(w.statedKinds())
for _, f := range obs.Findings {
f, _, err := w.complete(f)
if err != nil {
return err
}
if _, err := w.cfg.State.Put(keyOf(f), f); err != nil {
return fmt.Errorf("writing the finding %s: %w", keyOf(f), err)
}
keep[keyOf(f)] = true
body.AddFinding(f)
}
for _, p := range obs.Pending {
keep[p.Key] = true
body.AddPending(p.Key, p.Since, p.Bound)
}
keys, err := w.cfg.State.Keys()
if err != nil {
return fmt.Errorf("the keys of the state findings: %w", err)
}
slices.Sort(keys)
for _, k := range keys {
// a views reader's open findings are stated beside observe's (design 47 G §639)
if i := strings.LastIndex(k, "."); !keep[k] && i >= 0 && slices.Contains(ViewKinds, k[i+1:]) {
if old, err := w.read(k); err == nil && old != nil {
keep[k] = true
body.AddFinding(old)
}
}
if !keep[k] && w.ownsKey(k) {
if err := w.cfg.State.Delete(k); err != nil {
return fmt.Errorf("deleting the finding %s: %w", k, err)
}
}
}
raw, _ := json.Marshal(body)
if w.cfg.Send != nil {
if raw, err = w.cfg.Send("findings-stated."+w.cfg.Module, raw); err != nil {
w.cfg.Logf("the findings-stated of %s could not be sent, the next statement covers it: %v", w.cfg.Module, err)
return nil
}
}
w.say(ctx, w.cfg.Subjects.Stated, "findings-stated", w.cfg.Module, raw)
return nil
}
// say publishes one event awaiting the bus's acknowledgement, sent again for PublishRetryFor when unacknowledged, then
// written in the journal as an error naming what it said (design 47 G §383); the next statement covers it.
func (w *Writer) say(ctx context.Context, subject, event, what string, data []byte) {
w.seq++
id := fmt.Sprintf("%s-%s-%d-%d", w.cfg.Module, event, w.now().UnixNano(), w.seq)
start := w.now()
for delay := time.Second; ; delay = min(delay*2, 10*time.Second) {
err := w.cfg.Publish(ctx, subject, data, id)
if err == nil {
return
}
if w.now().Sub(start) >= PublishRetryFor || ctx.Err() != nil || !w.cfg.Wait(ctx, delay) {
w.cfg.Logf("error: %s for %s was not acknowledged by the bus, the next statement covers it: %v", event, what, err)
return
}
}
}