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) }