mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-06-03 17:51:18 +00:00
d9ba9937a6
Red commit `2a8102b9` (failing test) → green commit `bb957c9f`. CI: https://github.com/Kpa-clawbot/CoreScope/actions/workflows/ci.yml?query=branch%3Afix%2Fissue-1321 Fixes #1321. ## Why On staging `/api/scope-stats` 500'd with `scope_name column not present` despite the ingestor adding the column ~0.5s after server startup. `cmd/server/db.go detectSchema()` runs in `OpenDB` and caches `hasScopeName`/`hasDefaultScope`/`hasObsRawHex` booleans. With supervisord launching server + ingestor simultaneously, the server's PRAGMA can fire BEFORE the ingestor's `ALTER TABLE` completes — and the boolean stays false until the server restarts. Same race class as #1283; #1289 moved server-side ensures to `dbschema` but the optional columns the ingestor still owned were left out. ## Fix — option (c) from the issue Made `internal/dbschema/dbschema.go` the single source of truth for the optional columns the server detects. **Migrations moved from `cmd/ingestor/db.go applySchema` into `dbschema.Apply`:** - `transmissions.scope_name` + `idx_tx_scope_name` partial index - `nodes.default_scope` - `inactive_nodes.default_scope` - `observations.raw_hex` **`AssertReady` now asserts** every one of those columns. The server cannot start with stale-false booleans because `AssertReady` will fatal first if the columns are missing. The ingestor's old gated blocks are replaced with pointer comments so anyone hunting for them lands in `dbschema.go`. The `_migrations` marker rows are preserved (`INSERT OR IGNORE`) to keep legacy DBs idempotent. **Documented invariant** in the package doc: any new optional column the server PRAGMA-detects belongs in `internal/dbschema/dbschema.go`, NOT in `cmd/ingestor/db.go applySchema`. ## Tests Added `internal/dbschema/dbschema_test.go` (RED in `2a8102b9`): - `TestApplyAddsOptionalColumns_CanonicalSource` — post-`Apply`, all four columns must exist. - `TestAssertReady_RequiresOptionalColumns` — `AssertReady` must refuse a DB missing them AND pass after full `Apply`. `cmd/ingestor` and `cmd/server` full suites green. --------- Co-authored-by: openclaw-bot <bot@openclaw.local>
163 lines
4.4 KiB
Go
163 lines
4.4 KiB
Go
package dbschema
|
|
|
|
import (
|
|
"database/sql"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
_ "modernc.org/sqlite"
|
|
)
|
|
|
|
// minimalDB bootstraps a SQLite DB with just enough tables for the
|
|
// ensure_* helpers to run against, but WITHOUT any of the optional
|
|
// columns that dbschema.Apply is responsible for ensuring.
|
|
func minimalDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "schema.db")
|
|
db, err := sql.Open("sqlite", "file:"+dbPath+"?_journal_mode=WAL")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stmts := []string{
|
|
// Bare-bones tables, mirroring the legacy/empty fixture shape
|
|
// pre-migration. Intentionally omit columns we expect Apply to add.
|
|
`CREATE TABLE transmissions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
raw_hex TEXT NOT NULL,
|
|
hash TEXT NOT NULL UNIQUE,
|
|
first_seen TEXT NOT NULL,
|
|
route_type INTEGER,
|
|
payload_type INTEGER,
|
|
payload_version INTEGER,
|
|
decoded_json TEXT
|
|
)`,
|
|
`CREATE TABLE observations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
transmission_id INTEGER NOT NULL,
|
|
observer_idx INTEGER,
|
|
direction TEXT,
|
|
snr REAL,
|
|
rssi REAL,
|
|
score INTEGER,
|
|
path_json TEXT,
|
|
timestamp INTEGER NOT NULL
|
|
)`,
|
|
`CREATE TABLE observers (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT
|
|
)`,
|
|
`CREATE TABLE nodes (
|
|
public_key TEXT PRIMARY KEY,
|
|
name TEXT
|
|
)`,
|
|
`CREATE TABLE inactive_nodes (
|
|
public_key TEXT PRIMARY KEY,
|
|
name TEXT,
|
|
last_seen TEXT
|
|
)`,
|
|
}
|
|
for _, s := range stmts {
|
|
if _, err := db.Exec(s); err != nil {
|
|
t.Fatalf("bootstrap: %v", err)
|
|
}
|
|
}
|
|
return db
|
|
}
|
|
|
|
// TestApplyAddsOptionalColumns_CanonicalSource is the regression gate
|
|
// for issue #1321: dbschema.Apply must be the single source of truth
|
|
// for ALL optional columns the server PRAGMA-detects. Previously
|
|
// scope_name/default_scope/observations.raw_hex lived ONLY in
|
|
// cmd/ingestor/db.go applySchema, so the server (which runs
|
|
// detectSchema AFTER dbschema.AssertReady) could race the writer and
|
|
// cache stale false values when ingestor hadn't yet finished its
|
|
// applySchema migrations.
|
|
func TestApplyAddsOptionalColumns_CanonicalSource(t *testing.T) {
|
|
db := minimalDB(t)
|
|
defer db.Close()
|
|
|
|
if err := Apply(db, nil); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
|
|
cases := []struct {
|
|
table, col string
|
|
}{
|
|
{"transmissions", "scope_name"},
|
|
{"nodes", "default_scope"},
|
|
{"inactive_nodes", "default_scope"},
|
|
{"observations", "raw_hex"},
|
|
}
|
|
for _, c := range cases {
|
|
has, err := TableHasColumn(db, c.table, c.col)
|
|
if err != nil {
|
|
t.Fatalf("probe %s.%s: %v", c.table, c.col, err)
|
|
}
|
|
if !has {
|
|
t.Errorf("after Apply: %s.%s missing — dbschema must be the source of truth for this optional column (#1321)", c.table, c.col)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestAssertReady_RequiresOptionalColumns enforces that AssertReady
|
|
// REFUSES a DB missing the optional columns the server depends on —
|
|
// proving dbschema.AssertReady (not server-side PRAGMA detection) is
|
|
// the gate.
|
|
func TestAssertReady_RequiresOptionalColumns(t *testing.T) {
|
|
db := minimalDB(t)
|
|
defer db.Close()
|
|
// Run pre-existing ensures so we only fail on the new ones.
|
|
noop := Logger(func(string, ...interface{}) {})
|
|
if err := ensureNeighborEdgesTable(db); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureResolvedPathColumn(db, noop); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureObserverInactiveColumn(db, noop); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureLastPacketAtColumn(db, noop); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureObserverIATAColumn(db, noop); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureForeignAdvertColumn(db, noop); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureFromPubkeyColumn(db, noop); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// At this point the OLD AssertReady set is satisfied but the NEW
|
|
// columns are NOT — AssertReady must still fail.
|
|
err := AssertReady(db)
|
|
if err == nil {
|
|
t.Fatal("AssertReady should fail when scope_name/default_scope/observations.raw_hex are missing (#1321)")
|
|
}
|
|
for _, must := range []string{"scope_name", "default_scope", "raw_hex"} {
|
|
if !contains(err.Error(), must) {
|
|
t.Errorf("AssertReady error should mention missing %q; got: %v", must, err)
|
|
}
|
|
}
|
|
|
|
// After full Apply, AssertReady passes.
|
|
if err := Apply(db, nil); err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
if err := AssertReady(db); err != nil {
|
|
t.Fatalf("AssertReady after full Apply: %v", err)
|
|
}
|
|
}
|
|
|
|
func contains(haystack, needle string) bool {
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if haystack[i:i+len(needle)] == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|