O-6: C-S8: stateobjects.Tools, the four tools that read and drop a module's state objects and sent objects, each drop with by and why as a hand act first (milestone 47/G, issue 1966, ADR 0666 section 25)
Builds: design 47 G §60, §84, §258 Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
This commit is contained in:
@@ -23,6 +23,7 @@ type memory struct {
|
||||
keys map[string]Entry
|
||||
objects map[string][]byte
|
||||
failPuts bool
|
||||
modified map[string]time.Time
|
||||
most int
|
||||
}
|
||||
|
||||
@@ -79,6 +80,11 @@ func (m *memory) Put(name string, data []byte) error {
|
||||
return errors.New("the store is full")
|
||||
}
|
||||
m.objects[name] = data
|
||||
if m.modified != nil {
|
||||
if _, ok := m.modified[name]; !ok {
|
||||
m.modified[name] = time.Time{}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -114,7 +120,7 @@ func (o memObjects) List() ([]Object, error) {
|
||||
defer o.m.mu.Unlock()
|
||||
var out []Object
|
||||
for n, d := range o.m.objects {
|
||||
out = append(out, Object{Name: n, Size: int64(len(d))})
|
||||
out = append(out, Object{Name: n, Size: int64(len(d)), Modified: o.m.modified[n]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package stateobjects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
// SentMaxAge is how long a store of sent objects keeps one: transport, not kept data (ADR 0595 §4), so the store ages
|
||||
// its objects out by itself and the drop tool is for the case where it does not.
|
||||
const SentMaxAge = 7 * 24 * time.Hour
|
||||
|
||||
// State is one state of a module with its objects: the state's name (its local name in the manifest), its values and the
|
||||
// fill of its object store against its cap.
|
||||
type State struct {
|
||||
Name string
|
||||
Values Values
|
||||
Fill func() (buscalls.Fill, error)
|
||||
}
|
||||
|
||||
// Sent is a module's store of sent objects, read and dropped by the module's own tools.
|
||||
type Sent struct {
|
||||
Objects Objects
|
||||
Fill func() (buscalls.Fill, error)
|
||||
// MaxAge is the store's age (SentMaxAge when zero), which the listing compares each object's age with.
|
||||
MaxAge time.Duration
|
||||
}
|
||||
|
||||
// Tools are the four tools of a module that keeps large values or sends large bodies (design 25 § 12.2, ADR 0666 §25),
|
||||
// named for the module the caller gives: `<module>_state_objects` and `<module>_state_objects_drop` over the states, and
|
||||
// `<module>_sent_objects` and `<module>_sent_objects_drop` over the store of sent objects. The two reading tools read
|
||||
// everything the store holds; the two dropping tools remove only what is no longer live, each only with the person who
|
||||
// asks (`by`) and why, recorded as a hand act before anything is removed, and refuse one that cannot be recorded.
|
||||
func Tools(module string, states []State, sent *Sent, acts views.HandActs, now func() time.Time) []stdio.Tool {
|
||||
var tools []stdio.Tool
|
||||
if len(states) > 0 {
|
||||
tools = append(tools, stateObjectsTool(module, states), stateDropTool(module, states, acts))
|
||||
}
|
||||
if sent != nil {
|
||||
tools = append(tools, sentObjectsTool(module, *sent, now), sentDropTool(module, *sent, acts, now))
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func pick(states []State, args map[string]any) (State, error) {
|
||||
name, _ := args["state"].(string)
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" && len(states) == 1 {
|
||||
return states[0], nil
|
||||
}
|
||||
for _, s := range states {
|
||||
if s.Name == name {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
var names []string
|
||||
for _, s := range states {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
return State{}, fmt.Errorf("state names one of %s, not %q", strings.Join(names, ", "), name)
|
||||
}
|
||||
|
||||
var stateArg = map[string]any{"type": "string", "description": "the state whose object store is read: its local name in the manifest"}
|
||||
|
||||
func stateObjectsTool(module string, states []State) stdio.Tool {
|
||||
return stdio.Tool{
|
||||
Name: module + "_state_objects",
|
||||
Description: "The objects that hold the large values of " + module + "'s states: each object, its size, the key whose value names it " +
|
||||
"or key-gone when the key was deleted (such an object stays until " + module + "_state_objects_drop removes it on a person's word), " +
|
||||
"and how full the object store is against its cap.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{"state": stateArg}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
s, err := pick(states, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kept, err := s.Values.Kept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"state": s.Name, "objects": kept}
|
||||
gone := 0
|
||||
for _, k := range kept {
|
||||
if k.KeyGone {
|
||||
gone++
|
||||
}
|
||||
}
|
||||
out["key-gone"] = gone
|
||||
if s.Fill != nil {
|
||||
if f, err := s.Fill(); err == nil {
|
||||
out["fill"] = f
|
||||
} else {
|
||||
out["fill-error"] = err.Error()
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var byWhy = map[string]any{
|
||||
"by": map[string]any{"type": "string", "description": "who asks, as who@node"},
|
||||
"why": map[string]any{"type": "string", "description": "why, in plain words"},
|
||||
}
|
||||
|
||||
// asked is by and why of a dropping call: both are needed, or nothing is done.
|
||||
func asked(args map[string]any) (by, why string, err error) {
|
||||
by, _ = args["by"].(string)
|
||||
why, _ = args["why"].(string)
|
||||
by, why = strings.TrimSpace(by), strings.TrimSpace(why)
|
||||
if by == "" || why == "" {
|
||||
return "", "", errors.New("a drop names who asks (by) and why: nothing was dropped")
|
||||
}
|
||||
return by, why, nil
|
||||
}
|
||||
|
||||
func record(acts views.HandActs, tool, what, by, why string) error {
|
||||
if acts == nil {
|
||||
return errors.New("no way to record a hand act is given, so nothing was dropped")
|
||||
}
|
||||
if err := acts.RecordHandAct(views.HandAct{What: what + " (asked by " + by + ")", Why: why, Cause: "objects-dropped", Verb: tool}); err != nil {
|
||||
return fmt.Errorf("the hand act was not recorded, so nothing was dropped: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stateDropTool(module string, states []State, acts views.HandActs) stdio.Tool {
|
||||
name := module + "_state_objects_drop"
|
||||
props := map[string]any{"state": stateArg}
|
||||
for k, v := range byWhy {
|
||||
props[k] = v
|
||||
}
|
||||
return stdio.Tool{
|
||||
Name: name,
|
||||
Description: "Drop the objects of one of " + module + "'s states whose key is gone, on a person's word (ADR 0595 §4, ADR 0666 §16). " +
|
||||
"An object a key names is never dropped. Recorded as a hand act first; needs by and why.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"by", "why"}, "properties": props},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
by, why, err := asked(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := pick(states, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := record(acts, name, "dropped the objects of "+module+"'s state "+s.Name+" whose key is gone", by, why); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dropped, err := s.Values.DropKeyGone()
|
||||
return map[string]any{"state": s.Name, "dropped": dropped}, err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SentObject is one object of a store of sent objects as the listing shows it.
|
||||
type SentObject struct {
|
||||
Name string `json:"name"`
|
||||
// Label is the verb or event the body was sent for.
|
||||
Label string `json:"label"`
|
||||
Size int64 `json:"size"`
|
||||
Sent time.Time `json:"sent,omitzero"`
|
||||
Age string `json:"age"`
|
||||
PastAge bool `json:"past-age,omitempty"`
|
||||
}
|
||||
|
||||
func (s Sent) maxAge() time.Duration {
|
||||
if s.MaxAge > 0 {
|
||||
return s.MaxAge
|
||||
}
|
||||
return SentMaxAge
|
||||
}
|
||||
|
||||
func (s Sent) list(now time.Time) ([]SentObject, error) {
|
||||
objects, err := s.Objects.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SentObject, 0, len(objects))
|
||||
for _, o := range objects {
|
||||
age := now.Sub(o.Modified)
|
||||
out = append(out, SentObject{Name: o.Name, Label: keyOf(o.Name), Size: o.Size, Sent: o.Modified,
|
||||
Age: age.Round(time.Second).String(), PastAge: age > s.maxAge()})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func sentObjectsTool(module string, s Sent, now func() time.Time) stdio.Tool {
|
||||
return stdio.Tool{
|
||||
Name: module + "_sent_objects",
|
||||
Description: "The objects " + module + " sent in place of a reply or an event over the message limit: each object, its size, when it " +
|
||||
"was sent, the verb or event it was sent for, its age against the store's age (" + s.maxAge().String() + ", transport and not kept data), " +
|
||||
"and how full the store is against its cap.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
objects, err := s.list(now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"objects": objects, "max-age": s.maxAge().String()}
|
||||
if s.Fill != nil {
|
||||
if f, err := s.Fill(); err == nil {
|
||||
out["fill"] = f
|
||||
} else {
|
||||
out["fill-error"] = err.Error()
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func sentDropTool(module string, s Sent, acts views.HandActs, now func() time.Time) stdio.Tool {
|
||||
name := module + "_sent_objects_drop"
|
||||
props := map[string]any{"older-than": map[string]any{"type": "string", "description": "a duration such as 24h: only objects sent longer ago are dropped"}}
|
||||
for k, v := range byWhy {
|
||||
props[k] = v
|
||||
}
|
||||
return stdio.Tool{
|
||||
Name: name,
|
||||
Description: "Drop the sent objects of " + module + " older than a given age, on a person's word. Younger objects are live and stay. " +
|
||||
"Recorded as a hand act first; needs by, why and older-than.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"by", "why", "older-than"}, "properties": props},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
by, why, err := asked(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
text, _ := args["older-than"].(string)
|
||||
age, err := time.ParseDuration(strings.TrimSpace(text))
|
||||
if err != nil || age <= 0 {
|
||||
return nil, fmt.Errorf("older-than %q is not a duration above zero such as 24h: nothing was dropped", text)
|
||||
}
|
||||
if err := record(acts, name, "dropped the sent objects of "+module+" older than "+age.String(), by, why); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects, err := s.Objects.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var dropped []string
|
||||
for _, o := range objects {
|
||||
if now().Sub(o.Modified) <= age {
|
||||
continue
|
||||
}
|
||||
if err := s.Objects.Delete(o.Name); err != nil {
|
||||
return map[string]any{"dropped": dropped}, fmt.Errorf("the object %s: %w", o.Name, err)
|
||||
}
|
||||
dropped = append(dropped, o.Name)
|
||||
}
|
||||
return map[string]any{"dropped": dropped}, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package stateobjects
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/kvlimit"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
)
|
||||
|
||||
type acts struct {
|
||||
done []views.HandAct
|
||||
err error
|
||||
}
|
||||
|
||||
func (a *acts) RecordHandAct(h views.HandAct) error {
|
||||
if a.err != nil {
|
||||
return a.err
|
||||
}
|
||||
a.done = append(a.done, h)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ADR 0666 §25: the four tools read what each store holds and drop only what is no longer live, each drop only with who
|
||||
// asks and why, recorded as a hand act first.
|
||||
func TestTheObjectToolsReadEverythingAndDropOnlyWhatIsNotLive(t *testing.T) {
|
||||
m := newMemory()
|
||||
m.modified = map[string]time.Time{}
|
||||
v := m.values()
|
||||
big := big(2 * kvlimit.ModuleStateValueBytes)
|
||||
for _, k := range []string{"live", "gone"} {
|
||||
if err := v.Put(k, big); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// the two keys hold one object each (different keys): delete one key
|
||||
if err := v.Delete("gone"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 11, 10, 12, 0, 0, 0, time.UTC)
|
||||
rec := &acts{}
|
||||
sentStore := newMemory()
|
||||
sentStore.modified = map[string]time.Time{"reply.list.aa": now.Add(-48 * time.Hour), "event.done.bb": now.Add(-time.Hour)}
|
||||
sentStore.objects = map[string][]byte{"reply.list.aa": []byte("1"), "event.done.bb": []byte("22")}
|
||||
tools := Tools("m", []State{{Name: "views", Values: v}}, &Sent{Objects: memObjects{sentStore}}, rec, func() time.Time { return now })
|
||||
by := map[string]func(map[string]any) (any, error){}
|
||||
for _, tl := range tools {
|
||||
by[tl.Name] = tl.Run
|
||||
}
|
||||
for _, name := range []string{"m_state_objects", "m_state_objects_drop", "m_sent_objects", "m_sent_objects_drop"} {
|
||||
if by[name] == nil {
|
||||
t.Fatalf("no tool %s", name)
|
||||
}
|
||||
}
|
||||
read, err := by["m_state_objects"](map[string]any{})
|
||||
if err != nil || read.(map[string]any)["key-gone"] != 1 {
|
||||
t.Fatalf("the state's objects: %v, %v", read, err)
|
||||
}
|
||||
listed, err := by["m_sent_objects"](map[string]any{})
|
||||
if objs := listed.(map[string]any)["objects"].([]SentObject); err != nil || len(objs) != 2 {
|
||||
t.Fatalf("the sent objects: %v, %v", listed, err)
|
||||
}
|
||||
// each drop refuses without by and why, and records nothing
|
||||
for _, tc := range []struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}{
|
||||
{"m_state_objects_drop", map[string]any{"why": "x"}},
|
||||
{"m_state_objects_drop", map[string]any{"by": "a@b"}},
|
||||
{"m_sent_objects_drop", map[string]any{"why": "x", "older-than": "24h"}},
|
||||
{"m_sent_objects_drop", map[string]any{"by": "a@b", "why": "x", "older-than": "soon"}},
|
||||
} {
|
||||
if _, err := by[tc.tool](tc.args); err == nil {
|
||||
t.Errorf("%s %v was accepted", tc.tool, tc.args)
|
||||
}
|
||||
}
|
||||
if len(rec.done) != 0 {
|
||||
t.Fatalf("a refused drop recorded %v", rec.done)
|
||||
}
|
||||
// the hand act is recorded first: one that cannot be recorded drops nothing
|
||||
rec.err = errors.New("hand acts are down")
|
||||
if _, err := by["m_state_objects_drop"](map[string]any{"by": "a@b", "why": "tidy"}); err == nil || !strings.Contains(err.Error(), "nothing was dropped") {
|
||||
t.Fatalf("an unrecorded drop: %v", err)
|
||||
}
|
||||
if kept, _ := v.Kept(); len(kept) != 2 {
|
||||
t.Fatalf("an unrecorded drop removed objects: %d left", len(kept))
|
||||
}
|
||||
rec.err = nil
|
||||
// the state drop takes only the key-gone object
|
||||
out, err := by["m_state_objects_drop"](map[string]any{"by": "a@b", "why": "tidy"})
|
||||
if err != nil || len(out.(map[string]any)["dropped"].([]string)) != 1 {
|
||||
t.Fatalf("the state drop: %v, %v", out, err)
|
||||
}
|
||||
if kept, _ := v.Kept(); len(kept) != 1 || kept[0].KeyGone {
|
||||
t.Fatalf("the live object did not survive: %+v", kept)
|
||||
}
|
||||
// the sent drop takes only objects past the age
|
||||
out, err = by["m_sent_objects_drop"](map[string]any{"by": "a@b", "why": "tidy", "older-than": "24h"})
|
||||
if err != nil || len(out.(map[string]any)["dropped"].([]string)) != 1 {
|
||||
t.Fatalf("the sent drop: %v, %v", out, err)
|
||||
}
|
||||
if _, ok := sentStore.objects["event.done.bb"]; !ok || len(sentStore.objects) != 1 {
|
||||
t.Fatalf("the young sent object did not survive: %v", sentStore.objects)
|
||||
}
|
||||
if len(rec.done) != 2 {
|
||||
t.Fatalf("%d hand acts, want 2", len(rec.done))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user