mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-15 16:29:55 +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.
315 lines
13 KiB
Go
315 lines
13 KiB
Go
package dbschema
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
sqlite3 "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
// upsertMergedColumns are the columns the ingestor's observation UPSERT writes
|
|
// on conflict, in cmd/ingestor/db.go:
|
|
//
|
|
// ON CONFLICT(...) DO UPDATE SET
|
|
// snr = COALESCE(excluded.snr, snr),
|
|
// rssi = COALESCE(excluded.rssi, rssi),
|
|
// score = COALESCE(excluded.score, score),
|
|
// raw_hex = COALESCE(excluded.raw_hex, raw_hex),
|
|
// resolved_path = COALESCE(excluded.resolved_path, resolved_path)
|
|
//
|
|
// Two things follow, and collapseDuplicatesAndIndex has to honour both to be a
|
|
// faithful replay rather than an approximation of it.
|
|
//
|
|
// First, `excluded` is the incoming row, so a non-NULL later value REPLACES an
|
|
// earlier one. Applied down a group in insertion order the result is the LAST
|
|
// non-NULL value, not the first.
|
|
//
|
|
// Second, every other column is absent from the SET list, so the UPSERT never
|
|
// touches it: the surviving row keeps its own direction, timestamp, path_json
|
|
// and so on. Merging those too would invent history the ingestor would not have
|
|
// written.
|
|
var upsertMergedColumns = []string{"snr", "rssi", "score", "raw_hex", "resolved_path"}
|
|
|
|
// dupGroupsDDL materialises one row per duplicate group: the group key plus the
|
|
// earliest id, which is the row that is kept and merged into.
|
|
//
|
|
// Materialised rather than left as a repeated subquery on purpose. In the
|
|
// subquery form every merged column's UPDATE re-ran this GROUP BY over the whole
|
|
// observations table — measured at 9.7s on 2.4M rows holding only 5 duplicates,
|
|
// nearly all of it the same scan performed repeatedly. This runs inside a write
|
|
// transaction and holds the write lock for its duration, so the repetition is
|
|
// worth removing.
|
|
// The WHERE clause is the whole correctness of this query, and leaving it out
|
|
// destroys data.
|
|
//
|
|
// GROUP BY folds all NULLs into one group. A UNIQUE index does the opposite:
|
|
// SQLite treats NULLs as distinct, so a row with NULL in any indexed column can
|
|
// never violate it. Group without excluding them and the repair calls rows
|
|
// duplicates that the index would have accepted, then deletes them to build an
|
|
// index that did not need them gone.
|
|
//
|
|
// Not hypothetical: on an 11.2M-row instance, 198 of the 222 groups this
|
|
// reported had observer_idx IS NULL — 238 of the 262 rows it planned to delete.
|
|
// They were direction='tx' rows, and the index built without complaint once
|
|
// they were left alone.
|
|
//
|
|
// transmission_id is NOT NULL in the schema and COALESCE(path_json, ”) can
|
|
// never be NULL, so observer_idx is the only one that needs the guard; it is
|
|
// written out in full anyway, because the next person to add a column to this
|
|
// index should see the rule rather than infer it.
|
|
const dupGroupsDDL = `CREATE TEMP TABLE dedup_groups AS
|
|
SELECT transmission_id, observer_idx, COALESCE(path_json, '') AS p,
|
|
MIN(id) AS keep, COUNT(*) AS n
|
|
FROM observations
|
|
WHERE transmission_id IS NOT NULL
|
|
AND observer_idx IS NOT NULL
|
|
GROUP BY transmission_id, observer_idx, COALESCE(path_json, '')
|
|
HAVING COUNT(*) > 1`
|
|
|
|
const dedupIndexDDL = `CREATE UNIQUE INDEX IF NOT EXISTS idx_observations_dedup ` +
|
|
`ON observations(transmission_id, observer_idx, COALESCE(path_json, ''))`
|
|
|
|
// ensureObservationsDedupIndex creates the unique expression index that the
|
|
// ingestor's observation UPSERT resolves its ON CONFLICT target against:
|
|
//
|
|
// ON CONFLICT(transmission_id, observer_idx, COALESCE(path_json, ''))
|
|
//
|
|
// cmd/ingestor/db.go creates this index, but only inside the branch that
|
|
// creates the observations table for the first time. Any database whose
|
|
// observations table predates that branch therefore never got one, so the
|
|
// UPSERT had no conflict target to resolve against. Under modernc.org/sqlite
|
|
// that surfaced late — statements compiled lazily, so it failed on the first
|
|
// insert. github.com/mattn/go-sqlite3 compiles inside Prepare, so it now
|
|
// surfaces at OpenStore. Same bug either way; creating the index here repairs
|
|
// the database rather than just moving the error.
|
|
//
|
|
// Because the index is what was supposed to prevent duplicates, a database that
|
|
// never had it can already hold rows violating it — the repo's own
|
|
// test-fixtures/e2e-fixture.db did. Those rows are collapsed first. Refusing
|
|
// would not be safer: without the index the ingestor cannot prepare its UPSERT,
|
|
// so it cannot run at all.
|
|
//
|
|
// Skipped on v2 schemas (observer_id rather than observer_idx): the UPSERT does
|
|
// not apply there, and indexing a column that does not exist would fail.
|
|
func ensureObservationsDedupIndex(rw *sql.DB, logf Logger) error {
|
|
hasIdx, err := TableHasColumn(rw, "observations", "observer_idx")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !hasIdx {
|
|
return nil
|
|
}
|
|
// Fast path: no duplicates, so the index just builds. Atomic on its own.
|
|
_, err = rw.Exec(dedupIndexDDL)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if !isConstraintViolation(err) {
|
|
return err
|
|
}
|
|
|
|
// Deleting rows is not something to do quietly, and on a large table this
|
|
// holds the write lock long enough that an operator watching startup
|
|
// deserves to know why before it happens rather than after.
|
|
logf("[dbschema] idx_observations_dedup cannot be created: duplicate observations exist. " +
|
|
"Repairing now — on a large observations table this takes tens of seconds, and " +
|
|
"writers are blocked for part of it.")
|
|
|
|
removed, err := collapseDuplicatesAndIndex(rw, logf)
|
|
if err != nil {
|
|
return fmt.Errorf("collapse duplicate observations: %w", err)
|
|
}
|
|
logf("[dbschema] collapsed %d duplicate observation row(s); idx_observations_dedup created", removed)
|
|
return nil
|
|
}
|
|
|
|
// isConstraintViolation reports whether err is SQLite refusing a constraint.
|
|
//
|
|
// Deliberately a typed check. This used to match on
|
|
// strings.Contains(err, "UNIQUE constraint failed"), which made the entire
|
|
// repair path — including the row deletion — hinge on the driver's prose, in
|
|
// the same change that swapped the driver. A reworded or wrapped message would
|
|
// silently skip the repair and surface later as an OpenStore failure with no
|
|
// hint as to why. CREATE UNIQUE INDEX can only hit a constraint error because
|
|
// the data violates the uniqueness it asks for, so the primary code is the
|
|
// right granularity.
|
|
func isConstraintViolation(err error) bool {
|
|
var se sqlite3.Error
|
|
if errors.As(err, &se) {
|
|
return se.Code == sqlite3.ErrConstraint
|
|
}
|
|
return false
|
|
}
|
|
|
|
// collapseDuplicatesAndIndex merges duplicate observation rows into the lowest
|
|
// id of each group, deletes the rest, and creates the unique index — all in one
|
|
// transaction. It returns the number of rows deleted.
|
|
//
|
|
// The index creation belongs inside the same transaction as the deletion. With
|
|
// them separated, a writer inserting a duplicate in the gap makes the index
|
|
// creation fail while leaving the deletions committed: rows destroyed and no
|
|
// index to show for it. One transaction makes the repair all-or-nothing.
|
|
func collapseDuplicatesAndIndex(rw *sql.DB, logf Logger) (int64, error) {
|
|
tx, err := rw.Begin()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
// Every reference to the TEMP table goes through tx, so its whole lifecycle
|
|
// sits on one connection: created here, dropped before COMMIT below, and
|
|
// removed by ROLLBACK on any error path (a TEMP table created inside a
|
|
// transaction does not survive its rollback — verified, not assumed).
|
|
//
|
|
// It has to be tx, not rw. A TEMP table belongs to a single connection and
|
|
// database/sql hands out pooled ones, so an `rw.Exec` drop can land on a
|
|
// different connection and leave the table behind on the one that has it.
|
|
// The leading DROP is still here because this package cannot prove nothing
|
|
// else ever left one on this connection.
|
|
if _, err := tx.Exec(`DROP TABLE IF EXISTS temp.dedup_groups`); err != nil {
|
|
return 0, fmt.Errorf("drop stale dedup_groups: %w", err)
|
|
}
|
|
if _, err := tx.Exec(dupGroupsDDL); err != nil {
|
|
return 0, fmt.Errorf("build dedup_groups: %w", err)
|
|
}
|
|
if _, err := tx.Exec(`CREATE INDEX temp.dedup_groups_key
|
|
ON dedup_groups(transmission_id, observer_idx, p)`); err != nil {
|
|
return 0, fmt.Errorf("index dedup_groups: %w", err)
|
|
}
|
|
|
|
// The group keys exist right now and cease to after the delete. Record them
|
|
// before destroying anything: a row count is not enough to reconstruct from
|
|
// if the merge direction is ever wrong again.
|
|
if err := logDuplicateGroups(tx, logf); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Only merge columns this database actually has. Apply runs this step
|
|
// before ensureResolvedPathColumn and ensureObservationsRawHexColumn, so on
|
|
// a database old enough to be missing the dedup index, resolved_path and
|
|
// raw_hex may not exist yet either.
|
|
cols, err := existingColumns(tx, "observations", upsertMergedColumns)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Replay the UPSERT: for each merged column take the LAST non-NULL value in
|
|
// the group by id. The subquery spans the whole group, the survivor
|
|
// included, so when every row is NULL the result is NULL — which is what
|
|
// the UPSERT would also have left behind.
|
|
for _, c := range cols {
|
|
q := `UPDATE observations SET ` + c + ` = (
|
|
SELECT o2.` + c + ` FROM observations o2
|
|
WHERE o2.transmission_id = observations.transmission_id
|
|
AND o2.observer_idx = observations.observer_idx
|
|
AND COALESCE(o2.path_json, '') = COALESCE(observations.path_json, '')
|
|
AND o2.` + c + ` IS NOT NULL
|
|
ORDER BY o2.id DESC LIMIT 1)
|
|
WHERE id IN (SELECT keep FROM dedup_groups)`
|
|
if _, err := tx.Exec(q); err != nil {
|
|
return 0, fmt.Errorf("merge %s: %w", c, err)
|
|
}
|
|
}
|
|
|
|
res, err := tx.Exec(`DELETE FROM observations WHERE id IN (
|
|
SELECT o.id FROM observations o
|
|
JOIN dedup_groups d
|
|
ON d.transmission_id = o.transmission_id
|
|
AND d.observer_idx = o.observer_idx
|
|
AND d.p = COALESCE(o.path_json, '')
|
|
WHERE o.id <> d.keep)`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
removed, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
// Same transaction: if this fails, the deletions above roll back with it.
|
|
if _, err := tx.Exec(`DROP TABLE temp.dedup_groups`); err != nil {
|
|
return 0, fmt.Errorf("drop dedup_groups: %w", err)
|
|
}
|
|
|
|
if _, err := tx.Exec(dedupIndexDDL); err != nil {
|
|
return 0, fmt.Errorf("create idx_observations_dedup after collapsing %d row(s): %w", removed, err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
return removed, nil
|
|
}
|
|
|
|
// existingColumns filters want down to the columns table actually has, keeping
|
|
// the given order.
|
|
//
|
|
// Takes a Querier, not *sql.DB, and callers inside a transaction must pass the
|
|
// transaction. Probing the pool from inside one deadlocks the ingestor, whose
|
|
// pool is capped at a single connection: the probe waits for the connection the
|
|
// transaction is holding, and nothing ever releases it.
|
|
func existingColumns(rw Querier, table string, want []string) ([]string, error) {
|
|
out := make([]string, 0, len(want))
|
|
for _, c := range want {
|
|
ok, err := TableHasColumn(rw, table, c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// maxLoggedDupGroups bounds the audit log. A database missing the index for
|
|
// long enough can have a great many duplicate groups, and an unbounded dump at
|
|
// startup is its own operational problem.
|
|
const maxLoggedDupGroups = 20
|
|
|
|
// logDuplicateGroups records what is about to be collapsed, while dedup_groups
|
|
// still holds it. Reads through tx because the TEMP table lives on that
|
|
// transaction's connection.
|
|
func logDuplicateGroups(tx *sql.Tx, logf Logger) error {
|
|
var groups, extra int64
|
|
if err := tx.QueryRow(`SELECT COUNT(*), COALESCE(SUM(n - 1), 0) FROM dedup_groups`).
|
|
Scan(&groups, &extra); err != nil {
|
|
return fmt.Errorf("count dedup_groups: %w", err)
|
|
}
|
|
logf("[dbschema] %d duplicate observation group(s), %d row(s) to remove", groups, extra)
|
|
|
|
rows, err := tx.Query(`SELECT transmission_id, observer_idx, p, keep, n
|
|
FROM dedup_groups ORDER BY keep LIMIT ?`, maxLoggedDupGroups)
|
|
if err != nil {
|
|
return fmt.Errorf("list dedup_groups: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
var listed int64
|
|
for rows.Next() {
|
|
var txID, keep, n int64
|
|
var observerIdx sql.NullInt64
|
|
var pathJSON string
|
|
if err := rows.Scan(&txID, &observerIdx, &pathJSON, &keep, &n); err != nil {
|
|
return fmt.Errorf("scan dedup_groups: %w", err)
|
|
}
|
|
// Print NULL as NULL. Rendering observerIdx.Int64 unconditionally shows
|
|
// a NULL as 0, which is the one value that would hide the grouping bug
|
|
// above from the only person able to notice it.
|
|
idx := "NULL"
|
|
if observerIdx.Valid {
|
|
idx = strconv.FormatInt(observerIdx.Int64, 10)
|
|
}
|
|
logf("[dbschema] transmission_id=%d observer_idx=%s path_json=%q rows=%d keeping id=%d",
|
|
txID, idx, pathJSON, n, keep)
|
|
listed++
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return fmt.Errorf("iterate dedup_groups: %w", err)
|
|
}
|
|
if groups > listed {
|
|
logf("[dbschema] ... and %d more group(s) not listed", groups-listed)
|
|
}
|
|
return nil
|
|
}
|