Files
meshcore-analyzer/internal/mbcapqueue/mbcapqueue.go
T
efiten 0b35c7eef3 feat(server): persist multi-byte capability across restart + O(1) per-key lookup (#903) (#1324)
## Summary

Follows the reconciliation recommendation in #916 — extracts only the
NET-NEW persistence layer from that PR (which is now superseded by #1002
for the overlay UI) into a focused 6-file change against current master.

**What this adds:**
- `multibyte_sup_v1` migration: `multibyte_sup INTEGER NOT NULL DEFAULT
0` + `multibyte_evidence TEXT` on `nodes`/`inactive_nodes` so capability
survives restart
- `hasMultibyteSupCols` schema detection gates the persist/load paths
- `loadMultibyteCapFromDB()`: pre-populates `mbCapSnapshot`/`mbCapIndex`
at startup — cold starts serve last-known capability without waiting for
the first ~15s analytics cycle
- `maybePersistMultibyteCapability()` + `persistMultibyteCapability()`:
after each analytics cycle; TryLock-gated (concurrent cycles coalesce);
skips `sup==0` entries (data-destruction guard)
- `GetMultibyteCapFor(pk)`: O(1) map lookup; both `handleNodes` and
node-detail call sites updated from the O(N)-alloc
`GetMultiByteCapMap()`

**What this explicitly does NOT change:**
- API field names (`multi_byte_status`, `multi_byte_evidence`,
`multi_byte_max_hash_size`)
- `EnrichNodeWithMultiByte` — unchanged
- `GetMultiByteCapMap` — still present for any external callers
- `public/map.js`, `public/live.css`, `Dockerfile`, `docs/` — zero
frontend churn

## Test plan

- [x] `TestMultibyteCapPersistRoundTrip` — confirmed values survive
persist → fresh-store load
- [x] `TestMultibyteCapPersistSkipsUnknown` — data-destruction guard:
`sup==0` entry does not overwrite DB-confirmed value
- [x] `TestMultibyteCapMaybePersistCoalesces` — TryLock coalesces 10
concurrent callers without deadlock
- [x] `TestMultibyteCapGetMultibyteCapForO1` — O(1) index returns
correct entry / false for unknown pubkey
- [x] `TestMultibyteCapLoadFromDB` — only `sup>0` rows loaded; `sup==0`
row excluded
- [x] `TestSchemaMultibyteSupColumns` — migration adds columns to both
tables; idempotent on second `OpenStore`
- [x] All existing `TestMultiByteCapability_*` tests pass unchanged
- [x] Full ingestor test suite: `ok` in 27s
- [x] `go build ./cmd/server/ && go build ./cmd/ingestor/` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-25 22:35:35 -07:00

119 lines
4.0 KiB
Go

// Package mbcapqueue defines the on-disk handoff used by the read-only
// server (cmd/server) to publish multi-byte capability snapshots that
// the writer-owning ingestor (cmd/ingestor) persists to the nodes /
// inactive_nodes tables.
//
// Rationale: PR #903 originally added a server-side persistMultibyteCapability
// that executed UPDATEs on nodes/inactive_nodes — a hard violation of the
// read-only-server invariant established in #1283/#1287/#1289 (the server
// opens SQLite with mode=ro). The capability computation is heavy and lives
// in the server's analytics cycle; rather than duplicate it in the ingestor,
// the server writes a snapshot file under <dataDir>/mbcap-snapshot/ and the
// ingestor's maintenance loop picks it up and writes to the DB.
//
// Pattern mirrors internal/prunequeue (#669/#738).
//
// Layout (under <dir(dbPath)>/mbcap-snapshot/):
//
// snapshot.json — atomic-replaced by the server each analytics cycle
// snapshot.json.tmp — transient (rename target)
//
// The file is rewritten in full each cycle (idempotent overwrite). The
// ingestor reads the file at most once per persist tick; if absent, the
// tick is a no-op.
package mbcapqueue
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// QueueDirName is the subdirectory (under the SQLite data dir) holding
// the snapshot file.
const QueueDirName = "mbcap-snapshot"
// SnapshotFileName is the canonical snapshot file written by the server.
const SnapshotFileName = "snapshot.json"
// Entry is one node's multi-byte capability as derived by the server's
// analytics cycle. Status is the human label ("confirmed", "suspected",
// "unknown"); the ingestor maps it to the DB sup integer.
//
// Entries with Status=="unknown" are NEVER persisted (the writer must
// not overwrite a previously confirmed/suspected DB value with a
// snapshot blank — same data-destruction guard the server enforced).
type Entry struct {
PublicKey string `json:"public_key"`
Status string `json:"status"`
Evidence string `json:"evidence,omitempty"`
}
// Snapshot is the full payload the server writes.
type Snapshot struct {
WrittenAt time.Time `json:"writtenAt"`
Entries []Entry `json:"entries"`
}
// QueueDir returns the absolute path of the snapshot directory, given
// the SQLite database path the ingestor and server share.
func QueueDir(dbPath string) string {
return filepath.Join(filepath.Dir(dbPath), QueueDirName)
}
// EnsureDir creates the snapshot directory if missing.
func EnsureDir(dbPath string) error {
return os.MkdirAll(QueueDir(dbPath), 0o755)
}
// SnapshotPath returns the absolute path of snapshot.json under dbPath.
func SnapshotPath(dbPath string) string {
return filepath.Join(QueueDir(dbPath), SnapshotFileName)
}
// WriteSnapshot atomically replaces snapshot.json with the given payload.
// Uses tmp-then-rename so a reader never sees a torn file.
func WriteSnapshot(dbPath string, snap Snapshot) error {
if err := EnsureDir(dbPath); err != nil {
return fmt.Errorf("ensure dir: %w", err)
}
if snap.WrittenAt.IsZero() {
snap.WrittenAt = time.Now().UTC()
}
b, err := json.Marshal(snap)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
final := SnapshotPath(dbPath)
tmp := final + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return fmt.Errorf("write tmp: %w", err)
}
if err := os.Rename(tmp, final); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename: %w", err)
}
return nil
}
// ReadSnapshot loads the current snapshot.json. Returns os.ErrNotExist
// when no snapshot has been written yet — callers should treat that as
// "nothing to persist" rather than an error.
func ReadSnapshot(dbPath string) (Snapshot, error) {
var snap Snapshot
b, err := os.ReadFile(SnapshotPath(dbPath))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return snap, os.ErrNotExist
}
return snap, fmt.Errorf("read: %w", err)
}
if err := json.Unmarshal(b, &snap); err != nil {
return snap, fmt.Errorf("unmarshal: %w", err)
}
return snap, nil
}