mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 11:50:24 +00:00
Swaps the SQLite driver from `modernc.org/sqlite` (pure Go, SQLite
3.46.0) to `github.com/mattn/go-sqlite3` (cgo, bundled SQLite 3.53.4),
and pays the resulting cross-compilation cost with `zig cc`.
Draft because the riskiest part of this deletes rows — see [Please
review this part first](#please-review-this-part-first) — and because
three things remain unverified at the bottom.
`modernc.org/sqlite` is a transpilation of the C amalgamation. This repo
is read-heavy: `cmd/server` chunk-loads a graph at startup and fans out
neighbour/topology/analytics queries per request, and it pays for that
transpilation on exactly those paths. Head-to-head on the same
120k-transmission / 240k-observation database, running our own hot-path
SQL under both drivers (Apple M4, `-count=5`, medians):
| workload | modernc | mattn | |
|---|---:|---:|---|
| chunk load (`chunked_load.go` v3 join, 20k tx) | 449ms | 196ms |
**2.3×** |
| aggregate scan (240k-row join + `GROUP BY`) | 276ms | 137ms | **2.0×**
|
| 1500 prepared-statement lookups | 512ms | 403ms | **1.3×** |
Allocations fall with it: 1.12M vs 1.64M allocs and 21MB vs 30MB on the
chunk load.
**Superseded by a production run.** @efiten measured both drivers on a
real instance — 11,077,038 observations, 9.7GB database, 4-core arm64 —
as server-only containers against the same live volume, one at a time,
with round 2 reversing the order so the page cache favours the old
driver:
| | audit 7d | audit 24h | background fill (13 chunks) | start →
/api/health |
|---|---:|---:|---:|---:|
| modernc, round 1 | 16.67s | 2.27s | 130.2s | 16.6s |
| mattn, round 1 | 7.87s | 1.34s | 93.8s | 13.5s |
| mattn, round 2 | 8.15s | 1.35s | 96.4s | 13.0s |
| modernc, round 2 | 13.46s | 2.29s | 137.8s | 15.5s |
Warm, the old driver improves to 13.46s on the 7d audit and still loses
by ~1.8×. Chunk load is ~1.4×. `/api/nodes?limit=500` is 0.039s against
0.037s — nothing.
**So the real gain is ~1.4–1.8× on the paths that matter, not 2–2.3×.**
The shape the harness predicted holds — scans and joins gain, small
lookups do not — which is more reassuring than the magnitude would have
been. Quote these numbers.
**The counterweight**, cold and native on that machine: a build goes
from **52s to 163s**. An instance that builds its own image pays that
per deploy.
## The build is cgo now, and one thing about that is a trap
**`CGO_ENABLED=0` still builds.** mattn links a stub, and the binary
dies on its first query with `go-sqlite3 requires cgo to work. This is a
stub`. A green build is not evidence of anything here, which is why
`AGENTS.md` now says so explicitly. `GOOS=linux go build` genuinely
cannot cross-compile any more.
A new root `Makefile` is the entry point. `make crossbuild` uses `zig cc
-target {x86_64,aarch64}-linux-musl` and links static, so each artifact
stays a single self-contained file and the `alpine:3.20` runtime no
longer depends on the base image's libc at all.
`-Wl,-s` is load-bearing: Go's own `-s -w` does not reach the musl
objects zig links in, and without it the server binary is 19.8MB instead
of 12.1MB.
The Dockerfile keeps its single `$BUILDPLATFORM` builder — still no QEMU
for compilation — and gains a checksum-pinned zig plus BuildKit cache
mounts. The mounts are not a nicety: without them an image build
recompiles the amalgamation from cold and takes over half an hour.
## Please review this part first
`internal/dbschema/dedup_index.go` **deletes observation rows**. It is
the one part of this change that can lose data, and it exists because
the migration exposed a real bug rather than causing one.
`stmtInsertObservation` resolves its `ON CONFLICT` against
`idx_observations_dedup`, which `cmd/ingestor/db.go` only ever created
inside the branch that creates the `observations` table for the first
time. Any database whose table predates that branch never got one, so
the UPSERT had no conflict target. modernc failed on the first insert;
mattn fails at `OpenStore`. Same bug, found earlier.
Creating the index unconditionally repairs it — but the index is what
was supposed to prevent duplicates, so a database that never had it can
already hold rows violating it. **`test-fixtures/e2e-fixture.db` in this
repo holds one.** So duplicates are collapsed first. Refusing is not the
safer option: without the index the ingestor cannot prepare its UPSERT,
so it cannot start at all.
Replaying that UPSERT faithfully is subtler than it looks, and a first
cut of this got it wrong twice:
- `COALESCE(excluded.x, x)` means the **incoming** value wins, so down a
group in id order the survivor keeps the **last** non-NULL value. Taking
the first silently discarded newer readings.
- The UPSERT names exactly five columns (`snr`, `rssi`, `score`,
`raw_hex`, `resolved_path`). Every other column must keep the surviving
row's own value; merging those too invents history the ingestor would
never have written.
Merge, delete and `CREATE UNIQUE INDEX` now share one transaction. Split
apart, a writer inserting a duplicate in the gap fails the index
creation while leaving the deletions committed — rows destroyed and no
index to show for it.
Cost, measured on 2.4M synthetic rows holding 5 duplicates: **4.1s**,
holding the write lock throughout, once, at ingestor startup before MQTT
subscribe. Materialising the duplicate-group scan once rather than per
column took that from 9.7s; the pathological case (400k of 600k rows
duplicated) is 5.7s, slightly worse than the 4.2s it was before that
change.
## Four more behavioural differences
Full detail in `docs/sqlite-driver-migration.md`. Briefly:
**Statement preparation is eager.** modernc's `newStmt` stored the SQL
and compiled lazily; mattn calls `sqlite3_prepare_v2` inside `Prepare`,
so SQL naming a missing table fails at *open*. 59 server tests failed on
this alone, all fixtures with partial schemas. `OpenDB` keeps failing
loudly (#1901; `main.go` gates on `dbschema.AssertReady` anyway) and the
fixtures now declare what they are prepared against via
`ensurePreparable`. This also exposed nine `nodes(pubkey …)`
declarations across seven files, where production has only ever had
`public_key` — lazy compilation had hidden the mismatch for as long as
it existed.
**`synchronous` silently dropped FULL → NORMAL.** mattn defaults it to
NORMAL and executes the pragma unconditionally, where SQLite's own
default (what modernc left alone) is FULL. In WAL mode that weakens
durability under power loss. Pinned in `dbschema.WriterDSN`, which both
writers now share — `cmd/migrate` kept a bare path at first and so
quietly wrote at NORMAL, which is what a second copy of a DSN buys you.
**The DSN dialects are mutually invisible.** modernc understood only
`_pragma=name(value)`, mattn only `_`-prefixed parameters, and neither
errors on the other's form — a driver-only rename would have dropped
every pragma in silence. `_journal_mode=WAL` is also gone from the
server's read handle: modernc ignored it, mattn honours it, and setting
`journal_mode` on a read-only connection is a write. Dropping
`_busy_timeout` with it costs nothing, since mattn already defaults to
5000ms — which means the read handle finally *gets* the busy timeout it
had silently lacked.
**`mode=ro` survives for a non-obvious reason.** mattn always passes
`READWRITE|CREATE` and its amalgamation has `SQLITE_USE_URI=0`; what
makes the URI work is its C wrapper ORing `SQLITE_OPEN_URI` in. So the
#1283/#1289 invariant holds with no build flags — but it depends on the
`file:` prefix. `cmd/decrypt` had been building its DSN without one, so
its `mode=ro` had never applied and a missing path was created
read-write. Fixed in passing; never a migration regression.
## What did not change
No modernc-specific API was in use: no `RegisterFunction`, no
`*sqlite.Conn`, no `sqlite/lib` error constants, no `sql.Register`. No
`time.Time` is ever bound as a query argument, so driver time handling
is not in play. Both drivers convert declared
`DATE`/`DATETIME`/`TIMESTAMP` columns to `time.Time`, so
`/api/dropped-packets` keeps emitting `dropped_at` as RFC3339 — an
earlier draft "fixed" that with a `CAST` and would have been the
regression.
## Tests and CI
New regression tests, each written because something got through without
it:
- `TestEnsureObservationsDedupIndexKeepsLatestValues` — the merge
ordering. The original test used complementary NULLs, which passes
whichever direction you pick, which is why the bug survived it.
- `TestCollapseDuplicatesAndIndexIsAtomic` — a failed index creation
must roll the deletions back.
- `TestOpenStorePragmas` / `TestWriterDSNPragmas` — every writer pragma,
read back through the store's own connection. A separate `sqlite3`
session or the startup log line would prove nothing.
- `TestOpenDBRefusesMissingDatabase` — the read-only invariant, which
now rests on a detail of the driver's C wrapper.
- `TestEnsurePreparableMatchesPrepareStatements` — fails when a new
prepared statement outgrows the fixture helper.
CI gains test execution for `cmd/migrate` and `internal/dbschema`, which
had none and both open the database. A PR-time two-arch build plus an
arm64 QEMU smoke gate is new: the GHCR push is push/tag-only, so without
it nothing on a PR would exercise zig, static musl linking or arm64, and
the first signal would arrive on master. `cache-dependency-path` widens
from 2 of the 5 tracked `go.sum` files to all of them.
`make test` passes across all 14 modules, `cmd/server` also under `-race
-count=2` with no failures and no races. `gofmt` and `go vet` clean.
Release-routing and Dockerfile COPY-invariant gates pass.
## Verified by running
- All 8 cross-builds static and correct-architecture; both arches of the
container image built, exported and run under QEMU, serving
`/api/health` and `/api/nodes` against a 2.9M-observation production
snapshot.
- The `migrate` binary repairing that snapshot's duplicate on bare
Alpine.
- `CGO_ENABLED=0` producing a binary that builds and then fails on first
query.
## Not verified
- ~~The 2–2.3× figures come from a standalone harness, not this load
under the old driver.~~ **Closed** by @efiten's production run above,
which also corrected the multiplier.
- SQLite 3.46.0 → 3.53.4 query-planner differences on queries with no
total `ORDER BY`.
- Sustained live ingest through the new writer DSN, and the duplicate
collapse against a database an ingestor is actively writing to. Verified
against a static snapshot only, and the collapse is measured at 4.1s on
2.4M synthetic rows with 5 duplicates — well short of an 11M-row
instance. @efiten has offered a staging instance taking real MQTT
traffic; **this is the item to close before the PR leaves draft.**
An earlier revision of this branch shipped the dedup merge in the wrong
direction with a green test suite, and review then found three more
things in the same file: the repair gated on an error string, a
non-atomic TEMP table drop aimed at the wrong connection, and a deletion
whose only record was a row count. All fixed in ac7e8d38. Passing tests
did not establish safety here, which is why the deletion path wanted a
second pair of eyes rather than a rubber stamp.
541 lines
21 KiB
Go
541 lines
21 KiB
Go
package dbschema
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
sqlite3 "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// observationsDB builds a database with an observations table but deliberately
|
|
// no idx_observations_dedup — the shape of every database created before
|
|
// cmd/ingestor/db.go started making that index, and of any database whose
|
|
// observations table it never ran against.
|
|
func observationsDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
db, err := sql.Open("sqlite3", "file:"+filepath.Join(t.TempDir(), "obs.db")+"?_journal_mode=WAL")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
// One connection, like cmd/ingestor. An unbounded pool hides any code that
|
|
// queries the pool while holding a transaction: it quietly opens a second
|
|
// connection instead of deadlocking, so the suite passes and production
|
|
// hangs. Every fixture here must match the tightest pool in production.
|
|
db.SetMaxOpenConns(1)
|
|
// The full observations shape, including all five columns the ingestor's
|
|
// UPSERT merges. An earlier fixture omitted score, raw_hex and
|
|
// resolved_path, so removing those three from upsertMergedColumns left the
|
|
// entire suite green — three fifths of the merge was untested.
|
|
if _, err := db.Exec(`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,
|
|
raw_hex TEXT,
|
|
resolved_path TEXT
|
|
)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return db
|
|
}
|
|
|
|
func dedupIndexExists(t *testing.T, db *sql.DB) bool {
|
|
t.Helper()
|
|
var n int
|
|
if err := db.QueryRow(
|
|
`SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_observations_dedup'`,
|
|
).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return n == 1
|
|
}
|
|
|
|
func TestEnsureObservationsDedupIndexOnCleanTable(t *testing.T) {
|
|
db := observationsDB(t)
|
|
if _, err := db.Exec(
|
|
`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp) VALUES (1, 1, '[]', 10), (1, 2, '[]', 10)`,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureObservationsDedupIndex(db, t.Logf); err != nil {
|
|
t.Fatalf("ensureObservationsDedupIndex: %v", err)
|
|
}
|
|
if !dedupIndexExists(t, db) {
|
|
t.Error("index was not created")
|
|
}
|
|
}
|
|
|
|
// The in-transaction DROP only matters on the success path: on failure ROLLBACK
|
|
// removes the temp table regardless. Replacing that DROP with a no-op survived
|
|
// every other test here, because none of them looked at the temp schema after a
|
|
// repair that worked.
|
|
//
|
|
// It matters because the connection goes back to the pool. A leftover
|
|
// dedup_groups is stale data the next repair would otherwise inherit.
|
|
func TestCollapseLeavesNoTempTableAfterSuccess(t *testing.T) {
|
|
db := observationsDB(t) // SetMaxOpenConns(1), so this is the same connection
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, path_json, timestamp) VALUES
|
|
(1, 21, 9, '[]', 10), (2, 21, 9, '[]', 10)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureObservationsDedupIndex(db, t.Logf); err != nil {
|
|
t.Fatalf("ensureObservationsDedupIndex: %v", err)
|
|
}
|
|
var n int
|
|
if err := db.QueryRow(
|
|
`SELECT COUNT(*) FROM temp.sqlite_master WHERE name LIKE 'dedup_groups%'`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("%d temp dedup_groups object(s) left on the connection after a successful repair", n)
|
|
}
|
|
}
|
|
|
|
// The interesting case: duplicates already present, which is what blocked the
|
|
// index from being created in the first place. They must be collapsed, and the
|
|
// survivor must inherit the non-NULL fields of the rows that went away — the
|
|
// same merge the ingestor's ON CONFLICT ... DO UPDATE SET x = COALESCE(...)
|
|
// would have performed had the index existed.
|
|
func TestEnsureObservationsDedupIndexCollapsesDuplicates(t *testing.T) {
|
|
db := observationsDB(t)
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, direction, snr, rssi, path_json, timestamp) VALUES
|
|
(1, 5, 3, 'rx', NULL, -90, '[]', 100),
|
|
(2, 5, 3, NULL, 7.5, NULL, '[]', 100),
|
|
(3, 5, 3, NULL, NULL, NULL, NULL, 100),
|
|
(4, 9, 1, 'rx', 1.0, -80, '["AA"]', 200)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := ensureObservationsDedupIndex(db, t.Logf); err != nil {
|
|
t.Fatalf("ensureObservationsDedupIndex: %v", err)
|
|
}
|
|
if !dedupIndexExists(t, db) {
|
|
t.Fatal("index was not created after collapsing duplicates")
|
|
}
|
|
|
|
// Rows 1 and 2 share the key (5, 3, '[]') and collapse into id 1.
|
|
//
|
|
// Row 3 does NOT join them: its path_json is NULL, and COALESCE(path_json,'')
|
|
// makes that the empty string, a different key from '[]'. That is the
|
|
// ingestor's conflict target verbatim, so an unrecorded path and an
|
|
// explicitly empty path are distinct observations here — worth pinning,
|
|
// because from the outside it looks like it ought to be one group.
|
|
// Row 4 has its own key and is untouched.
|
|
var ids string
|
|
if err := db.QueryRow(`SELECT GROUP_CONCAT(id) FROM (SELECT id FROM observations ORDER BY id)`).Scan(&ids); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ids != "1,3,4" {
|
|
t.Errorf("surviving ids = %q, want \"1,3,4\" (lowest id of each group; NULL path_json is its own group)", ids)
|
|
}
|
|
|
|
var direction sql.NullString
|
|
var snr, rssi sql.NullFloat64
|
|
if err := db.QueryRow(`SELECT direction, snr, rssi FROM observations WHERE id = 1`).Scan(&direction, &snr, &rssi); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if direction.String != "rx" {
|
|
t.Errorf("direction = %q, want \"rx\" (its own value)", direction.String)
|
|
}
|
|
if snr.Float64 != 7.5 {
|
|
t.Errorf("snr = %v, want 7.5 (merged from the row that was removed)", snr.Float64)
|
|
}
|
|
if rssi.Float64 != -90 {
|
|
t.Errorf("rssi = %v, want -90 (its own value, not overwritten by a NULL)", rssi.Float64)
|
|
}
|
|
}
|
|
|
|
// The merge has to replay the UPSERT, and the UPSERT's
|
|
// `COALESCE(excluded.x, x)` means a later non-NULL value REPLACES an earlier
|
|
// one. Complementary NULLs (as above) cannot tell "first non-NULL wins" from
|
|
// "last non-NULL wins" — both produce the same answer — so this asserts the
|
|
// direction explicitly with values that conflict.
|
|
func TestEnsureObservationsDedupIndexKeepsLatestValues(t *testing.T) {
|
|
db := observationsDB(t)
|
|
// One group, three rows, every merged column non-NULL and different.
|
|
// path_json is identical so they collide; direction is NOT in the UPSERT's
|
|
// SET list, so the survivor must keep its own.
|
|
// All five merged columns differ across the group, so each one proves the
|
|
// direction independently. direction/timestamp are NOT in the UPSERT's SET
|
|
// list and must keep the survivor's own values.
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, direction, snr, rssi, score, path_json, timestamp, raw_hex, resolved_path) VALUES
|
|
(1, 7, 2, 'first', 1.0, -10, 11, '[]', 100, 'aa', '["a"]'),
|
|
(2, 7, 2, 'second', 7.0, -20, 22, '[]', 200, 'bb', '["b"]'),
|
|
(3, 7, 2, 'third', 9.0, -30, 33, '[]', 300, 'cc', '["c"]')`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureObservationsDedupIndex(db, t.Logf); err != nil {
|
|
t.Fatalf("ensureObservationsDedupIndex: %v", err)
|
|
}
|
|
|
|
var direction, rawHex, resolvedPath string
|
|
var snr, rssi float64
|
|
var score, timestamp int64
|
|
if err := db.QueryRow(`SELECT direction, snr, rssi, score, raw_hex, resolved_path, timestamp
|
|
FROM observations WHERE id = 1`).Scan(
|
|
&direction, &snr, &rssi, &score, &rawHex, &resolvedPath, ×tamp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Merged: last non-NULL down the group wins.
|
|
for _, c := range []struct {
|
|
name string
|
|
got, want any
|
|
}{
|
|
{"snr", snr, 9.0},
|
|
{"rssi", rssi, -30.0},
|
|
{"score", score, int64(33)},
|
|
{"raw_hex", rawHex, "cc"},
|
|
{"resolved_path", resolvedPath, `["c"]`},
|
|
} {
|
|
if c.got != c.want {
|
|
t.Errorf("%s = %v, want %v (last non-NULL: COALESCE(excluded.x, x) lets later rows win)", c.name, c.got, c.want)
|
|
}
|
|
}
|
|
// Not merged: the UPSERT never SETs these, so the survivor keeps its own.
|
|
if direction != "first" {
|
|
t.Errorf("direction = %q, want \"first\": not in the UPSERT's SET list", direction)
|
|
}
|
|
if timestamp != 100 {
|
|
t.Errorf("timestamp = %d, want 100: not in the UPSERT's SET list", timestamp)
|
|
}
|
|
var n int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM observations`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("observations = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// The repair must be all-or-nothing. If the index cannot be created, the
|
|
// deletions must not survive: rows destroyed with no index to show for it is
|
|
// the worst outcome available.
|
|
func TestCollapseDuplicatesAndIndexIsAtomic(t *testing.T) {
|
|
db := observationsDB(t)
|
|
// Conflicting readings, so the merge has something to write. Checking only
|
|
// the row count let a version that committed the merge early pass: the rows
|
|
// came back but their values did not.
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, snr, rssi, path_json, timestamp) VALUES
|
|
(1, 1, 1, 1.0, -10, '[]', 10),
|
|
(2, 1, 1, 2.0, -20, '[]', 10)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Occupy the index name with a TABLE. `CREATE INDEX IF NOT EXISTS` only
|
|
// shrugs when an *index* of that name exists; a table of that name is an
|
|
// error, so the CREATE fails after the merge and delete have already run.
|
|
if _, err := db.Exec(`CREATE TABLE idx_observations_dedup (x INTEGER)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := collapseDuplicatesAndIndex(db, t.Logf); err == nil {
|
|
t.Fatal("expected index creation to fail")
|
|
}
|
|
|
|
var n int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM observations`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 2 {
|
|
t.Errorf("observations = %d, want 2: a failed index creation must roll the deletions back", n)
|
|
}
|
|
// The merge must roll back too, not just the delete.
|
|
var snr, rssi float64
|
|
if err := db.QueryRow(`SELECT snr, rssi FROM observations WHERE id = 1`).Scan(&snr, &rssi); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if snr != 1.0 || rssi != -10 {
|
|
t.Errorf("id=1 snr=%v rssi=%v, want 1/-10: the merge must roll back with the delete", snr, rssi)
|
|
}
|
|
// And nothing may be left in the temp schema for the next user of this
|
|
// connection. Replacing the in-transaction DROP with a no-op used to pass.
|
|
var temps int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM temp.sqlite_master WHERE name LIKE 'dedup_groups%'`).Scan(&temps); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if temps != 0 {
|
|
t.Errorf("temp.dedup_groups survived a failed repair (%d objects)", temps)
|
|
}
|
|
}
|
|
|
|
// Running twice must be a no-op: Apply runs on every ingestor start.
|
|
func TestEnsureObservationsDedupIndexIsIdempotent(t *testing.T) {
|
|
db := observationsDB(t)
|
|
if _, err := db.Exec(`INSERT INTO observations (transmission_id, observer_idx, path_json, timestamp) VALUES (1, 1, '[]', 10), (1, 1, '[]', 10)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := range 2 {
|
|
if err := ensureObservationsDedupIndex(db, t.Logf); err != nil {
|
|
t.Fatalf("pass %d: %v", i+1, err)
|
|
}
|
|
}
|
|
var n int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM observations`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("observations = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// v2 schemas key observations by observer_id, not observer_idx. The ingestor's
|
|
// UPSERT does not apply there, and indexing a missing column would error, so the
|
|
// step must skip rather than fail.
|
|
func TestEnsureObservationsDedupIndexSkipsV2Schema(t *testing.T) {
|
|
db, err := sql.Open("sqlite3", "file:"+filepath.Join(t.TempDir(), "v2.db")+"?_journal_mode=WAL")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := db.Exec(`CREATE TABLE observations (id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureObservationsDedupIndex(db, t.Logf); err != nil {
|
|
t.Fatalf("expected a skip on a v2 schema, got: %v", err)
|
|
}
|
|
if dedupIndexExists(t, db) {
|
|
t.Error("index must not be created on a v2 schema")
|
|
}
|
|
}
|
|
|
|
// Nothing covered the branch that DECIDES to repair. TestCollapseDuplicates...
|
|
// calls collapseDuplicatesAndIndex directly, so a broken error check in
|
|
// ensureObservationsDedupIndex would leave every one of those tests green while
|
|
// production silently skipped the repair and failed later at OpenStore.
|
|
//
|
|
// This asserts the decision: duplicates present, the real driver's real error,
|
|
// and the repair actually taken. It is the test that would catch the driver
|
|
// rewording its constraint message.
|
|
func TestEnsureObservationsDedupIndexTakesRepairPathOnRealDriverError(t *testing.T) {
|
|
db := observationsDB(t)
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, path_json, timestamp) VALUES
|
|
(1, 4, 4, '[]', 10), (2, 4, 4, '[]', 10)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The error the fast path actually gets. If isConstraintViolation stops
|
|
// recognising this, the repair below never runs.
|
|
_, createErr := db.Exec(dedupIndexDDL)
|
|
if createErr == nil {
|
|
t.Fatal("expected CREATE UNIQUE INDEX to fail over duplicates")
|
|
}
|
|
if !isConstraintViolation(createErr) {
|
|
t.Fatalf("isConstraintViolation did not recognise the driver's own error: %v (%T)", createErr, createErr)
|
|
}
|
|
|
|
// The above passes just as happily with a strings.Contains check, because
|
|
// the current driver still says "UNIQUE constraint failed". These pin the
|
|
// typed behaviour itself: a constraint error whose text says nothing of the
|
|
// sort must still be recognised, a wrapped one must be unwrapped, and an
|
|
// unrelated error carrying the magic phrase must NOT trigger a repair that
|
|
// deletes rows.
|
|
typed := sqlite3.Error{Code: sqlite3.ErrConstraint, ExtendedCode: sqlite3.ErrConstraintUnique}
|
|
if !isConstraintViolation(typed) {
|
|
t.Error("a bare sqlite3.Error with ErrConstraint was not recognised")
|
|
}
|
|
if !isConstraintViolation(fmt.Errorf("create index: %w", typed)) {
|
|
t.Error("a wrapped constraint error was not recognised; errors.As should unwrap it")
|
|
}
|
|
if isConstraintViolation(errors.New("UNIQUE constraint failed: index 'x'")) {
|
|
t.Error("a plain error carrying the message was treated as a constraint violation — " +
|
|
"that is the string match this check exists to replace")
|
|
}
|
|
if isConstraintViolation(sqlite3.Error{Code: sqlite3.ErrBusy}) {
|
|
t.Error("SQLITE_BUSY was treated as a constraint violation; a locked database must not start deleting rows")
|
|
}
|
|
|
|
var repaired bool
|
|
logf := func(format string, args ...interface{}) {
|
|
repaired = true
|
|
t.Logf(format, args...)
|
|
}
|
|
if err := ensureObservationsDedupIndex(db, logf); err != nil {
|
|
t.Fatalf("ensureObservationsDedupIndex: %v", err)
|
|
}
|
|
if !repaired {
|
|
t.Error("repair path was not taken: no log output, so the constraint error was not recognised")
|
|
}
|
|
if !dedupIndexExists(t, db) {
|
|
t.Error("index missing after repair")
|
|
}
|
|
var n int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM observations`).Scan(&n); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("observations = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
// The audit log has to name what it destroyed, not just count it.
|
|
func TestCollapseLogsGroupKeysBeforeDeleting(t *testing.T) {
|
|
db := observationsDB(t)
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, path_json, timestamp) VALUES
|
|
(1, 11, 3, '["AA"]', 10), (2, 11, 3, '["AA"]', 10), (3, 11, 3, '["AA"]', 10)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out []string
|
|
logf := func(format string, args ...interface{}) {
|
|
out = append(out, fmt.Sprintf(format, args...))
|
|
}
|
|
if err := ensureObservationsDedupIndex(db, logf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
joined := strings.Join(out, "\n")
|
|
for _, want := range []string{
|
|
"1 duplicate observation group(s), 2 row(s) to remove",
|
|
"transmission_id=11",
|
|
`path_json="[\"AA\"]"`,
|
|
"keeping id=1",
|
|
} {
|
|
if !strings.Contains(joined, want) {
|
|
t.Errorf("audit log missing %q; got:\n%s", want, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Content is not the point — order is. The log exists so that a repair which
|
|
// destroys the wrong rows leaves a record of what it destroyed, which requires
|
|
// the keys to be written BEFORE the delete, not after. Asserting content alone
|
|
// passed with the logging moved below the DELETE.
|
|
//
|
|
// Forcing the repair to fail after the merge proves the ordering: if the keys
|
|
// were logged before the failure, they were logged before the delete.
|
|
func TestCollapseLogsGroupKeysEvenWhenTheRepairFails(t *testing.T) {
|
|
db := observationsDB(t)
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, path_json, timestamp) VALUES
|
|
(1, 12, 4, '["BB"]', 10), (2, 12, 4, '["BB"]', 10)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Fail at the DELETE specifically. Failing later (at CREATE INDEX) does not
|
|
// pin the ordering: logging moved to just after the DELETE would still have
|
|
// run. A BEFORE DELETE trigger that aborts puts the failure exactly at the
|
|
// step the log is supposed to precede.
|
|
if _, err := db.Exec(`CREATE TRIGGER block_delete BEFORE DELETE ON observations
|
|
BEGIN SELECT RAISE(ABORT, 'no deletes'); END`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var out []string
|
|
logf := func(format string, args ...interface{}) { out = append(out, fmt.Sprintf(format, args...)) }
|
|
if _, err := collapseDuplicatesAndIndex(db, logf); err == nil {
|
|
t.Fatal("expected the repair to fail")
|
|
}
|
|
|
|
joined := strings.Join(out, "\n")
|
|
if !strings.Contains(joined, "transmission_id=12") || !strings.Contains(joined, "keeping id=1") {
|
|
t.Errorf("group keys were not logged before the repair failed, so a failed or wrong "+
|
|
"repair leaves no record of what it touched; got:\n%s", joined)
|
|
}
|
|
}
|
|
|
|
// A pool of one is what cmd/ingestor runs. Anything in the repair that queries
|
|
// the pool while holding the transaction waits for a connection the transaction
|
|
// itself has checked out, and never gets it: the ingestor hangs at boot, after
|
|
// logging that it is repairing, with the database untouched and ingest dead.
|
|
//
|
|
// Found on staging, not here, because every fixture used an unbounded pool.
|
|
// Runs in a goroutine so a regression fails in seconds with a usable message
|
|
// rather than hanging until the package timeout.
|
|
func TestCollapseDoesNotDeadlockOnSingleConnectionPool(t *testing.T) {
|
|
db := observationsDB(t) // SetMaxOpenConns(1)
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, path_json, timestamp) VALUES
|
|
(1, 3, 1, '[]', 10), (2, 3, 1, '[]', 10)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
done := make(chan error, 1)
|
|
go func() { done <- ensureObservationsDedupIndex(db, func(string, ...interface{}) {}) }()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("ensureObservationsDedupIndex: %v", err)
|
|
}
|
|
case <-time.After(15 * time.Second):
|
|
t.Fatal("deadlock: the repair is querying the pool while holding its own transaction — " +
|
|
"pass tx, not rw, to anything that reads inside collapseDuplicatesAndIndex")
|
|
}
|
|
if !dedupIndexExists(t, db) {
|
|
t.Error("index missing")
|
|
}
|
|
}
|
|
|
|
// GROUP BY folds NULLs together; a UNIQUE index keeps them apart. Rows with a
|
|
// NULL in an indexed column can never violate the index, so the repair must not
|
|
// treat them as duplicates at all.
|
|
//
|
|
// The damage is not the obvious one. Both the DELETE and the merge's correlated
|
|
// subquery join on `observer_idx = observer_idx`, and NULL = NULL is not true,
|
|
// so the rows are never actually deleted — the *merge* is what destroys data:
|
|
// the subquery matches nothing and writes NULL over the survivor's real
|
|
// readings. Measured with the guard removed, a row holding snr=4.5 rssi=-70
|
|
// came back with both NULL and its row still in place, so nothing looks missing
|
|
// while the measurements are gone. Assert the values, not just the row count:
|
|
// an earlier version of this test checked survival alone and passed against the
|
|
// bug.
|
|
//
|
|
// On an 11.2M-row instance, 198 of 222 reported groups were observer_idx IS
|
|
// NULL — direction='tx' rows.
|
|
func TestCollapseLeavesNullKeyedRowsAlone(t *testing.T) {
|
|
db := observationsDB(t)
|
|
if _, err := db.Exec(`INSERT INTO observations (id, transmission_id, observer_idx, direction, snr, rssi, path_json, timestamp) VALUES
|
|
(1, 1, NULL, 'tx', 4.5, -70, '[]', 10),
|
|
(2, 1, NULL, 'tx', 5.5, -60, '[]', 10),
|
|
(3, 2, 7, 'rx', 1.0, -80, '[]', 20),
|
|
(4, 2, 7, 'rx', 2.0, -90, '[]', 20)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var logged []string
|
|
logf := func(format string, args ...interface{}) { logged = append(logged, fmt.Sprintf(format, args...)) }
|
|
if err := ensureObservationsDedupIndex(db, logf); err != nil {
|
|
t.Fatalf("ensureObservationsDedupIndex: %v", err)
|
|
}
|
|
|
|
// Only the observer_idx=7 pair was a real violation: one row removed there,
|
|
// both NULL rows left entirely alone.
|
|
var ids string
|
|
if err := db.QueryRow(`SELECT GROUP_CONCAT(id) FROM (SELECT id FROM observations ORDER BY id)`).Scan(&ids); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if ids != "1,2,3" {
|
|
t.Errorf("surviving ids = %q, want \"1,2,3\": NULL observer_idx rows never violate the unique index", ids)
|
|
}
|
|
|
|
// The readings on the NULL rows must be exactly as inserted.
|
|
for _, want := range []struct {
|
|
id int64
|
|
snr, rssi float64
|
|
}{{1, 4.5, -70}, {2, 5.5, -60}} {
|
|
var snr, rssi sql.NullFloat64
|
|
if err := db.QueryRow(`SELECT snr, rssi FROM observations WHERE id = ?`, want.id).Scan(&snr, &rssi); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !snr.Valid || !rssi.Valid {
|
|
t.Errorf("id=%d: snr/rssi wiped to NULL — the merge matched nothing and overwrote real readings", want.id)
|
|
continue
|
|
}
|
|
if snr.Float64 != want.snr || rssi.Float64 != want.rssi {
|
|
t.Errorf("id=%d: snr=%v rssi=%v, want %v/%v", want.id, snr.Float64, rssi.Float64, want.snr, want.rssi)
|
|
}
|
|
}
|
|
|
|
if !dedupIndexExists(t, db) {
|
|
t.Error("index missing — proof the NULL rows were never in its way")
|
|
}
|
|
if strings.Contains(strings.Join(logged, "\n"), "observer_idx=0") {
|
|
t.Error("a NULL observer_idx was logged as 0, which hides exactly this class of bug")
|
|
}
|
|
}
|