Files
mesh-catalog/modules/nats/snapshot/restore.go
T
jochen 419e82cded Back up the bus by the server's own snapshot of each stream, not its live files (hq ADR 0235)
The restic holder copied JetStream's store while the server wrote it; such a
copy may not restore. The nats image now carries mesh-nats-snapshot, run by
the declared dump under the module's own bus account (snapshot API only):
every stream one at a time, flow-controlled, into one tar with a manifest of
counts, sequences and checksums. Restore builds a new store beside the live
one with the bus's own server; a person swaps it in. Proven against
throwaway nats 2.11 servers being written to during the snapshot.
2026-10-06 18:20:51 +02:00

262 lines
8.6 KiB
Go

package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/nats-io/nats.go"
)
// restoreChunk is what is sent at a time; the server answers each before the next goes.
const restoreChunk = 64 * 1024
// RestoreAll puts every stream of the archive into the server nc reaches, one at a time, and holds
// each restored stream's state to the manifest. **Nothing is restored over anything**: a server that
// already holds any of the streams is refused before the first is sent.
func RestoreAll(ctx context.Context, nc *nats.Conn, a *Archive, log func(string, ...any)) error {
var present []string
for _, s := range a.Manifest.Streams {
err := request(ctx, nc, apiStreamInfo+s.Name, nil, nil)
var api *apiError
switch {
case err == nil:
present = append(present, s.Name)
case errors.As(err, &api) && api.Code == 404:
default:
return err
}
}
if len(present) > 0 {
return fmt.Errorf("the server already holds %s; nothing is restored over a live stream — restore into a new "+
"store (--into) and swap it in with the bus stopped", strings.Join(present, ", "))
}
for _, s := range a.Manifest.Streams {
started := time.Now()
if err := restoreStream(ctx, nc, a, s); err != nil {
return fmt.Errorf("stream %s: %w", s.Name, err)
}
log("%s: restored, at least %d message(s) to sequence %d, in %.2fs", s.Name, s.Messages, s.LastSeq, time.Since(started).Seconds())
}
return nil
}
func restoreStream(ctx context.Context, nc *nats.Conn, a *Archive, s StreamEntry) error {
meta, archive, err := a.open(s)
if err != nil {
return err
}
defer archive.Close()
var req map[string]json.RawMessage
if err := json.Unmarshal(meta, &req); err != nil || req["config"] == nil {
return errors.New("its meta is not a stream's configuration and state")
}
var resp struct {
DeliverSubject string `json:"deliver_subject"`
}
if err := request(ctx, nc, apiStreamRestore+s.Name, req, &resp); err != nil {
return err
}
if resp.DeliverSubject == "" {
return errors.New("the server gave nowhere to send the archive")
}
buf := make([]byte, restoreChunk)
for {
n, err := archive.Read(buf)
if n > 0 {
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
reply, rerr := nc.RequestWithContext(cctx, resp.DeliverSubject, buf[:n])
cancel()
if rerr != nil {
return fmt.Errorf("sending the archive: %w", rerr)
}
if len(reply.Data) > 0 {
return fmt.Errorf("the server refused the archive: %s", truncate(reply.Data))
}
}
if err == io.EOF {
break
}
if err != nil {
return err
}
}
// The end: an empty message, answered once the server has rebuilt the stream from what it was
// sent — which for a large stream takes a while.
fctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
defer cancel()
final, err := nc.RequestWithContext(fctx, resp.DeliverSubject, nil)
if err != nil {
return fmt.Errorf("finishing the restore: %w", err)
}
var created struct {
Error *apiError `json:"error"`
State streamState `json:"state"`
}
if err := json.Unmarshal(final.Data, &created); err != nil {
return fmt.Errorf("the server's last answer is not JSON: %q", truncate(final.Data))
}
if created.Error != nil {
return created.Error
}
return heldTo(created.State, s)
}
// heldTo is whether a restored stream holds at least what its snapshot said it held.
//
// **At least, not exactly — found by the restore test, not assumed.** The state the server answers a
// snapshot request with is taken when the snapshot starts; the stream goes on taking messages while
// its blocks are read out, and a block read a moment later carries some of what was appended in that
// moment. So a stream written during its snapshot restores with a later last sequence than the
// manifest says (90075 where it said 90024, with a writer publishing every 2ms through it), and of
// the messages in that tail some may be there and some not. Every message up to the manifest's last
// sequence is there, exactly; that is the snapshot's promise, and what the test holds it to message
// by message. What must never happen is a restored stream that ends before the manifest said it did.
func heldTo(restored streamState, s StreamEntry) error {
if restored.LastSeq < s.LastSeq || (s.Messages > 0 && restored.Messages == 0) {
return fmt.Errorf("restored with %d message(s) up to sequence %d; the snapshot held %d up to %d",
restored.Messages, restored.LastSeq, s.Messages, s.LastSeq)
}
return nil
}
// RestoreOffline fills a new store directory with the archive's streams, by a server of this
// program's own: nats-server, the same binary the bus runs, on loopback, with the bus's one account
// and a user that exists only for this run. It is stopped when the streams are in. What it leaves
// is a store directory laid out exactly as the bus's — `jetstream/<account>/streams/<name>` — which
// a person swaps in for the bus's own with the bus stopped. It answers that jetstream directory.
//
// Why a server of its own rather than the live bus: a stream is restored only where it does not
// exist, and on the live bus every stream exists — the controller asserts them on every start. So a
// restore beside the live data, swapped in by a person, is the only one that never touches it.
func RestoreOffline(ctx context.Context, a *Archive, into, account string, log func(string, ...any)) (string, error) {
if !validName(account) {
return "", fmt.Errorf("%q is not an account name", account)
}
if entries, err := os.ReadDir(into); err == nil && len(entries) > 0 {
return "", fmt.Errorf("%s is not empty; a restore goes into a new directory, never over one", into)
}
if err := os.MkdirAll(into, 0o700); err != nil {
return "", err
}
store, err := filepath.Abs(into)
if err != nil {
return "", err
}
binary, err := exec.LookPath("nats-server")
if err != nil {
return "", errors.New("no nats-server here to restore with; run this in the bus's own image")
}
work, err := os.MkdirTemp("", "mesh-nats-restore-server-*")
if err != nil {
return "", err
}
defer os.RemoveAll(work)
port, err := freePort()
if err != nil {
return "", err
}
password := randomHex(24)
conf := filepath.Join(work, "restore.conf")
config := fmt.Sprintf(`listen: "127.0.0.1:%d"
jetstream { store_dir: %q }
accounts { %s { jetstream: enabled, users: [ { user: "restore", password: %q } ] } }
`, port, store, account, password)
if err := os.WriteFile(conf, []byte(config), 0o600); err != nil {
return "", err
}
cmd := exec.Command(binary, "-c", conf)
logFile, err := os.Create(filepath.Join(work, "server.log"))
if err != nil {
return "", err
}
defer logFile.Close()
cmd.Stdout, cmd.Stderr = logFile, logFile
if err := cmd.Start(); err != nil {
return "", err
}
exited := make(chan error, 1)
go func() { exited <- cmd.Wait() }()
stop := func() error {
_ = cmd.Process.Signal(syscall.SIGTERM)
select {
case err := <-exited:
return err
case <-time.After(30 * time.Second):
_ = cmd.Process.Kill()
<-exited
return errors.New("the restore's server did not stop within 30s and was killed")
}
}
serverLog := func() string {
raw, _ := os.ReadFile(filepath.Join(work, "server.log"))
return strings.TrimSpace(string(raw))
}
url := fmt.Sprintf("nats://127.0.0.1:%d", port)
var nc *nats.Conn
for deadline := time.Now().Add(20 * time.Second); ; {
nc, err = connect(url, Credential{User: "restore", Password: password})
if err == nil {
break
}
if time.Now().After(deadline) {
_ = stop()
return "", fmt.Errorf("the restore's server did not come up: %v\n%s", err, serverLog())
}
select {
case err := <-exited:
return "", fmt.Errorf("the restore's server stopped: %v\n%s", err, serverLog())
case <-time.After(200 * time.Millisecond):
}
}
err = RestoreAll(ctx, nc, a, log)
nc.Close()
if stopErr := stop(); err == nil && stopErr != nil && !isTerminated(stopErr) {
err = stopErr
}
if err != nil {
return "", err
}
return filepath.Join(store, "jetstream"), nil
}
// isTerminated is a server that stopped because it was asked to.
func isTerminated(err error) bool {
var exit *exec.ExitError
if errors.As(err, &exit) {
if status, ok := exit.Sys().(syscall.WaitStatus); ok && status.Signaled() && status.Signal() == syscall.SIGTERM {
return true
}
}
return false
}
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}