Let a bundle write a state key with its own time to live and hear its end, so wakes need no in-process timer (issue 1952)

mesh/state.create and mesh/state.put take ttl_seconds (mesh-sdk 32f5ce3's
CreateTTL/RenewTTL contract); a bucket not declared key-ttl is refused,
naming it; a watch hands an expired key as op "expire". Pins mesh-sdk 32f5ce3.
This commit is contained in:
2026-10-29 22:11:39 +01:00
parent 95a12105cf
commit 1583fa85d9
6 changed files with 169 additions and 6 deletions
+23 -3
View File
@@ -868,10 +868,14 @@ type stateAsked struct {
Key string `json:"key"`
Value json.RawMessage `json:"value"`
Revision uint64 `json:"revision"`
// TTLSeconds gives a created or put key its own time to live (issue 1952): create makes it only when it has no
// value, put renews it whole. Zero is none.
TTLSeconds int `json:"ttl_seconds"`
}
// State answers a bundle's `get`, `put`, `delete`, `keys`, `create`, `update` and `history` on its
// module's state (ADR 0201, ADR 0259, ADR 0307). The runtime refuses, with the reason, a state the module was not issued and a write to one it only reads.
// module's state (ADR 0201, ADR 0259, ADR 0307); `create` and `put` take `ttl_seconds`, a key's own time to live,
// whose end every watch hears as an `expire` (issue 1952). The runtime refuses, with the reason, a state the module was not issued and a write to one it only reads.
func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage, error) {
var asked stateAsked
if err := json.Unmarshal(params, &asked); err != nil || asked.State == "" {
@@ -897,7 +901,13 @@ func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage,
}
answer = kept
case "put":
revision, err := conn.StatePut(b.module, asked.State, asked.Key, asked.Value)
put := conn.StatePut
if asked.TTLSeconds != 0 {
put = func(module, name, key string, value json.RawMessage) (uint64, error) {
return conn.StateRenewTTL(module, name, key, value, time.Duration(asked.TTLSeconds)*time.Second)
}
}
revision, err := put(b.module, asked.State, asked.Key, asked.Value)
if err != nil {
return nil, err
}
@@ -915,13 +925,23 @@ func (b *moduleBus) State(verb string, params json.RawMessage) (json.RawMessage,
answer = keys
case "create":
// Only when the key has no value: of two writers making it, one wins (novox/hq ADR 0259).
revision, err := conn.StateCreate(b.module, asked.State, asked.Key, asked.Value)
create := conn.StateCreate
if asked.TTLSeconds != 0 {
create = func(module, name, key string, value json.RawMessage) (uint64, error) {
return conn.StateCreateTTL(module, name, key, value, time.Duration(asked.TTLSeconds)*time.Second)
}
}
revision, err := create(b.module, asked.State, asked.Key, asked.Value)
if err != nil {
return nil, err
}
answer = map[string]any{"revision": revision}
case "update":
// Only when the key is still at the revision read: a second answer to one ask loses (ADR 0259).
if asked.TTLSeconds != 0 {
return nil, fmt.Errorf("mesh/state.update gives no time to live: the bus sets one only when a key is " +
"created; put the key with ttl_seconds to renew it whole (issue 1952)")
}
revision, err := conn.StateUpdate(b.module, asked.State, asked.Key, asked.Value, asked.Revision)
if err != nil {
return nil, err
@@ -4,8 +4,10 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"github.com/novox/mesh-tools/node-tools/internal/bus"
"github.com/novox/mesh-tools/node-tools/internal/launch"
@@ -18,8 +20,10 @@ func counterBus(t *testing.T) launch.Bus {
t.Helper()
mesh := mt.New(t)
mesh.Bucket(t, "issues_numbers")
mesh.BucketKeyTTL(t, "issues_wakes")
m := mt.MembershipOf("issues", "anchor", false, nil)
m.State = []bus.StateIssued{{Name: "numbers", Bucket: "issues_numbers", Writes: true}}
m.State = []bus.StateIssued{{Name: "numbers", Bucket: "issues_numbers", Writes: true},
{Name: "wakes", Bucket: "issues_wakes", Writes: true}}
mesh.Issue(t, m)
conn := connect(t, "node-tools", "anchor")
conn.Follow("issues")
@@ -131,6 +135,9 @@ func TestTwoWritersIncrementingOneCounterOnTheBusLoseNoUpdate(t *testing.T) {
// A write at a revision that is no longer the key's is refused with the lost-write code and writes
// nothing; create is refused for a key with a value and taken for one deleted; an update names the
// revision it read, never zero.
// A key's own time to live (issue 1952, mesh-sdk process.State.CreateTTL and RenewTTL): created once, renewed whole by a
// put, its end heard by a watch as an expiry; refused, writing nothing, on a bucket that takes none, under a second, and
// on an update.
func TestAStaleWriteIsRefusedAndWritesNothing(t *testing.T) {
b := counterBus(t)
state := func(verb string, extra map[string]any) (uint64, error) {
@@ -200,4 +207,57 @@ func TestAStaleWriteIsRefusedAndWritesNothing(t *testing.T) {
if v := value(); v != "1" {
t.Fatalf("after delete and create: %s", v)
}
// A time to live per key: refused where it cannot be kept, writing nothing.
for _, c := range []struct {
verb, state, why string
ttl int
}{
{"create", "numbers", "does not take keys with their own time to live", 60},
{"put", "numbers", "does not take keys with their own time to live", 60},
{"create", "wakes", "shorter than the bus keeps", -1},
{"update", "wakes", "gives no time to live", 60},
} {
_, err := ask(b, c.verb, map[string]any{"state": c.state, "key": "late", "value": 1, "revision": 1, "ttl_seconds": c.ttl})
if err == nil || !strings.Contains(err.Error(), c.why) {
t.Errorf("%s with ttl %d on %s: want refused for %q, got %v", c.verb, c.ttl, c.state, c.why, err)
}
if raw, _ := ask(b, "get", map[string]any{"state": c.state, "key": "late"}); string(raw) != "null" {
t.Errorf("%s with ttl %d on %s wrote %s", c.verb, c.ttl, c.state, raw)
}
}
// Created once, renewed whole, and its end heard: a wake with no timer.
heard := make(chan bus.StateChange, 8)
stop, err := b.Watch(json.RawMessage(`{"state":"wakes"}`), func(raw json.RawMessage) error {
var c bus.StateChange
_ = json.Unmarshal(raw, &c)
heard <- c
return nil
})
if err != nil {
t.Fatal(err)
}
defer stop()
if _, err := ask(b, "create", map[string]any{"state": "wakes", "key": "settle", "value": 1, "ttl_seconds": 60}); err != nil {
t.Fatalf("create with a time to live: %v", err)
}
if _, err := ask(b, "create", map[string]any{"state": "wakes", "key": "settle", "value": 2, "ttl_seconds": 60}); !isLost(err) {
t.Fatalf("a second create with a time to live: %v", err)
}
if _, err := ask(b, "put", map[string]any{"state": "wakes", "key": "settle", "value": 3, "ttl_seconds": 1}); err != nil {
t.Fatalf("a put renewing the time to live: %v", err)
}
var ops []string
for want := "put delete put expire"; strings.Join(ops, " ") != want; {
select {
case c := <-heard:
ops = append(ops, c.Op)
if c.Op == "put" && len(ops) == 3 && string(c.Value) != "3" {
t.Fatalf("the renewed key holds %s", c.Value)
}
case <-time.After(5 * time.Second):
t.Fatalf("the watch heard %q, want %q", strings.Join(ops, " "), want)
}
}
}