Files
mesh-host/internal/store/numbers.go
T
jochen 31804bd8c2 Apply through one queue, and order what is applied and reported (hq to-be 45 Phase 2)
A delivery and the five-minute reconcile were two paths that applied, ordered only by a lock, and
each order it allowed was met live (issues 257, 261, 267). Now both only enqueue: one worker takes
the newest declaration held when it starts, applies it once and makes one report, and reports leave
in the order they are made.

A declaration may carry the controller's lease epoch beside its sequence; one older than what this
node applied is refused before anything is touched, counted, logged and reported. A report carries
the declaration's epoch and sequence and the host's own report sequence, kept on disk so it goes on
increasing across restarts and self-updates. Without an epoch, today's behaviour stands.
2026-10-06 11:54:10 +02:00

95 lines
2.9 KiB
Go

package store
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// What the node-engine counts about what it says, kept so the counting outlives the process.
//
// **The report sequence must increase across restarts and self-updates** (novox/hq to-be 45 §6). The
// mesh keeps, per machine, the highest report it accepted and refuses an older one; a host that began
// again from one after every restart — and a self-update is a restart — would have every report it
// made refused until it had counted past where its predecessor stopped. So the number is kept beside
// the state, where the successor reads it.
//
// And the count of declarations refused as older than what this node applied (rule 2), which the
// mesh's stale-writer watchdog reads from every report.
// NumbersName is where they live, beside the state.
const NumbersName = "numbers.json"
// NumbersPath is where the numbers live, given where the state lives.
func NumbersPath(statePath string) string {
return filepath.Join(filepath.Dir(statePath), NumbersName)
}
// Numbers is what is kept.
type Numbers struct {
// ReportSequence is the number of the last report this node made.
ReportSequence int64 `json:"report_sequence"`
// RefusedOlder is how many declarations it has refused as older, ever.
RefusedOlder int64 `json:"refused_older"`
}
// ReadNumbers is what was kept, or zero when nothing was — a node that has never reported.
//
// **Unreadable is an error, never zero** (novox/hq ADR 0227, rule 4): read as zero, every report the
// node makes next would be older than the ones it already made, and refused.
func ReadNumbers(path string) (Numbers, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return Numbers{}, nil
}
if err != nil {
return Numbers{}, err
}
var n Numbers
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&n); err != nil {
return Numbers{}, fmt.Errorf("the numbers kept at %s are unreadable: %w", path, err)
}
if n.ReportSequence < 0 || n.RefusedOlder < 0 {
return Numbers{}, fmt.Errorf("the numbers kept at %s are below zero, which nothing counting writes", path)
}
return n, nil
}
// SaveNumbers keeps them, replacing what was kept, and is on disk when it returns: a number used and
// not kept is one the next host would use again.
func SaveNumbers(path string, n Numbers) error {
raw, err := json.Marshal(n)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".numbers-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if _, err := tmp.Write(raw); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}