diff --git a/cmd/server/issue1809_bg_load_race_test.go b/cmd/server/issue1809_bg_load_race_test.go index 674cce03..7a729c0e 100644 --- a/cmd/server/issue1809_bg_load_race_test.go +++ b/cmd/server/issue1809_bg_load_race_test.go @@ -10,8 +10,19 @@ package main // The fix extracts a `RunStartupLoad` helper that runs LoadChunked first // and only then spawns the background loader. This test calls the helper // directly and asserts the post-load state. +// +// PR #1811 round-1 fixture rewrite (B1 — tautology trap): the original +// fixture put all 100 rows inside the 1h hot window, so LoadChunked alone +// produced coverage=1.0 and the test passed even if loadBackgroundChunks +// was a no-op. We now spread rows across 14 days with hotStartupHours=24, +// so a no-op bg loader leaves a deliberately incomplete store and the +// assertions fail. The original red-commit assertions +// (oldestLoaded != "", !backgroundLoadFailed, backgroundLoadDone) are +// kept intact; we add the coverage assertions on top. import ( + "database/sql" + "fmt" "path/filepath" "testing" "time" @@ -27,11 +38,14 @@ func Test1809_StartupLoad_BgLoaderSeesOldestLoaded(t *testing.T) { dbPath := filepath.Join(dir, "test.db") nowSec := time.Now().UTC().Unix() - // 100 rows, all within the 1h hot window so LoadChunked picks them up - // and bg loader has only ancient (empty) territory to walk back to. - createTestDBWithLastSeen(t, dbPath, 100, 1, nowSec, - 30*time.Minute, // first_seen - 30*time.Minute) // last_seen + // Seed 100 rows spread over 14 days with last_seen also spread, so + // hotStartupHours=24 picks up only ~24/(14*24) ≈ 7 rows in the hot + // window. The remaining ~93 rows MUST be loaded by the background + // loader; if it is a no-op the post-load len(packets) and + // oldestLoaded will betray the regression. + const totalRows = 100 + const spanDays = 14 + createTestDBSpreadOverDays(t, dbPath, totalRows, spanDays, nowSec) db, err := OpenDB(dbPath) if err != nil { @@ -40,14 +54,15 @@ func Test1809_StartupLoad_BgLoaderSeesOldestLoaded(t *testing.T) { defer db.conn.Close() store := NewPacketStore(db, &PacketStoreConfig{ - RetentionHours: 168, - HotStartupHours: 1, + RetentionHours: float64(spanDays * 24), // cover the full seed span + HotStartupHours: 24, // hot window = 1 day }) if err := store.RunStartupLoad(500); err != nil { t.Fatalf("RunStartupLoad: %v", err) } + // --- original red-commit assertions: KEEP INTACT --- if store.oldestLoaded == "" { t.Fatalf("oldestLoaded is empty after RunStartupLoad; bg loader would bail") } @@ -60,4 +75,95 @@ func Test1809_StartupLoad_BgLoaderSeesOldestLoaded(t *testing.T) { if !store.backgroundLoadDone.Load() { t.Fatalf("backgroundLoadDone=false after RunStartupLoad; expected true on success") } + + // --- B1 anti-tautology assertions: bg loader actually did work --- + + // Bound the rows the hot window alone could have loaded. The seeder + // places rows evenly across spanDays so the hot window (24h) catches + // at most ~totalRows/spanDays + a small fudge for boundary edge. + hotOnlyMax := (totalRows/spanDays)*2 + 5 + if len(store.packets) <= hotOnlyMax { + t.Fatalf("len(packets)=%d after RunStartupLoad — bg loader appears to be a no-op "+ + "(hot window alone caps at ~%d rows for %d rows spread over %d days). "+ + "Coverage = backgroundLoadDone may have flipped without bg work.", + len(store.packets), hotOnlyMax, totalRows, spanDays) + } + + // oldestLoaded must be older than the hot cutoff after the bg loader + // has retreated through the retention window. Pre-fix it would equal + // the hot cutoff (or empty), proving bg loader never advanced. + oldest, err := time.Parse(time.RFC3339, store.oldestLoaded) + if err != nil { + t.Fatalf("oldestLoaded=%q is not RFC3339: %v", store.oldestLoaded, err) + } + hotCutoff := time.Unix(nowSec, 0).UTC().Add(-24 * time.Hour) + // Allow a small margin since the bg loader chunks daily; oldest + // should be at least one full day before the hot cutoff. + if !oldest.Before(hotCutoff.Add(-12 * time.Hour)) { + t.Fatalf("oldestLoaded=%s is not materially older than hot cutoff %s — "+ + "bg loader did not advance through the retention window", + oldest.Format(time.RFC3339), hotCutoff.Format(time.RFC3339)) + } +} + +// createTestDBSpreadOverDays seeds a DB with rows whose first_seen + +// last_seen are evenly spread across `spanDays` ending at `nowSec`. +// Reuses the same schema shape as createTestDBWithLastSeen. +func createTestDBSpreadOverDays(t *testing.T, dbPath string, numTx, spanDays int, nowSec int64) { + t.Helper() + conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL") + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + execOrFail := func(s string) { + if _, err := conn.Exec(s); err != nil { + t.Fatalf("test DB exec: %v\nSQL: %s", err, s) + } + } + execOrFail(`CREATE TABLE transmissions ( + id INTEGER PRIMARY KEY, + raw_hex TEXT, hash TEXT, first_seen TEXT, + route_type INTEGER, payload_type INTEGER, + payload_version INTEGER, decoded_json TEXT, + last_seen INTEGER NOT NULL DEFAULT 0 + )`) + execOrFail(`CREATE TABLE observations ( + id INTEGER PRIMARY KEY, transmission_id INTEGER, observer_id TEXT, observer_name TEXT, + direction TEXT, snr REAL, rssi REAL, score INTEGER, + path_json TEXT, timestamp TEXT, raw_hex TEXT + )`) + execOrFail(`CREATE TABLE observers (rowid INTEGER PRIMARY KEY, id TEXT, name TEXT, iata TEXT)`) + execOrFail(`CREATE TABLE nodes (pubkey TEXT PRIMARY KEY, name TEXT, role TEXT, lat REAL, lon REAL, last_seen TEXT, first_seen TEXT, frequency REAL)`) + execOrFail(`CREATE TABLE schema_version (version INTEGER)`) + execOrFail(`INSERT INTO schema_version (version) VALUES (1)`) + execOrFail(`CREATE INDEX idx_tx_first_seen ON transmissions(first_seen)`) + execOrFail(`CREATE INDEX idx_tx_last_seen ON transmissions(last_seen)`) + + txStmt, err := conn.Prepare("INSERT INTO transmissions (id, raw_hex, hash, first_seen, route_type, payload_type, payload_version, decoded_json, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") + if err != nil { + t.Fatalf("prepare tx: %v", err) + } + defer txStmt.Close() + obsStmt, err := conn.Prepare("INSERT INTO observations (id, transmission_id, observer_id, observer_name, direction, snr, rssi, score, path_json, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + if err != nil { + t.Fatalf("prepare obs: %v", err) + } + defer obsStmt.Close() + + // Evenly distribute first_seen/last_seen across the span (ending now). + spanSeconds := int64(spanDays) * 86400 + for i := 1; i <= numTx; i++ { + ago := spanSeconds * int64(numTx-i) / int64(numTx) // newest at i==numTx → 0s ago + seenUnix := nowSec - ago + seenStr := time.Unix(seenUnix, 0).UTC().Format(time.RFC3339) + hash := fmt.Sprintf("h%06d", i) + if _, err := txStmt.Exec(i, "aabb", hash, seenStr, 0, 4, 1, "{}", seenUnix); err != nil { + t.Fatalf("insert tx %d: %v", i, err) + } + if _, err := obsStmt.Exec(i, i, "obs1", "Obs1", "RX", -10.0, -80.0, 5, "[]", seenStr); err != nil { + t.Fatalf("insert obs %d: %v", i, err) + } + } }