An unprotected snapshot is received through snapshot-get into incoming/<request>, refused before the first chunk past the cap, checked against its digest before any aside, and restored by the module; a protected one is verified, the running data set aside and restored in place by the keeper, waited on by snapshot-restored within snapshot-restore-wait-minutes, cancelled at the bound. The bus module sends its chunks with mesh-sdk's form. Builds: design 47 G §681, §682, A-137, A-138, A-139, A-140 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
353 lines
12 KiB
Go
353 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/buscalls"
|
|
"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.
|
|
|
|
// The form of the chunks is mesh-sdk's (buscalls.SeatChunks, A-139): the caller receives with the same one.
|
|
const (
|
|
// ChunkSize is at most what one chunk carries: 512 KiB.
|
|
ChunkSize = buscalls.SeatChunkBytes
|
|
// AckWithin is how long the next chunk waits for its acknowledgement.
|
|
AckWithin = buscalls.SeatAckWithin
|
|
)
|
|
|
|
// callToken is the shape of the subject token a caller chooses for a send.
|
|
var callToken = regexp.MustCompile(`^[A-Za-z0-9_-]{16,64}$`)
|
|
|
|
func sendOf(call string) buscalls.SeatChunks {
|
|
return buscalls.SeatChunks{Seat: meshbroker.Seat, Verb: meshbroker.VerbSnapshotGet, Call: call}
|
|
}
|
|
|
|
// 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 sendOf(call).Chunk(n) }
|
|
|
|
// AckSubject is the subject the caller acknowledges the nth chunk on.
|
|
func AckSubject(call string, n int64) string { return sendOf(call).Ack(n) }
|
|
|
|
// AckPattern is every acknowledgement of snapshot-get.
|
|
var AckPattern = buscalls.SeatAcks(meshbroker.Seat, meshbroker.VerbSnapshotGet)
|
|
|
|
// Chunk is the body of one chunk: its number, how many there are, and its bytes.
|
|
type Chunk = buscalls.SeatChunk
|
|
|
|
// 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
|
|
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{}
|
|
}
|
|
|
|
// keeperSnapshots is the world as the keeper's process reaches it: its own core publish of a chunk and its own core subscription for the acknowledgements, answered at once since the caller asks them as requests.
|
|
func keeperSnapshots(nc *nats.Conn, logf func(string, ...any)) *snapshotWorld {
|
|
return &snapshotWorld{
|
|
Open: func(path string) (io.ReadCloser, error) { return os.Open(path) },
|
|
// A chunk is a transfer on the core bus, kept on no stream (A-109).
|
|
Publish: func(subject string, body any, _ string) error {
|
|
data, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nc.Publish(subject, data)
|
|
},
|
|
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
|
|
},
|
|
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 {
|
|
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
|
|
}},
|
|
}
|
|
}
|