TL: the teller says answered and reads its answers through teller_answers; the view says reached with each reach it writes (abdf11be)

Builds: design 47 G §431
This commit is contained in:
2026-11-11 14:48:31 +01:00
parent c48c7e723a
commit 99e35b74e6
13 changed files with 236 additions and 15 deletions
@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"sync"
"time"
@@ -51,6 +52,55 @@ func Tools(ask asker, now func() time.Time) []stdio.Tool {
}
}
// AnswersTool is the tool that reads the teller's state answers (ADR 0596 §3: the owner's state is read through its owner's
// tool): one answer by the key of the ask it answers, or every answer when none is named. It reads and writes nothing else.
const AnswersTool = "teller_answers"
// answers is the teller's state answers as the tool reads it.
type answers interface {
Get(key string) (*stdio.StateEntry, error)
Keys() ([]string, error)
}
// AnswerTools is the tool teller_answers over the state answers.
func AnswerTools(s answers) []stdio.Tool {
return []stdio.Tool{{Name: AnswersTool,
Description: "Read the answers the delivery teller kept to the gate's asks, as it wrote them: the answer under one ask's " +
"key (found false when the ask has none), or every answer when no key is named. An answer is the operator's chosen " +
"option, the warrant it came by and the words of it. What the gate reads to merge on the Approve.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"key": map[string]any{"type": "string", "description": "the key of the gate's ask the answer is under; none for every answer"}}},
Run: func(args map[string]any) (any, error) {
key, _ := args["key"].(string)
if key = strings.TrimSpace(key); key != "" {
e, err := s.Get(key)
if err != nil {
return nil, fmt.Errorf("the answer under %s cannot be read: %w", key, err)
}
if e == nil {
return map[string]any{"key": key, "found": false}, nil
}
return map[string]any{"key": key, "found": true, "answer": json.RawMessage(e.Value)}, nil
}
keys, err := s.Keys()
if err != nil {
return nil, fmt.Errorf("the answers cannot be listed: %w", err)
}
sort.Strings(keys)
out := make([]map[string]any, 0, len(keys))
for _, k := range keys {
e, err := s.Get(k)
if err != nil {
return nil, fmt.Errorf("the answer under %s cannot be read: %w", k, err)
}
if e != nil {
out = append(out, map[string]any{"key": k, "answer": json.RawMessage(e.Value)})
}
}
return map[string]any{"answers": out}, nil
}}}
}
// askEnd writes the ends value at key and waits, on a watch of the key, at most bound for the teller's answer.
func askEnd(s stdio.KeptState, bound time.Duration) asker {
return func(key string, value []byte) (ends.End, error) {
@@ -108,6 +158,7 @@ func main() {
// asking it again by hand (ADR 0615, design 25 § 12.2).
tools := append(Tools(askEnd(stdio.State(ends.State), 25*time.Second), time.Now),
views.StatusTools("mesh-delivery-teller", "teller_first_views", "teller_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...)
tools = append(tools, AnswerTools(stdio.State("answers"))...)
if err := stdio.Serve("", tools); err != nil {
fmt.Fprintf(os.Stderr, "[mesh-delivery-teller-tools] %v\n", err)
os.Exit(1)
@@ -0,0 +1,49 @@
package main
import (
"encoding/json"
"fmt"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
)
type fakeAnswers map[string]string
func (f fakeAnswers) Get(key string) (*stdio.StateEntry, error) {
v, ok := f[key]
if !ok {
return nil, nil
}
return &stdio.StateEntry{Key: key, Value: json.RawMessage(v)}, nil
}
func (f fakeAnswers) Keys() ([]string, error) {
var out []string
for k := range f {
out = append(out, k)
}
return out, nil
}
// teller_answers reads the teller's answers: one by the ask's key, an ask with none said as not found, every answer when no key
// is named (ADR 0596 §3: the gate asks this tool, never the bucket).
func TestTellerAnswersReadsOneAnswerOrEveryAnswer(t *testing.T) {
tool := AnswerTools(fakeAnswers{"k1": `{"option":"approve"}`, "k2": `{"option":"decline"}`})[0]
if tool.Name != "teller_answers" {
t.Fatalf("the tool is %s", tool.Name)
}
out, err := tool.Run(map[string]any{"key": "k1"})
if err != nil || fmt.Sprint(out) != `map[answer:{"option":"approve"} found:true key:k1]` {
t.Errorf("one answer: %v, %v", out, err)
}
out, err = tool.Run(map[string]any{"key": "gone"})
if err != nil || fmt.Sprint(out) != `map[found:false key:gone]` {
t.Errorf("no answer: %v, %v", out, err)
}
out, err = tool.Run(map[string]any{})
raw, _ := json.Marshal(out)
if err != nil || string(raw) != `{"answers":[{"answer":{"option":"approve"},"key":"k1"},{"answer":{"option":"decline"},"key":"k2"}]}` {
t.Errorf("every answer: %s, %v", raw, err)
}
}
@@ -187,7 +187,8 @@ func fullConf(dir string) string {
seatAsk, seatCancel, seatCondition, "$JS.API.DIRECT.GET.KV_"+messengerAsks+".$KV."+messengerAsks+".mesh-delivery-teller.>")
// The durable consumer on EVENTS the controller makes for the seat it uses, pulled and acknowledged (issue 1126).
teller = append(teller, "$JS.API.CONSUMER.INFO.EVENTS."+tellerConsumer, "$JS.API.CONSUMER.MSG.NEXT.EVENTS."+tellerConsumer, "$JS.ACK.EVENTS."+tellerConsumer+".>")
teller = append(teller, readOf(viewAnswers)...) // issue 1297 B10: the manifest's reads
teller = append(teller, "mesh.mod.mesh-delivery-teller.event.answered") // the manifest's emits (ADR 0596 §2)
teller = append(teller, readOf(viewAnswers)...) // issue 1297 B10: the manifest's reads
// The assigner's asks by its read tool and its views family (ADR 0615, DLV-M3), and the teller's own first-views and
// findings; the bucket itself is no longer the teller's to read.
teller = append(append(append(teller, viewstest.Grants(assignerModule, assignerModule+".assigner_read_asks", tellerConsumer)...), keepsOf(firstViewsBucket)...), keepsOf(findingsBucket)...)
@@ -434,6 +435,23 @@ func TestTellerCarriesAnAskAndWritesTheAnswer(t *testing.T) { // 1343/AC1, AC2
if a, err := meshdeliveryclient.DecodeAnswer(e.Value()); err != nil || a.Option != "approve" {
t.Errorf("answer %+v err %v", a, err)
}
// The answer is said as the event answered, which the gate hears (ADR 0596 §2).
events, err := b.js.Stream(b.ctx, process.EventsStream)
if err != nil {
t.Fatal(err)
}
var said *jetstream.RawStreamMsg
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
if said, err = events.GetLastMsgForSubject(b.ctx, "mesh.mod.mesh-delivery-teller.event.answered"); err == nil {
break
}
}
if said == nil {
t.Fatalf("the answer was not said as the event answered: %v\n%s", err, logs.String())
}
if got := string(said.Data); !strings.Contains(got, `"key":"`+key+`"`) || !strings.Contains(got, `"option":"approve"`) {
t.Errorf("the event answered is %s", got)
}
// A second ask, withdrawn by the gate, is cancelled at the router.
other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 13, Head: headB}}
key2, value2, ga2 := gateAsk(t, other, other)
@@ -19,6 +19,7 @@ import (
"os/signal"
"syscall"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/process"
"git.novox.be/novox/mesh-sdk/go/views"
@@ -99,6 +100,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return fmt.Errorf("the teller's connection for its tool calls: %w", err)
}
defer calls.Close()
t.Answered = func(key, askID string, answer []byte) error {
return calls.Emit(answeredEvent, Answered{Key: key, Ask: askID, Answer: answer}, stdio.EmitOptions{MessageID: answeredID(key, askID)})
}
reader := &views.ProcessReader{Module: tellerModule, Call: calls.Call, Logf: logger.Printf, Hub: hub}
findings := process.AwaitBucket(ctx, js, conn, findingsBucket, "a first view unanswered past its bound is said in the log alone", logger.Printf)
reader.Findings = func() views.FindingWriter { return sdkfindings.Writer(ctx, findings.KV) }
@@ -1,5 +1,7 @@
package main
import "encoding/json"
// The holders and buckets the teller names, taken from its own manifest (module.json: its name, its `reads`) and the
// states it keeps itself; a unit row checks them against it (TestTheNamesAreTheManifests). No contract names a holder
// (novox/hq ADR 0625 §4, ADR 0634 §1).
@@ -27,3 +29,16 @@ const (
// answerKey is the key, in the teller's answers, of its answer to the ask at askKey: the same string.
func answerKey(askKey string) string { return askKey }
// answeredEvent is the event the teller says after each answer it writes (manifest emits): the key of the ask and the answer.
const answeredEvent = "answered"
// Answered is the body of the event answered.
type Answered struct {
Key string `json:"key"`
Ask string `json:"ask"`
Answer json.RawMessage `json:"answer"`
}
// answeredID is the bus message id of the event for one ask's answer: said twice, it is kept once.
func answeredID(key, askID string) string { return "answered." + key + "." + askID }
@@ -100,6 +100,9 @@ type Teller struct {
// Publish is the bus the asker publishes on, used to ask again an ask the router refused for want of room under a bus
// message id of its own (the bus drops a second message under an id it holds).
Publish func(subject string, body any, id string) error
// Answered says an answer written under an ask's key as the event answered (ADR 0596 §2: the owner says what it
// wrote; a reader hears it and asks the tool teller_answers, never the bucket). Nil says nothing.
Answered func(key, askID string, answer []byte) error
// Now is the clock; nil is time.Now.
Now func() time.Time
// Emit says one of the teller's own events (its manifest's emits, tellerevents) under a bus message id of its own; nil
@@ -427,7 +430,9 @@ func (t *Teller) writeAnswer(key, askID string, body []byte, words string) error
}
if found && answeredAsking(cur) == askID {
t.logf("%s: an answer is there already, and is not written again", key)
return nil
// The event is said again under the same message id, so an answer written whose event was not said is said by the
// next warrant, and a second time inside the bus's window is kept once.
return t.sayAnswered(key, askID, cur)
}
written, err := t.Answers.Write(akey, body, crev, found)
if err != nil {
@@ -438,6 +443,17 @@ func (t *Teller) writeAnswer(key, askID string, body []byte, words string) error
return nil
}
t.logf("%s: %s", key, words)
return t.sayAnswered(key, askID, body)
}
// sayAnswered says the event answered for the answer under key to the asking askID: the key and the answer itself.
func (t *Teller) sayAnswered(key, askID string, answer []byte) error {
if t.Answered == nil {
return nil
}
if err := t.Answered(key, askID, answer); err != nil {
return fmt.Errorf("the answer under %s is written and its event could not be said: %w", key, err)
}
return nil
}
@@ -425,6 +425,40 @@ func TestAWarrantThatHoldsWritesOneAnswerAndASecondWritesNone(t *testing.T) { //
}
}
// ADR 0596 §2: the teller says answered after each answer it writes, under one message id per ask, and says it again for an
// answer written whose event was not said.
func TestEveryAnswerWrittenIsSaidAsTheEventAnswered(t *testing.T) {
b := newBed(t)
var said []string
fail := true
b.t.Answered = func(key, askID string, answer []byte) error {
if fail {
return fmt.Errorf("the bus did not answer")
}
said = append(said, answeredID(key, askID)+" "+string(answer))
return nil
}
key, value, ga := gateAsk(t, twoHeads(), twoHeads())
b.gate[key] = value
b.t.OnAsk(key, 1, value)
w := warrantFor(ga.Ask, "approve")
b.deliver(t, w)
if len(said) != 0 || b.answers.writes != 1 {
t.Fatalf("said %v with the bus down, written %d times", said, b.answers.writes)
}
fail = false
if err := b.t.writeAnswer(key, ga.Ask.ID, b.answers.written[answerKey(key)], "again"); err != nil { // the answer is there; its event was not said
t.Fatal(err)
}
if len(said) != 1 || b.answers.writes != 1 {
t.Fatalf("said %v, written %d times; want the event said once and the answer written once", said, b.answers.writes)
}
raw := b.answers.written[answerKey(key)]
if want := answeredID(key, ga.Ask.ID) + " " + string(raw); said[0] != want {
t.Errorf("said %q, want %q", said[0], want)
}
}
func TestAWarrantThatDoesNotHoldWritesNoAnswerAndRaisesTheCondition(t *testing.T) { // 1343/AC3
other := []meshdeliveryclient.Head{{Repository: "novox/mesh-delivery", Number: 12, Head: headB}, {Repository: "novox/mesh-sdk", Number: 4, Head: headB}}
cases := map[string]func(t *testing.T) (*bed, string, oc.Warrant){
@@ -45,12 +45,12 @@ type manifest struct {
Version string `json:"version"`
Slug string `json:"slug"`
Tools []string `json:"tools"`
Emits []string `json:"emits"`
Uses []string `json:"uses"`
State []state `json:"state"`
Reads []string `json:"reads"`
Invokes []string `json:"invokes"`
Consumes []string `json:"consumes"`
Emits []string `json:"emits"`
Streams []stream `json:"streams"`
OwnSecrets map[string]string `json:"own-secrets"`
SecretsOwner string `json:"secrets-owner"`
@@ -124,11 +124,12 @@ func TestManifestDeclarations(t *testing.T) { // 1240/AC1
t.Errorf("secrets-owner = %q", m.SecretsOwner)
}
// 774: teller_end_ask, served by its tools bundle through the state ends; the process serves none.
if want := []string{"teller_end_ask", "teller_first_views", "teller_first_view_again"}; !reflect.DeepEqual(m.Tools, want) {
if want := []string{"teller_end_ask", "teller_answers", "teller_first_views", "teller_first_view_again"}; !reflect.DeepEqual(m.Tools, want) {
t.Errorf("tools = %v, want %v", m.Tools, want)
}
// Its one event: an assigner ask ended at the channel without an answer, heard by the assigner (issue 1966, tellerevents).
if want := []string{tellerevents.AskEnded}; !reflect.DeepEqual(m.Emits, want) {
// Its two events: each answer it writes (ADR 0596 §2), and an assigner ask ended at the channel without an answer, heard by
// the assigner (issue 1966, tellerevents).
if want := []string{"answered", tellerevents.AskEnded}; !reflect.DeepEqual(m.Emits, want) {
t.Errorf("emits = %v, want %v", m.Emits, want)
}
// design 47 G §383, §387: the stream <module>-findings, 7 days and 16 MiB; §368: version is MAJOR.MINOR.
+2
View File
@@ -4,6 +4,7 @@
"slug": "teller",
"tools": [
"teller_end_ask",
"teller_answers",
"teller_first_views",
"teller_first_view_again"
],
@@ -24,6 +25,7 @@
}
],
"emits": [
"answered",
"ask-ended"
],
"state": [
@@ -827,7 +827,7 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
t.Fatal(err)
}
kvs := map[string]jetstream.KeyValue{}
for _, name := range []string{view.SourceBucket, view.AssignerWantsBucket, view.HaveBucket} {
for _, name := range []string{view.SourceBucket, view.AssignerWantsBucket, view.HaveBucket, viewstate.ReachBucket} {
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: 5})
if err != nil {
t.Fatal(err)
@@ -869,7 +869,7 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
t.Fatal(err)
}
time.Sleep(time.Second)
if info, err := events.Info(ctx, jetstream.WithSubjectFilter("mesh.mod.mesh-delivery-view.event.>")); err != nil || len(info.State.Subjects) != 0 {
if info, err := events.Info(ctx, jetstream.WithSubjectFilter("mesh.mod.mesh-delivery-view.event."+DeliveredEventType)); err != nil || len(info.State.Subjects) != 0 {
t.Fatalf("an event was said before the revision was delivered: %v %+v", err, info.State)
}
if _, err := kvs[view.HaveBucket].Put(ctx, "ace.web", good); err != nil {
@@ -889,6 +889,21 @@ func TestProcessWholeSaysTheSourceRevisionIsDelivered(t *testing.T) {
if msg.Header.Get("x-source") != "mesh-delivery-view" || msg.Header.Get("x-event-id") == "" {
t.Errorf("the headers = %v", msg.Header)
}
// The reach it wrote is said as the event reached, with the reach as written (ADR 0596 §2).
var rm *jetstream.RawStreamMsg
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline) && rm == nil; time.Sleep(100 * time.Millisecond) {
rm, _ = events.GetLastMsgForSubject(ctx, "mesh.mod.mesh-delivery-view.event."+ReachedEventType)
}
if rm == nil {
t.Fatal("the reach of novox/web was written and never said as the event reached")
}
var re ReachedEvent
if err := json.Unmarshal(rm.Data, &re); err != nil || re.Key != "novox/web" {
t.Fatalf("the event reached = %s, %v", rm.Data, err)
}
if stored, gerr := kvs[viewstate.ReachBucket].Get(ctx, re.Key); gerr != nil || string(stored.Value()) != string(re.Reach) {
t.Errorf("the event carries %s, the state reach holds %v (%v)", re.Reach, stored, gerr)
}
}
// 1906/D10, whole on the bus (through run): a want that differs from its have is said `moving`, the have reaching it running and
@@ -102,9 +102,15 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
return err
})
model.SetReachWriter(func(w view.ReachWrite) error {
err := putReach(ctx, js, w)
rev, err := putReach(ctx, js, w)
if err != nil {
logger.Printf("the reach of %s could not be written (written again at the next change the view takes in): %v", w.Key, err)
return err
}
// What the view wrote it says (ADR 0596 §2): a reader hears the reach change and asks view_reach, never the bucket.
err = emitEvent(ctx, js, cred, ReachedEventType, ReachedEvent{Key: w.Key, Reach: w.Value}, fmt.Sprintf("reached.%s.%d", w.Key, rev))
if err != nil {
logger.Printf("the reach of %s is written and could not be said (said again at the next change the view takes in): %v", w.Key, err)
}
return err
})
@@ -172,13 +178,22 @@ func (w kvWriter) Put(key string, value []byte) error {
}
// putReach writes a reach value under its key in the view's own state reach (ADR 0556 clause 2), its only writer.
func putReach(ctx context.Context, js jetstream.JetStream, w view.ReachWrite) error {
func putReach(ctx context.Context, js jetstream.JetStream, w view.ReachWrite) (uint64, error) {
kv, err := js.KeyValue(ctx, viewstate.ReachBucket)
if err != nil {
return fmt.Errorf("the bucket %s: %w", viewstate.ReachBucket, err)
return 0, fmt.Errorf("the bucket %s: %w", viewstate.ReachBucket, err)
}
_, err = kv.Put(ctx, w.Key, w.Value)
return err
return kv.Put(ctx, w.Key, w.Value)
}
// ReachedEventType is the event the view says after it writes a repository's reach: mesh.mod.mesh-delivery-view.event.reached
// (ADR 0596 §2). Its body is ReachedEvent.
const ReachedEventType = "reached"
// ReachedEvent is the body of the event reached: the key of the reach (the repository's) and the reach as the view wrote it.
type ReachedEvent struct {
Key string `json:"key"`
Reach json.RawMessage `json:"reach"`
}
// The view's own states of its first views (views.StatusState) and of its findings, where the condition keeper raises a
+2 -2
View File
@@ -139,8 +139,8 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7
t.Errorf("consumes = %v, want %v", m.Consumes, want)
}
// The one event the view says: a source revision is delivered (1906/D9, ADR 0539 §17), derived by it from the wants and the haves.
if !reflect.DeepEqual(m.Emits, []string{"delivered", "moving", "settled", "stuck"}) {
t.Errorf("emits = %v, want exactly delivered, moving, settled and stuck (1906/D9, D10)", m.Emits)
if !reflect.DeepEqual(m.Emits, []string{"delivered", "reached", "moving", "settled", "stuck"}) {
t.Errorf("emits = %v, want exactly delivered, reached (ADR 0596 §2), moving, settled and stuck (1906/D9, D10)", m.Emits)
}
// The view uses no build seat: it reads their workers by the broker's named grant and publishes no ask (ADR 0525 §2).
if len(m.Uses) != 0 {
+1
View File
@@ -31,6 +31,7 @@
],
"emits": [
"delivered",
"reached",
"moving",
"settled",
"stuck"