mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-23 18:12:30 +00:00
fix(dbschema): stop gating the dedup repair on an error string, and log what it deletes
Review feedback on #1992, all three points on `internal/dbschema/dedup_index.go`. **The repair decision no longer hinges on prose.** `ensureObservationsDedupIndex` matched `strings.Contains(err.Error(), "UNIQUE constraint failed")` to decide whether to repair — a text match on driver output, introduced by the same change that swapped the driver. A reworded or wrapped message would skip the repair silently and resurface as an `OpenStore` failure with nothing pointing at the cause. It now checks `sqlite3.Error.Code == sqlite3.ErrConstraint` via `errors.As`. Confirmed against the driver: mattn returns `Code=19` (`ErrConstraint`), `ExtendedCode=2067` (`ErrConstraintUnique`) for `CREATE UNIQUE INDEX` over duplicates, and `errors.As` reaches it. `TestEnsureObservationsDedupIndexTakesRepairPathOnRealDriverError` covers the branch that *decides* to repair, which nothing did: the existing tests call `collapseDuplicatesAndIndex` directly, so a broken error check would have left all of them green. It asserts `isConstraintViolation` recognises the driver's own error and that the repair actually runs. **The TEMP table lifecycle now matches its comment.** The deferred cleanup ran `rw.Exec("DROP TABLE IF EXISTS temp.dedup_groups")`, but a TEMP table belongs to one connection and `database/sql` hands out pooled ones, so that drop could land on a different connection and leave the table on the one holding it. Harmless in `cmd/ingestor` at `SetMaxOpenConns(1)`, not guaranteed for `cmd/migrate`, which calls the same function with an unbounded pool. Every reference is now through `tx`: created, dropped before COMMIT, and removed by ROLLBACK on the error paths. Verified rather than assumed — a TEMP table created inside a transaction does not survive its rollback, so the deferred drop was both unnecessary and aimed at the wrong connection. The leading `DROP ... IF EXISTS` stays, since this package cannot prove nothing else left one behind. **The deletion is auditable now.** `collapsed N duplicate observation row(s)` was the whole record, and if the merge direction were ever wrong again that line is all anyone would have to work from. The group keys are sitting in `dedup_groups` at that moment, so they get logged — group count, rows to remove, then the first 20 groups with their key and the id being kept — before anything is destroyed. It also warns up front that the write lock is held, because on a large table the pause at ingestor startup is otherwise unexplained: [dbschema] idx_observations_dedup cannot be created: duplicate observations exist. Repairing now — this holds the write lock until it completes, and on a large observations table it can take tens of seconds. [dbschema] 1 duplicate observation group(s), 1 row(s) to remove [dbschema] transmission_id=4 observer_idx=4 path_json="[]" rows=2 keeping id=1 [dbschema] collapsed 1 duplicate observation row(s); idx_observations_dedup created **Docs carry the production numbers.** The PR's headline came from a standalone harness; @efiten ran both drivers against an 11M-observation, 9.7GB instance on 4-core arm64, with the order reversed in a second round so the page cache favoured the old driver. Warm, the 7d audit is ~1.8x and chunk load ~1.4x, and `/api/nodes?limit=500` is unchanged. So the real gain on the paths that matter is 1.4-1.8x, not the 2-2.3x the harness showed, and the doc now says to quote those instead. It also records the counterweight nobody had quantified: a cold native build goes from 52s to 163s, which an instance building its own image pays per deploy. **Also removes four files the previous commit should not have added.** `cmd/server/prune-requests/*.json` is runtime queue state written by `internal/prunequeue` and left behind by the server tests; a `git add -A` swept it in. They were never in master, so the branch's net diff was unaffected, but they had no business being committed. `prune-requests/` is now in `.gitignore` so it cannot happen again. Constraint: the repair must stay all-or-nothing — merge, delete and CREATE UNIQUE INDEX share one transaction Rejected: keeping the string match with a test pinning the message | the typed code is available and the text is the driver's to change Rejected: an explicit sql.Conn for the TEMP table | tx already pins one connection, and rollback already cleans up Rejected: logging every duplicate group | unbounded output at startup is its own operational problem, so it caps at 20 with a count of the rest Directive: nothing in this file may decide control flow from an error message; use the typed code Directive: every TEMP table reference goes through tx, never rw — the pool will hand you a different connection Confidence: high Scope-risk: narrow Not-tested: the collapse at 11M rows, or against a database an ingestor is actively writing to — @efiten has offered a staging instance taking live MQTT traffic, which is the remaining gap before this leaves draft
This commit is contained in:
8 files changed
+216
-40
No files matched your search
@@ -11,6 +11,9 @@ theme.json
|
|||||||
firmware/
|
firmware/
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
|
# internal/prunequeue writes these next to the database; the server tests leave
|
||||||
|
# them in cmd/server/. Runtime queue state, never repo content.
|
||||||
|
prune-requests/
|
||||||
public-instrumented/
|
public-instrumented/
|
||||||
.nyc_output/
|
.nyc_output/
|
||||||
.setup-state
|
.setup-state
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "085fc40c889b4543",
|
|
||||||
"requestedAt": "2026-09-09T20:39:40.398005Z",
|
|
||||||
"reason": "geo-prune",
|
|
||||||
"pubkeys": [
|
|
||||||
"aaaa111122223333"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "b74000055aa4c213",
|
|
||||||
"requestedAt": "2026-09-09T20:46:44.481208Z",
|
|
||||||
"reason": "geo-prune",
|
|
||||||
"pubkeys": [
|
|
||||||
"aaaa111122223333"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "cc924dfc7c5711ae",
|
|
||||||
"requestedAt": "2026-09-09T20:46:43.491617Z",
|
|
||||||
"completedAt": "2026-09-09T20:46:44.491617Z",
|
|
||||||
"deleted": 1
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "df9a2e4ef5563fef",
|
|
||||||
"requestedAt": "2026-09-09T20:39:39.409166Z",
|
|
||||||
"completedAt": "2026-09-09T20:39:40.409167Z",
|
|
||||||
"deleted": 1
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,32 @@ corescope's own hot-path SQL under both drivers (Apple M4, `-count=5`, medians):
|
|||||||
Allocation counts drop with it: 1.12M vs 1.64M allocs (−32%) and 21 MB vs 30 MB
|
Allocation counts drop with it: 1.12M vs 1.64M allocs (−32%) and 21 MB vs 30 MB
|
||||||
(−30%) on the chunk load, 20.3k vs 27.3k on the lookups.
|
(−30%) on the chunk load, 20.3k vs 27.3k on the lookups.
|
||||||
|
|
||||||
|
## Confirmed on production
|
||||||
|
|
||||||
|
The table above comes from a standalone harness. @efiten then ran both drivers
|
||||||
|
against a real instance — 11,077,038 observations, 9.7 GB database, 4-core arm64
|
||||||
|
— as server-only containers against the same live volume, one at a time. Round 2
|
||||||
|
reverses the order so the page-cache advantage goes to 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 —
|
||||||
|
i.e. nothing.
|
||||||
|
|
||||||
|
**Real-world gains are smaller than the harness suggests: ~1.4-1.8× on the
|
||||||
|
paths that matter, not 2-2.3×.** The shape holds — scans and joins gain, small
|
||||||
|
lookups do not — but quote these numbers, not the harness ones.
|
||||||
|
|
||||||
|
**Build time is the counterweight**, cold and native on that machine:
|
||||||
|
**52s on master, 163s on this branch**. An instance that builds its own image
|
||||||
|
pays that on every deploy.
|
||||||
|
|
||||||
## The cost: the build is cgo now
|
## The cost: the build is cgo now
|
||||||
|
|
||||||
`CGO_ENABLED=0` still *builds*, which is the trap: mattn links a stub, and the
|
`CGO_ENABLED=0` still *builds*, which is the trap: mattn links a stub, and the
|
||||||
@@ -102,6 +128,17 @@ Merge, delete and index creation all share one transaction. Split apart, a
|
|||||||
writer inserting a duplicate in the gap makes the index creation fail while the
|
writer inserting a duplicate in the gap makes the index creation fail while the
|
||||||
deletions stay committed — rows destroyed and no index to show for it.
|
deletions stay committed — rows destroyed and no index to show for it.
|
||||||
|
|
||||||
|
The repair logs what it is about to destroy — group count, rows to remove, and
|
||||||
|
the first 20 group keys with the id it keeps — before deleting anything, because
|
||||||
|
a bare row count is not enough to reconstruct from if the merge direction is
|
||||||
|
ever wrong again. It also says up front that it holds the write lock, since on a
|
||||||
|
large table that pause at ingestor startup is otherwise unexplained.
|
||||||
|
|
||||||
|
The cost is measured at 4.1s on 2.4M synthetic rows holding 5 duplicates. That
|
||||||
|
is well short of a real deployment: an 11M-row instance has not been measured,
|
||||||
|
and the collapse has not been run against a database an ingestor is actively
|
||||||
|
writing to.
|
||||||
|
|
||||||
Note `COALESCE(path_json, '')` makes a NULL path and an empty-string path the
|
Note `COALESCE(path_json, '')` makes a NULL path and an empty-string path the
|
||||||
same key, but `NULL` and `'[]'` different keys. See
|
same key, but `NULL` and `'[]'` different keys. See
|
||||||
`internal/dbschema/dedup_index_test.go`, where the last-wins and atomicity
|
`internal/dbschema/dedup_index_test.go`, where the last-wins and atomicity
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package dbschema
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
|
sqlite3 "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// upsertMergedColumns are the columns the ingestor's observation UPSERT writes
|
// upsertMergedColumns are the columns the ingestor's observation UPSERT writes
|
||||||
@@ -39,7 +41,8 @@ var upsertMergedColumns = []string{"snr", "rssi", "score", "raw_hex", "resolved_
|
|||||||
// transaction and holds the write lock for its duration, so the repetition is
|
// transaction and holds the write lock for its duration, so the repetition is
|
||||||
// worth removing.
|
// worth removing.
|
||||||
const dupGroupsDDL = `CREATE TEMP TABLE dedup_groups AS
|
const dupGroupsDDL = `CREATE TEMP TABLE dedup_groups AS
|
||||||
SELECT transmission_id, observer_idx, COALESCE(path_json, '') AS p, MIN(id) AS keep
|
SELECT transmission_id, observer_idx, COALESCE(path_json, '') AS p,
|
||||||
|
MIN(id) AS keep, COUNT(*) AS n
|
||||||
FROM observations
|
FROM observations
|
||||||
GROUP BY transmission_id, observer_idx, COALESCE(path_json, '')
|
GROUP BY transmission_id, observer_idx, COALESCE(path_json, '')
|
||||||
HAVING COUNT(*) > 1`
|
HAVING COUNT(*) > 1`
|
||||||
@@ -82,18 +85,43 @@ func ensureObservationsDedupIndex(rw *sql.DB, logf Logger) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
if !isConstraintViolation(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
removed, err := collapseDuplicatesAndIndex(rw)
|
// 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 — this holds the write lock until it completes, and on a large " +
|
||||||
|
"observations table it can take tens of seconds.")
|
||||||
|
|
||||||
|
removed, err := collapseDuplicatesAndIndex(rw, logf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("collapse duplicate observations: %w", err)
|
return fmt.Errorf("collapse duplicate observations: %w", err)
|
||||||
}
|
}
|
||||||
logf("[dbschema] collapsed %d duplicate observation row(s) so idx_observations_dedup could be created", removed)
|
logf("[dbschema] collapsed %d duplicate observation row(s); idx_observations_dedup created", removed)
|
||||||
return nil
|
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
|
// collapseDuplicatesAndIndex merges duplicate observation rows into the lowest
|
||||||
// id of each group, deletes the rest, and creates the unique index — all in one
|
// id of each group, deletes the rest, and creates the unique index — all in one
|
||||||
// transaction. It returns the number of rows deleted.
|
// transaction. It returns the number of rows deleted.
|
||||||
@@ -102,29 +130,41 @@ func ensureObservationsDedupIndex(rw *sql.DB, logf Logger) error {
|
|||||||
// them separated, a writer inserting a duplicate in the gap makes the index
|
// them separated, a writer inserting a duplicate in the gap makes the index
|
||||||
// creation fail while leaving the deletions committed: rows destroyed and no
|
// creation fail while leaving the deletions committed: rows destroyed and no
|
||||||
// index to show for it. One transaction makes the repair all-or-nothing.
|
// index to show for it. One transaction makes the repair all-or-nothing.
|
||||||
func collapseDuplicatesAndIndex(rw *sql.DB) (int64, error) {
|
func collapseDuplicatesAndIndex(rw *sql.DB, logf Logger) (int64, error) {
|
||||||
tx, err := rw.Begin()
|
tx, err := rw.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
// A TEMP table lives for the life of the connection, and database/sql hands
|
// Every reference to the TEMP table goes through tx, so its whole lifecycle
|
||||||
// out pooled connections, so an earlier call may have left one behind on
|
// sits on one connection: created here, dropped before COMMIT below, and
|
||||||
// this one. Drop before creating rather than assume, and again afterwards
|
// removed by ROLLBACK on any error path (a TEMP table created inside a
|
||||||
// so the connection returns to the pool clean.
|
// 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 {
|
if _, err := tx.Exec(`DROP TABLE IF EXISTS temp.dedup_groups`); err != nil {
|
||||||
return 0, fmt.Errorf("drop stale dedup_groups: %w", err)
|
return 0, fmt.Errorf("drop stale dedup_groups: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(dupGroupsDDL); err != nil {
|
if _, err := tx.Exec(dupGroupsDDL); err != nil {
|
||||||
return 0, fmt.Errorf("build dedup_groups: %w", err)
|
return 0, fmt.Errorf("build dedup_groups: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _, _ = rw.Exec(`DROP TABLE IF EXISTS temp.dedup_groups`) }()
|
|
||||||
if _, err := tx.Exec(`CREATE INDEX temp.dedup_groups_key
|
if _, err := tx.Exec(`CREATE INDEX temp.dedup_groups_key
|
||||||
ON dedup_groups(transmission_id, observer_idx, p)`); err != nil {
|
ON dedup_groups(transmission_id, observer_idx, p)`); err != nil {
|
||||||
return 0, fmt.Errorf("index dedup_groups: %w", err)
|
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
|
// Only merge columns this database actually has. Apply runs this step
|
||||||
// before ensureResolvedPathColumn and ensureObservationsRawHexColumn, so on
|
// before ensureResolvedPathColumn and ensureObservationsRawHexColumn, so on
|
||||||
// a database old enough to be missing the dedup index, resolved_path and
|
// a database old enough to be missing the dedup index, resolved_path and
|
||||||
@@ -168,6 +208,10 @@ func collapseDuplicatesAndIndex(rw *sql.DB) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Same transaction: if this fails, the deletions above roll back with it.
|
// 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 {
|
if _, err := tx.Exec(dedupIndexDDL); err != nil {
|
||||||
return 0, fmt.Errorf("create idx_observations_dedup after collapsing %d row(s): %w", removed, err)
|
return 0, fmt.Errorf("create idx_observations_dedup after collapsing %d row(s): %w", removed, err)
|
||||||
}
|
}
|
||||||
@@ -192,3 +236,46 @@ func existingColumns(rw *sql.DB, table string, want []string) ([]string, error)
|
|||||||
}
|
}
|
||||||
return out, nil
|
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)
|
||||||
|
}
|
||||||
|
logf("[dbschema] transmission_id=%d observer_idx=%v path_json=%q rows=%d keeping id=%d",
|
||||||
|
txID, observerIdx.Int64, 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
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package dbschema
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
_ "github.com/mattn/go-sqlite3"
|
_ "github.com/mattn/go-sqlite3"
|
||||||
@@ -173,7 +175,7 @@ func TestCollapseDuplicatesAndIndexIsAtomic(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := collapseDuplicatesAndIndex(db); err == nil {
|
if _, err := collapseDuplicatesAndIndex(db, t.Logf); err == nil {
|
||||||
t.Fatal("expected index creation to fail")
|
t.Fatal("expected index creation to fail")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,3 +227,78 @@ func TestEnsureObservationsDedupIndexSkipsV2Schema(t *testing.T) {
|
|||||||
t.Error("index must not be created on a v2 schema")
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user