mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-09-16 07:02:36 +00:00
Part 2 of #1856. **Part 1 is deliberately not fixed here** and the issue stays open for it; reasoning at the end. ## The bug `migrateContentHashesAsync` set `store.hashMigrationComplete` in a deferred func that ran unconditionally. Every DB failure inside the loop takes a `continue` (begin tx, prepare, commit), so the loop always reaches that defer, **including when not a single batch was written**. That is not hypothetical. The server has held a `mode=ro` handle since #1283, so `Begin`, `Prepare` and `Commit` all fail, every batch is skipped, and `/api/stats` then answers `hashMigrationComplete: true` after migrating nothing. The migration is started unconditionally on every boot at `main.go:546`. ## The fix The three failure paths now count, and the defer only claims completion when the count is zero. When it is not, it logs once, naming the read-only handle as the expected cause and pointing at this issue, so an operator can tell "no work to do" apart from "could not do the work". **Nothing waits on the flag.** The only reader is `routes.go:828`, which reports it in `/api/stats`. Leaving it false on failure blocks nothing; it just stops the endpoint from lying. The in-memory index is untouched on failure. That was already true, because the index update runs only after a successful commit, and the test now asserts it so memory and disk cannot drift apart. ## Verification The regression test **fails on unmodified master**: ``` hash_migrate_test.go:115: hashMigrationComplete must stay false when no batch could be written; reporting true here is what #1856 called self-reported success ``` It closes the DB handle to make writes fail. That is deterministic and exercises the identical path as a read-only handle (`Begin` errors, batch skipped); the in-memory test DB cannot be reopened read-only. The existing happy-path test still passes, so the flag still turns true on a real migration. `gofmt` clean, `go vet` clean, `cmd/server` suite ok in 59.7s. ## Why part 1 is not in here `handlePostPacket` writes to the same read-only handle and therefore always answers 500. I checked the error path before assuming it was misleading: it already returns `"transmission insert: attempt to write a readonly database"`, so the message is accurate. The endpoint is not confusing, it is simply dead. The issue asks maintainers directly: *"is this endpoint still wanted? If ingestion is MQTT-only now, deleting it is simpler than routing it through a handoff."* That is a product decision, not a fix, and inventing a middle answer would only add code without settling it. Worth noting the repository already has a precedent for the handoff shape: the server writes `request-<id>.json` and the ingestor consumes it (`cmd/ingestor/prune_geofilter.go`). Two things a decision should account for: the endpoint is documented in `openapi.go:69` and guarded by `requireAPIKey`, and `routes_test.go:4850` asserts it writes an observation row using the v3 schema, which passes only because the test DB is read-write. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
137 lines
4.1 KiB
Go
137 lines
4.1 KiB
Go
package main
|
|
|
|
import (
|
|
"log"
|
|
"time"
|
|
)
|
|
|
|
// migrateContentHashesAsync recomputes content hashes in batches after the
|
|
// server is already serving HTTP. Packets whose hash changes are updated in
|
|
// both the DB and the in-memory byHash index. The migration is idempotent:
|
|
// once all hashes match the current formula it completes instantly.
|
|
func migrateContentHashesAsync(store *PacketStore, batchSize int, yieldDuration time.Duration) {
|
|
// #1856: every DB failure below continues to the next batch, so the loop
|
|
// always reaches the deferred completion. Setting the flag unconditionally
|
|
// therefore reported success after migrating nothing, which is exactly what
|
|
// happens on the read-only handle the server holds since #1283: begin,
|
|
// prepare and commit all fail, every batch is skipped, and /api/stats then
|
|
// answers hashMigrationComplete: true.
|
|
failedBatches := 0
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("[hash-migrate] panic recovered: %v", r)
|
|
failedBatches++
|
|
}
|
|
if failedBatches > 0 {
|
|
log.Printf("[hash-migrate] INCOMPLETE: %d batch(es) could not be written; "+
|
|
"hashMigrationComplete stays false. On a read-only DB handle this is expected "+
|
|
"and the migration belongs in the ingestor (#1856).", failedBatches)
|
|
return
|
|
}
|
|
store.hashMigrationComplete.Store(true)
|
|
}()
|
|
|
|
// Snapshot the packet slice length under lock (packets only grow).
|
|
store.mu.RLock()
|
|
total := len(store.packets)
|
|
store.mu.RUnlock()
|
|
|
|
migrated := 0
|
|
for offset := 0; offset < total; offset += batchSize {
|
|
end := offset + batchSize
|
|
if end > total {
|
|
end = total
|
|
}
|
|
|
|
// Collect stale hashes in this batch under RLock.
|
|
type hashUpdate struct {
|
|
tx *StoreTx
|
|
oldHash string
|
|
newHash string
|
|
}
|
|
var updates []hashUpdate
|
|
|
|
store.mu.RLock()
|
|
for _, tx := range store.packets[offset:end] {
|
|
if tx.RawHex == "" {
|
|
continue
|
|
}
|
|
newHash := ComputeContentHash(tx.RawHex)
|
|
if newHash != tx.Hash {
|
|
updates = append(updates, hashUpdate{tx: tx, oldHash: tx.Hash, newHash: newHash})
|
|
}
|
|
}
|
|
store.mu.RUnlock()
|
|
|
|
if len(updates) == 0 {
|
|
continue
|
|
}
|
|
|
|
// Write batch to DB in a single transaction.
|
|
dbTx, err := store.db.conn.Begin()
|
|
if err != nil {
|
|
log.Printf("[hash-migrate] begin tx: %v", err)
|
|
failedBatches++
|
|
continue
|
|
}
|
|
stmt, err := dbTx.Prepare("UPDATE transmissions SET hash = ? WHERE id = ?")
|
|
if err != nil {
|
|
log.Printf("[hash-migrate] prepare: %v", err)
|
|
dbTx.Rollback()
|
|
failedBatches++
|
|
continue
|
|
}
|
|
|
|
for _, u := range updates {
|
|
if _, err := stmt.Exec(u.newHash, u.tx.ID); err != nil {
|
|
// UNIQUE constraint = two old hashes map to the same new hash (duplicate).
|
|
// Merge observations to the surviving tx, delete the duplicate.
|
|
log.Printf("[hash-migrate] tx %d collides — merging duplicate", u.tx.ID)
|
|
var survID int
|
|
if err2 := dbTx.QueryRow("SELECT id FROM transmissions WHERE hash = ?", u.newHash).Scan(&survID); err2 == nil {
|
|
dbTx.Exec("UPDATE observations SET transmission_id = ? WHERE transmission_id = ?", survID, u.tx.ID)
|
|
dbTx.Exec("DELETE FROM transmissions WHERE id = ?", u.tx.ID)
|
|
u.newHash = "" // mark for in-memory removal only
|
|
}
|
|
}
|
|
}
|
|
stmt.Close()
|
|
|
|
if err := dbTx.Commit(); err != nil {
|
|
log.Printf("[hash-migrate] commit: %v", err)
|
|
failedBatches++
|
|
continue
|
|
}
|
|
|
|
// Update in-memory index under write lock.
|
|
store.mu.Lock()
|
|
for _, u := range updates {
|
|
delete(store.byHash, u.oldHash)
|
|
if u.newHash == "" {
|
|
// Merged duplicate — remove from packets slice and indexes.
|
|
delete(store.byTxID, u.tx.ID)
|
|
// Move observations to survivor if present.
|
|
if surv := store.byHash[ComputeContentHash(u.tx.RawHex)]; surv != nil {
|
|
for _, obs := range u.tx.Observations {
|
|
surv.Observations = append(surv.Observations, obs)
|
|
surv.ObservationCount++
|
|
}
|
|
}
|
|
} else {
|
|
u.tx.Hash = u.newHash
|
|
store.byHash[u.newHash] = u.tx
|
|
}
|
|
}
|
|
store.mu.Unlock()
|
|
|
|
migrated += len(updates)
|
|
|
|
// Yield to let HTTP handlers run.
|
|
time.Sleep(yieldDuration)
|
|
}
|
|
|
|
if migrated > 0 {
|
|
log.Printf("[hash-migrate] Migrated %d content hashes to new formula", migrated)
|
|
}
|
|
}
|