Files
mesh-catalog/modules/nats/cmd/nats-tools/snapshots.go
T
jschoubben 44cb7ad449 bus5 B5 (A-107): the keeper's process answers mesh-broker's snapshot verbs itself through AnswerSeat
snapshot-get, snapshot-restore, snapshot-restore-cancel and snapshot-remove are answered by
the keeper's process with the proved caller; it publishes snapshot-get's chunks and hears
their acks on its own subscription, and acts at once with no hand-over through requests
(the record of a running restore is written before it runs). The tools bundle keeps its
tools, nats_snapshots among them. The protected refusal and the whole test's case (send,
restore in place, removal) stay.

Builds: design 47 G A-107 (G-20-lead-answers), §681

Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
2026-11-11 23:56:06 +01:00

384 lines
14 KiB
Go

package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/process"
"nats-tools/internal/keeper"
)
// The seat mesh-broker's snapshot verbs and nats_snapshots (design 47 G §681, ADR 0675; A-44, A-107). The keeper's process answers the
// four verbs itself over the bus (process.Conn.AnswerSeat), with the caller the bus proved: snapshot-get answers the fields and
// sends the file in chunks, published by the process and acknowledged by the caller on a subscription of the process's own;
// snapshot-restore, snapshot-restore-cancel and snapshot-remove are acted on by the keeper at once, with no hand-over through the
// state requests. The tools bundle keeps nats_snapshots, which lists them.
// ChunkSize is at most what one chunk carries: 512 KiB.
const ChunkSize = 512 << 10
// AckWithin is how long the next chunk waits for its acknowledgement.
const AckWithin = 30 * time.Second
// callToken is the shape of the subject token a caller chooses for a send.
var callToken = regexp.MustCompile(`^[A-Za-z0-9_-]{16,64}$`)
// ChunkSubject and AckSubject are the subjects of the nth chunk of a send and of its acknowledgement.
func ChunkSubject(call string, n int64) string {
return "mesh.seat." + meshbroker.Seat + ".chunk." + meshbroker.VerbSnapshotGet + "." + call + "." + strconv.FormatInt(n, 10)
}
// AckSubject is the subject the caller acknowledges the nth chunk on.
func AckSubject(call string, n int64) string {
return "mesh.seat." + meshbroker.Seat + ".ack." + meshbroker.VerbSnapshotGet + "." + call + "." + strconv.FormatInt(n, 10)
}
// AckPattern is every acknowledgement of snapshot-get.
const AckPattern = "mesh.seat.mesh-broker.ack.snapshot-get.*.*"
// Chunk is the body of one chunk: its number, how many there are, and its bytes.
type Chunk struct {
N int64 `json:"n"`
Of int64 `json:"of"`
Data []byte `json:"data"`
}
// snapshotWorld is what the keeper's snapshot verbs reach: the file, the process's own publish and subscription, and the bus's users.
type snapshotWorld struct {
// Open opens the snapshot file the keeper wrote.
Open func(path string) (io.ReadCloser, error)
// Publish says one chunk on its subject, the process's own publish; Hear subscribes to the acknowledgements, once.
Publish func(subject string, body any, id string) error
Hear func(ack func(subject string)) error
// Granted says whether the caller may hear the chunks and acknowledge them (the bus's users, nats_user_can).
Granted func(caller, call string) bool
Logf func(string, ...any)
// Within is how long an acknowledgement is waited for.
Within time.Duration
once sync.Once
onceErr error
mu sync.Mutex
waiting map[string]chan struct{}
}
// grantedBy is whether the bus's users let caller hear a send's chunks and acknowledge them.
func grantedBy(caller, call string) bool {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
conf, err := ReadUsers(ctx)
if err != nil {
return false
}
a, err := UserCan(conf, caller, "subscribe", ChunkSubject(call, 1))
if err != nil || a["allowed"] != true {
return false
}
a, err = UserCan(conf, caller, "publish", AckSubject(call, 1))
return err == nil && a["allowed"] == true
}
// keeperSnapshots is the world as the keeper's process reaches it: its own JetStream publish of a chunk, as the seat's events are
// said, and its own core subscription for the acknowledgements, answered at once since the caller asks them as requests.
func keeperSnapshots(js jetstream.JetStream, nc *nats.Conn, a process.Account, logf func(string, ...any)) *snapshotWorld {
return &snapshotWorld{
Open: func(path string) (io.ReadCloser, error) { return os.Open(path) },
Publish: func(subject string, body any, id string) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
msg := nats.NewMsg(subject)
msg.Header.Set("x-event-id", a.Module+"."+id)
msg.Header.Set("x-source", a.Module)
msg.Header.Set("x-node", a.Node)
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
msg.Header.Set("content-type", "application/json")
msg.Data = data
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = js.PublishMsg(ctx, msg, jetstream.WithMsgID(a.Module+"."+id))
return err
},
Hear: func(ack func(subject string)) error {
_, err := nc.Subscribe(AckPattern, func(m *nats.Msg) {
ack(m.Subject)
if m.Reply != "" {
_ = m.Respond([]byte("{}"))
}
})
return err
},
Granted: grantedBy, Logf: logf, Within: AckWithin,
}
}
// hearAcks subscribes once to the acknowledgements of every send.
func (w *snapshotWorld) hearAcks() error {
w.once.Do(func() {
w.waiting = map[string]chan struct{}{}
w.onceErr = w.Hear(func(subject string) {
p := strings.Split(subject, ".")
if len(p) != 7 {
return
}
w.mu.Lock()
ch := w.waiting[p[5]+"."+p[6]]
w.mu.Unlock()
if ch != nil {
select {
case ch <- struct{}{}:
default:
}
}
})
})
return w.onceErr
}
// digestOf reads the file whole: its size and the `sha256:` of its bytes.
func (w *snapshotWorld) digestOf(path string) (int64, string, error) {
f, err := w.Open(path)
if err != nil {
return 0, "", err
}
defer f.Close()
h := sha256.New()
n, err := io.Copy(h, f)
if err != nil {
return 0, "", err
}
return n, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
// get answers snapshot-get: the fields, then the chunks sent after the answer when the caller is granted the chunk subjects.
func (w *snapshotWorld) get(held func(id string) (*keeper.Request, error), caller, call, request string) (meshbroker.SnapshotGetAnswer, error) {
refuse := func(why string) (meshbroker.SnapshotGetAnswer, error) {
return meshbroker.SnapshotGetAnswer{Error: why}, nil
}
if !callToken.MatchString(call) {
return refuse("call is one token of 16 to 64 letters, digits, - or _: 128 random bits the caller chooses")
}
rec, err := held(request)
if err != nil {
return meshbroker.SnapshotGetAnswer{}, fmt.Errorf("the snapshot of the request %s cannot be read: %w", request, err)
}
if rec == nil || (rec.Path == "" && rec.Snapshot == "") {
return refuse("no snapshot of the request " + request + " is held")
}
if !rec.Recorded() {
return refuse("snapshot taken before milestone G")
}
// A protected snapshot is never handed out: the keeper restores it in place (O-7).
if rec.Protected {
return refuse("snapshot protected")
}
size, digest, err := w.digestOf(rec.Path)
if errors.Is(err, os.ErrNotExist) {
return refuse("snapshot missing")
}
if err != nil {
return meshbroker.SnapshotGetAnswer{}, fmt.Errorf("the snapshot %s cannot be read: %w", rec.Path, err)
}
if digest != rec.Digest || size != rec.Size {
return refuse("snapshot damaged: request " + request)
}
at := rec.At
out := meshbroker.SnapshotGetAnswer{Request: rec.ID, Module: rec.Module, Local: rec.Local, Stream: keeper.BusName(rec.Module, rec.Local),
Size: size, Digest: digest, At: &at}
if size == 0 || !w.Granted(caller, call) {
return out, nil
}
if err := w.hearAcks(); err != nil {
return meshbroker.SnapshotGetAnswer{}, fmt.Errorf("the acknowledgements cannot be heard, so no chunk is sent: %w", err)
}
out.Chunks = (size + ChunkSize - 1) / ChunkSize
go w.send(rec.Path, call, out.Chunks)
return out, nil
}
// send sends the file in chunks, each acknowledged before the next; a missed acknowledgement ends the send, said in the journal.
func (w *snapshotWorld) send(path, call string, total int64) {
f, err := w.Open(path)
if err != nil {
w.Logf("the snapshot %s cannot be opened to send (%v): the send %s ends", path, err, call)
return
}
defer f.Close()
buf := make([]byte, ChunkSize)
for n := int64(1); n <= total; n++ {
read, err := io.ReadFull(f, buf)
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
w.Logf("the snapshot %s cannot be read (%v): the send %s ends at chunk %d", path, err, call, n)
return
}
key := call + "." + strconv.FormatInt(n, 10)
ack := make(chan struct{}, 1)
w.mu.Lock()
w.waiting[key] = ack
w.mu.Unlock()
err = w.Publish(ChunkSubject(call, n), Chunk{N: n, Of: total, Data: buf[:read]}, key)
if err == nil {
select {
case <-ack:
case <-time.After(w.Within):
err = fmt.Errorf("no acknowledgement within %s", w.Within)
}
}
w.mu.Lock()
delete(w.waiting, key)
w.mu.Unlock()
if err != nil {
w.Logf("the send %s of the snapshot %s ends at chunk %d of %d: %v", call, path, n, total, err)
return
}
}
}
// snapshotArgs are the arguments of the four verbs.
type snapshotArgs struct {
Request string `json:"request"`
Call string `json:"call"`
Act string `json:"act"`
Why string `json:"why"`
Verify bool `json:"verify"`
Expired bool `json:"expired"`
}
// argsOf reads a call's arguments and refuses one that lacks a name in need.
func argsOf(body json.RawMessage, need ...string) (snapshotArgs, error) {
var a snapshotArgs
if len(body) > 0 {
if err := json.Unmarshal(body, &a); err != nil {
return a, fmt.Errorf("the arguments are not JSON: %w", err)
}
}
a.Why = strings.TrimSpace(a.Why)
have := map[string]string{"request": a.Request, "call": a.Call, "act": a.Act, "why": a.Why}
for _, n := range need {
if have[n] == "" {
return a, fmt.Errorf("%s is required", n)
}
}
return a, nil
}
// snapshotAnswers are the four verbs of the seat mesh-broker that concern the keeper's snapshots, as the keeper's process answers
// them (process.Conn.AnswerSeat): each with the caller the bus proved, "unproven" for a call that named none.
func snapshotAnswers(k *keeper.Keeper, held func(id string) (*keeper.Request, error), w *snapshotWorld) map[string]process.SeatHandler {
acting := func(verb, caller, of string, a snapshotArgs) keeper.Request {
if caller == "" {
caller = stdio.Unproven
}
return k.AnswerSnapshot(keeper.Request{ID: requestID(time.Now().UTC()), Verb: verb, Of: of, Verify: a.Verify, AllExpired: a.Expired, Why: a.Why, Caller: caller})
}
return map[string]process.SeatHandler{
meshbroker.VerbSnapshotGet: func(caller string, body json.RawMessage) (any, error) {
a, err := argsOf(body, "request", "call")
if err != nil {
return nil, err
}
if caller == "" {
caller = stdio.Unproven
}
return w.get(held, caller, a.Call, a.Request)
},
meshbroker.VerbSnapshotRestore: func(caller string, body json.RawMessage) (any, error) {
a, err := argsOf(body, "request", "why")
if err != nil {
return nil, err
}
r := acting(keeper.VerbSnapshotRestore, caller, a.Request, a)
switch {
case r.Error != "":
return meshbroker.SnapshotRestoreAnswer{Error: r.Error}, nil
case r.Outcome == "busy":
return meshbroker.SnapshotRestoreAnswer{Busy: r.ActID}, nil
case r.ActID != "":
return meshbroker.SnapshotRestoreAnswer{Accepted: r.ActID}, nil
}
return meshbroker.SnapshotRestoreAnswer{Accepted: r.ID}, nil
},
meshbroker.VerbSnapshotRestoreCancel: func(caller string, body json.RawMessage) (any, error) {
a, err := argsOf(body, "act", "why")
if err != nil {
return nil, err
}
r := acting(keeper.VerbSnapshotRestoreCancel, caller, a.Act, a)
if r.Error != "" {
return meshbroker.SnapshotRestoreCancelAnswer{Error: r.Error}, nil
}
return meshbroker.SnapshotRestoreCancelAnswer{Accepted: r.ActID}, nil
},
meshbroker.VerbSnapshotRemove: func(caller string, body json.RawMessage) (any, error) {
a, err := argsOf(body, "why")
if err != nil {
return nil, err
}
r := acting(keeper.VerbSnapshotRemove, caller, a.Request, a)
if r.Error != "" {
return meshbroker.SnapshotRemoveAnswer{Error: r.Error}, nil
}
return meshbroker.SnapshotRemoveAnswer{Accepted: r.ID}, nil
},
}
}
// heldRequest reads one record of the keeper's own state requests.
func heldRequest(store keeper.Store) func(id string) (*keeper.Request, error) {
return func(id string) (*keeper.Request, error) {
raw, _, found, err := store.Get(keeper.StateRequests, id)
if err != nil || !found {
return nil, err
}
var r keeper.Request
return &r, json.Unmarshal(raw, &r)
}
}
// snapshotListTool is nats_snapshots, which the tools bundle keeps beside the verbs the keeper's process answers.
func snapshotListTool(rs requestStore, keepDays int) []stdio.Tool {
return []stdio.Tool{
{Name: "nats_snapshots",
Description: "The keeper's snapshots of kept data taken before a drop or recreate, from its state requests: each one's request, module, local name, stream, size, digest, time, whether it is protected (restored only in place, never sent), whether it is expired (past snapshot-keep-days, removed at the keeper's next request) and whether it is recorded (a snapshot of before milestone G holds no digest and is sent or removed by hand only).",
Run: func(map[string]any) (any, error) {
keys, err := rs.Keys()
if err != nil {
return nil, err
}
sort.Strings(keys)
out := []map[string]any{}
for _, key := range keys {
if strings.HasPrefix(key, keeper.ActedPrefix) {
continue
}
r, err := rs.Get(key)
if err != nil || r == nil || (r.Path == "" && r.Snapshot == "") {
continue
}
out = append(out, map[string]any{"request": r.ID, "module": r.Module, "local": r.Local, "stream": keeper.BusName(r.Module, r.Local),
"size": r.Size, "digest": r.Digest, "at": r.At, "protected": r.Protected, "expired": r.Expired(rs.Now(), keepDays), "recorded": r.Recorded()})
}
return map[string]any{"snapshots": out}, nil
}},
}
}