Commit Graph
8 Commits
Author SHA1 Message Date
e465e1c6c6 perf(#1740): replace idx_tx_last_seen with partial index WHERE last_seen=0 (#1756)
Fixes #1740.

## What

Replace the full `idx_tx_last_seen` with a partial index `WHERE
last_seen=0`. Two ordered, sequential migrations in
`internal/dbschema/dbschema.go::ensureTransmissionsLastSeenColumn`:

- **(a)** `CREATE INDEX IF NOT EXISTS idx_tx_last_seen_zero ON
transmissions(id) WHERE last_seen=0`
- **(b)** `DROP INDEX IF EXISTS idx_tx_last_seen` — gated after (a)
succeeds (sequential `Exec`; (b) never runs if (a) errors)

## Why

The only consumer is `chunkedTxLastSeenBackfill`'s `WHERE last_seen=0`
scan + `MAX(id)` lookup. The full index covers ALL rows including the
long tail where `last_seen != 0` after backfill converges (71K+ rows in
prod per carmack's #1740 note). The partial index degenerates to ~the
count of un-backfilled rows (0 in steady state, bounded by ingest rate
during ops) and stops competing for page cache.

## Migration cost

Both migrations are sync and annotated `PREFLIGHT: async=false`:
- (a) `CREATE INDEX` on partial subset `WHERE last_seen=0` is bounded by
un-backfilled rows — a small superset of the inflight ingest window, not
a full table scan.
- (b) `DROP INDEX` is a metadata-only schema rewrite in SQLite — no row
scan at any size.

`dbschema/` has no access to `Store.RunAsyncMigration` (that helper
lives in `cmd/ingestor/` and is the wrong layer for the schema
source-of-truth per #1321), so sync is the only path here regardless.

## TDD

- **RED** `a529f0f4`: `EXPLAIN QUERY PLAN` test asserting the backfill
`MAX(id) WHERE last_seen=0` query uses `idx_tx_last_seen_zero` + a
second test asserting the legacy `idx_tx_last_seen` is dropped
post-Apply. Both failed on assertion (planner picked the full index;
partial index didn't exist).
- **GREEN** `208fde8a`: add migrations (a) and (b). Both tests pass.

## Files touched

- `internal/dbschema/dbschema.go` — swap the index
- `internal/dbschema/dbschema_test.go` — `EXPLAIN QUERY PLAN` pin + DROP
assertion
- `cmd/ingestor/db.go` — comment refresh only (idx_tx_last_seen →
idx_tx_last_seen_zero)

## Acceptance

-  New partial index created
-  Old full index dropped via gated migration (sequential, order
preserved)
-  Query plan test asserts partial-index usage
-  Existing migration tests still green (`internal/dbschema`,
`cmd/ingestor` TestIssue1690 + applySchema)

---------

Co-authored-by: clawbot <bot@openclaw>
2026-06-20 15:45:28 -07:00
048143f54f fix(#1690): cold-load uses last_seen (effective recency) instead of first_seen (#1691)
## #1690 — cold-load uses wrong time axis (RED → GREEN)

The on-disk DB has thousands of long-lived hashes with recent traffic.
Prod's
cold-load filter (`transmissions.first_seen >= cutoff`) is bound to a
column
that is set once at insert time and never updated — so re-observation of
an
old hash does not move it into the hot window. Result: prod cold-loaded
~0.3%
of the on-disk rows and flipped `backgroundLoadComplete=true` without
ever
walking the retention window (the `retentionHours - hotStartupHours <=
0`
short-circuit at line 1353 of `cmd/server/store.go`).

### Three sub-fixes

**A) Denormalize `transmissions.last_seen`** so cold-load can window on
effective recency.

- `internal/dbschema/dbschema.go::ensureTransmissionsLastSeenColumn`
adds the
  column + `idx_tx_last_seen` (single-column INTEGER ALTER + index; both
  PREFLIGHT-annotated as cheap metadata-only ops).
- `cmd/ingestor/db.go::OpenStoreWithInterval` schedules
  `tx_last_seen_backfill_v1` via `Store.RunAsyncMigration` —
`UPDATE transmissions SET last_seen = MAX(observations.timestamp) WHERE
  last_seen = 0` — non-blocking on boot (1.9M+ obs row scan in prod).
- Writer-side: `InsertTransmission` seeds `last_seen` on initial insert,
and every observation insert bumps `last_seen = ?` via prepared
statement
`stmtBumpTxLastSeen` (conditional `last_seen < ?` so out-of-order ingest
  never goes backwards).
- Reader-side: `cmd/server/store.go::Load`, `loadChunk`, and
  `cmd/server/chunked_load.go::LoadChunked` switch the WHERE/ORDER-BY
clauses to `t.last_seen` when the column is present (PRAGMA-detected via
  `DB.hasLastSeen`). Test/legacy DBs without the column fall back to
  `first_seen` so existing fixtures stay green.

**B) Honest `backgroundLoadComplete` gating.**

- Drop the `retentionHours - hotStartupHours <= 0` short-circuit. Prod
runs
  with both at 12h, which flipped Done=true immediately.
- After the chunk loop, query
`SELECT COUNT(*) FROM transmissions WHERE last_seen >= retentionFloor`
and
  compute `loadCoverageRatio = inMem / inDB`. Done=true only when
  `ratio >= 0.90` AND no chunk errors. `backgroundLoadFailed=true` +
  `backgroundLoadError` populated otherwise (e.g. `"loaded 20.0% of 5000
  rows (1000 in memory)"`).
- `bgErrMu`-guarded `loadCoverageRatio` + `backgroundLoadErr` so the
perf
  endpoint can read them without blocking the writer.

**C) Perf exposure.**

`PerfPacketStoreStats` gains `RetentionHours`, `OldestLoaded`,
`LoadCoverageRatio`, `BackgroundLoadError` — surfaces what fraction of
the
on-disk DB the in-memory store currently reflects, so operators can see
the
0.3% case in `/api/perf` without reading the logs.

### TDD trail

- **RED**: `05f0c6dd2bea6dc37324c548a49564d739aca920` — failing tests +
21-line
store.go scaffolding. CI on this commit failed on assertions (intended).
- **GREEN**: this PR's HEAD commit (8 files, +271/-24). Targeted suite:
  `Test1690_ColdLoad_TimeAxis`, `Test1690_BackgroundLoadHonesty`,
  `Test1690_PerfStats_NewFields`, `TestHotStartup_*`,
  `TestIssue1690_LastSeenUpdatedOnObservation` — all pass.

Anti-tautology: locally reverted the `if !s.backgroundLoadFailed.Load()`
guard around `backgroundLoadDone.Store(true)` —
`Test1690_BackgroundLoadHonesty`
fails on the assertion `"backgroundLoadDone=true with only 1000/5000
packets
loaded; must be false until coverage ≥ 90%"`. Restored.

### Async-migration preflight

- `ensureTransmissionsLastSeenColumn` — ALTER + CREATE INDEX both
  `// PREFLIGHT: async=true reason="..."` annotated.
- `tx_last_seen_backfill_v1` — wrapped in `Store.RunAsyncMigration`.
- `stmtBumpTxLastSeen` prepared statement — annotated; it is a row-level
  UPDATE BY PRIMARY KEY, not a migration.

### Preflight overrides

PREFLIGHT-MIGRATION-SCALE: <30s N=5K
- check-async-migration: justified for
`cmd/server/issue1690_cold_load_test.go`
CREATE TABLE/INDEX statements — these build an in-memory test fixture DB
  (≤5000 rows, runs in <1s in CI), not a prod migration.

Fixes #1690.

---------

Co-authored-by: meshcore-bot <bot@meshcore.local>
Co-authored-by: bot <bot@example.com>
2026-06-12 12:47:53 -07:00
a4776557ae feat(#1290): use firmware repeat:on|off hint to exclude listener-only observers from disambiguator (#1624)
Closes #1290.

cross-stack: justified — backend persists firmware-side `repeat` hint to
a new observers column, frontend surfaces the listener/repeater status
as a badge on the observers list and node-detail Heard By table per the
issue's UI acceptance criterion.

## What

Firmware 1.16 publishes a `repeat: on|off` flag in the MQTT `/status`
JSON (confirmed by @cwichura on the issue thread — see
[`MQTTMessageBuilder.cpp:58`](https://github.com/agessaman/MeshCore/blob/b45373a31f111fb0de98bb3b168226d09ceadc47/src/helpers/MQTTMessageBuilder.cpp#L58)
in `agessaman/MeshCore mqtt-bridge-implementation-flex`). Listener-only
observers (`repeat:off`) by firmware contract never relay packets, so
they cannot legitimately be a hop in someone else's resolved path. This
PR plumbs the hint end-to-end so the disambiguator stops considering
them.

## How

* **`internal/dbschema`**: idempotent `can_relay INTEGER DEFAULT 1`
migration on `observers`, plus `AssertReady` probe (server fatal-logs if
absent). Mirrored in `cmd/ingestor/db.go` `CREATE TABLE` for fresh DBs.
Annotated `PREFLIGHT: async=true` — `DEFAULT 1` is constant so SQLite
does this as a metadata-only schema rewrite.
* **`cmd/ingestor`**: `extractObserverMeta` accepts `repeat` as bool,
case-insensitive string (`on|off|true|false|yes|no`), or numeric `0|1`.
Missing field → `nil` → `COALESCE` preserves the existing column value
(back-compat with legacy observers). Plumbed through `UpsertObserverAt`
and the prepared upsert statement.
* **`cmd/server`**: `GetNonRelayObserverPubkeys` + new
`prefixMap.markNonRelay` drop matching candidates inside
`pm.resolveWithContext` at the top of the resolver, so all 4 tiers see
the pruned candidate set. `ObserverResp.CanRelay` is surfaced on
`/api/observers` and `/api/observers/{id}`. `GetNodeHealth` enriches
per-observer rows with `can_relay` so the node-detail badge renders.
Probe-and-fall-back when the `can_relay` column is absent (legacy test
fixtures).
* **`public/`**: listener vs repeater pill on observers list, observer
detail `Relay` stat card, and node-detail `Heard By` table. CSS uses
existing theme vars.

## Test

Added `TestResolveWithContext_ExcludesNonRelayObservers_Issue1290` in
`cmd/server/resolve_non_relay_1290_test.go` covering all three required
cases:
* `repeat:off` pubkey → not a candidate (assertion failed in red commit
`5f7fdb96`, passes after green `f12911dc`)
* `repeat:on` pubkey → still a candidate (regression guard)
* legacy obs (no field) → still a candidate (back-compat)

Red→green proof:
```
$ git log --oneline origin/master..HEAD
f12911dc feat(#1290): exclude listener-only observers from path-hop disambiguator
5f7fdb96 test(#1290): red — assert listener-only observers excluded from path-hop candidates
```

Full server + ingestor + dbschema + migrate test suites pass locally.

## Acceptance checklist (from #1290)

* [x] Ingestor parses `repeat` field (boolean OR string `on|off`)
* [x] Field persisted on `observers` table (new `can_relay BOOLEAN`
column, idempotent migration via `internal/dbschema`)
* [x] Server's disambiguator (`pm.resolveWithContext`) excludes
`can_relay=false` observer-nodes from path-hop candidate set
* [x] UI badge on observers list + node detail page indicating
"listener" vs "repeater"
* [x] Backward compat: legacy observers default to `can_relay=true`
* [x] Test: `repeat:off` → NOT a candidate
* [x] Test: `repeat:on` → IS a candidate
* [x] Test: legacy → IS a candidate

## Out of scope (preserved per issue)

Backfilling already-resolved paths is left as a follow-up. No
firmware/broker changes.

---------

Co-authored-by: Kpa-clawbot <bot@kpa-clawbot.local>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-06-08 01:27:13 -07:00
43b93c6bb9 feat(observers): surface naive-clock observers as ⚠️ chip + detail banner (#1478) (#1480)
## Summary

Issue #1478 — surface observers whose envelope timestamps are being
clamped because they're emitting zone-less local-time strings (UTC-N
observers showed up perpetually as "Stale" before #1466, and per-packet
rxTime is still clamped to ingest time for them, muddying
propagation-delay analytics).

Now the UI tells operators which observers are misconfigured + how to
fix it.

## What changed

### Ingestor (cmd/ingestor)
- New `observers_clock_naive_v1` migration adds three columns to
`observers`:
- `clock_skew_seconds INTEGER` (signed: negative = behind UTC, positive
= ahead)
  - `clock_skew_count_24h INTEGER` (rolling 24h event count)
  - `clock_last_naive_at TEXT` (RFC3339 timestamp of last clamp)
- `resolveRxTime` now returns `(rxTime, naiveSkewSec)`. The
packet-handler call site invokes `store.RecordNaiveSkew(observerID,
deltaSec)` whenever a naive envelope is clamped (the existing >15 min
naive-tolerance path). The counter resets to 1 if no event in the prior
24h, else increments. Single INSERT-or-UPDATE round trip per clamp.

### Server (cmd/server)
- `Observer` struct + `GetObservers` / `GetObserverByID` extended to
scan the three new columns.
- `ObserverResp` gains four JSON fields exposed by `/api/observers` and
`/api/observers/{id}`:
- `clock_naive` (bool, derived from `clock_last_naive_at` being within
24h)
  - `clock_skew_seconds`, `clock_skew_count_24h`, `clock_last_naive_at`
- Decay is **read-side**: a stale event yields `clock_naive=false` with
zero counts. No background sweep, no writes from the read-only server,
no race with the ingestor.

### Frontend (public)
- `window.ObserversNaiveChip.render(o)` — total render helper, returns
⚠️ chip HTML when `o.clock_naive===true`, `""` otherwise. Used inline in
the observers-list `name` cell and in the row-detail slide-over. Tooltip
explains magnitude + direction + count + fix.
- `window.ObserverDetailNaiveBanner.render(obs)` — yellow alert banner
at the top of the observer-detail page with the skew magnitude,
last-event timestamp, and the actionable fix ("Set host clock to UTC, OR
emit Z-suffixed/offset-aware timestamps from the observer script").

## TDD trail
- `5ddd5b42` red: backend `cmd/server/observer_naive_clock_1478_test.go`
(3 tests asserting JSON fields + 24h decay) + frontend
`test-observer-naive-clock-1478.js` (8 jsdom-style tests asserting
helpers exist and render correctly). Both failed on master with
field-missing / export-missing assertions.
- `4ecc79c8` green backend: schema + Observer / GetObservers /
ObserverResp / handler decay.
- `2137ab81` green frontend: chip + banner helpers and call sites.

## Tests
- `cd cmd/server && go test ./...` → all green (full suite, 46s)
- `cd cmd/ingestor && go test ./...` → all green (full suite, 98s)
- `node test-observer-naive-clock-1478.js` → 8/8 pass
- `node test-frontend-helpers.js` → unchanged from master (pre-existing
failures only)

## Acceptance (issue #1478)
-  Observer running with `python datetime.now().isoformat()` (naive,
off by N hours) → `clock_naive=true` after the next clamp → UI shows ⚠️
chip + banner.
-  Observer with `datetime.now(timezone.utc).isoformat()` (Z-suffixed)
→ never clamped → never flagged.
-  Observer that fixed its clock → `clock_naive` returns to `false` 24h
after the last clamp event (read-side decay).

Closes #1478.

---------

Co-authored-by: openclaw <bot@openclaw.local>
2026-05-29 01:08:12 -07:00
0b35c7eef3 feat(server): persist multi-byte capability across restart + O(1) per-key lookup (#903) (#1324)
## Summary

Follows the reconciliation recommendation in #916 — extracts only the
NET-NEW persistence layer from that PR (which is now superseded by #1002
for the overlay UI) into a focused 6-file change against current master.

**What this adds:**
- `multibyte_sup_v1` migration: `multibyte_sup INTEGER NOT NULL DEFAULT
0` + `multibyte_evidence TEXT` on `nodes`/`inactive_nodes` so capability
survives restart
- `hasMultibyteSupCols` schema detection gates the persist/load paths
- `loadMultibyteCapFromDB()`: pre-populates `mbCapSnapshot`/`mbCapIndex`
at startup — cold starts serve last-known capability without waiting for
the first ~15s analytics cycle
- `maybePersistMultibyteCapability()` + `persistMultibyteCapability()`:
after each analytics cycle; TryLock-gated (concurrent cycles coalesce);
skips `sup==0` entries (data-destruction guard)
- `GetMultibyteCapFor(pk)`: O(1) map lookup; both `handleNodes` and
node-detail call sites updated from the O(N)-alloc
`GetMultiByteCapMap()`

**What this explicitly does NOT change:**
- API field names (`multi_byte_status`, `multi_byte_evidence`,
`multi_byte_max_hash_size`)
- `EnrichNodeWithMultiByte` — unchanged
- `GetMultiByteCapMap` — still present for any external callers
- `public/map.js`, `public/live.css`, `Dockerfile`, `docs/` — zero
frontend churn

## Test plan

- [x] `TestMultibyteCapPersistRoundTrip` — confirmed values survive
persist → fresh-store load
- [x] `TestMultibyteCapPersistSkipsUnknown` — data-destruction guard:
`sup==0` entry does not overwrite DB-confirmed value
- [x] `TestMultibyteCapMaybePersistCoalesces` — TryLock coalesces 10
concurrent callers without deadlock
- [x] `TestMultibyteCapGetMultibyteCapForO1` — O(1) index returns
correct entry / false for unknown pubkey
- [x] `TestMultibyteCapLoadFromDB` — only `sup>0` rows loaded; `sup==0`
row excluded
- [x] `TestSchemaMultibyteSupColumns` — migration adds columns to both
tables; idempotent on second `OpenStore`
- [x] All existing `TestMultiByteCapability_*` tests pass unchanged
- [x] Full ingestor test suite: `ok` in 27s
- [x] `go build ./cmd/server/ && go build ./cmd/ingestor/` clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: openclaw-bot <bot@openclaw>
2026-05-25 22:35:35 -07:00
c7ab5f3eb9 fix(#1366): channels view shows latest message time — backend emits LatestSeen, not FirstSeen (#1368)
Red commit: 702d82eb5e (CI: see Actions
tab for fix/issue-1366)

## What
Channel view emits the max observation timestamp (`tx.LatestSeen`)
instead of the analyzer's first-observation time (`tx.FirstSeen`) as the
rendered `timestamp` field. A new `first_seen` field is exposed
alongside for debug surfaces. `sender_timestamp` continues to be
returned in the JSON response but is intentionally NOT used as the
rendered time (client clocks are unreliable).

## Root cause

Two parallel call sites both emitted the wrong field:

- `cmd/server/store.go` — `GetChannelMessages` (~line 4807): set
`entry.Data["timestamp"] = strOrNil(tx.FirstSeen)` for every new dedup
entry. `tx.FirstSeen` is the analyzer's first-ever observation time of a
`transmissions.hash` row; for heartbeat-style packets (e.g. `BlorkoBot
🤖` posting the same status line periodically), the hash is stable, so
FirstSeen stays pinned at the very first observation while the message
keeps retransmitting hours later. Operator sees "old" message timestamps
for live messages.
- `cmd/server/db.go` — `GetChannelMessages` (~line 1757): same problem
against the SQLite-backed query path. Used `nullStr(fs)` (where `fs` is
`t.first_seen`) for the `timestamp` field.

### Repro from staging
Same packet, same hash `aba4f0493249de57`, sender `BlorkoBot 🤖`:
- `/api/channels/%23test/messages` → `timestamp: "2026-05-25T15:53:20Z"`
(FirstSeen, 7h+ in the past)
- `/api/packets?hash=aba4f0493249de57` → `first_seen:
"2026-05-25T22:53:19Z"` (latest obs), `observation_count: 84`

The packets view used max-obs correctly; the channels view did not. 7h
gap matches operator screenshot.

## TDD red → green

Red: `cmd/server/channels_message_order_1366_test.go` — three tests:
- `TestChannelMessages_TimestampUsesLatestSeen`: seeds a CHAN tx with
observations 7h apart, asserts returned `timestamp` ≈ latest observation
epoch (±1s). Fails under FirstSeen with Δ=−25200s.
- `TestChannelMessages_TimestampNotSenderTimestamp`: seeds a CHAN tx
whose decoded `sender_timestamp` is year-2000 (bad RTC). Asserts the
rendered `timestamp` parses to current year — guards against the
tempting "just use sender_timestamp" alt-fix that would let bad client
clocks corrupt the view.
- `TestChannelMessages_TimestampIsUTCZ`: asserts the emitted string is
unambiguously UTC (suffix `Z` or `+00:00`) so browsers don't apply a
local-zone shift.

Green commit changes:
- `store.go`: emit `tx.LatestSeen` (with FirstSeen fallback if no obs);
add `first_seen` field.
- `db.go`: join `o.timestamp` per-observation, track max epoch per tx,
emit RFC3339 UTC at the end; add `first_seen` field.

`sender_timestamp` remains in the response — unchanged shape, frontend
never read it for the rendered time (verified: only `msg.timestamp` is
consumed in `public/channels.js:1902`).

## Manual verification (post-merge)

1. Deploy to staging.
2. Curl `/api/channels/%23test/messages?limit=5` and
`/api/packets?hash=<recent>`. The channel `timestamp` field MUST equal
the packets `first_seen` (max obs) for the same hash, NOT lag it.
3. Send a fresh GRP_TXT via a MeshCore client into a watched channel.
Within 15s, refresh the Channels view at `/channels`. The new message
MUST render at the bottom with the correct (current) time.

## Why not `sender_timestamp`?

It's a per-client field, decoded from the payload. Many MeshCore
firmware builds run without RTC/NTP/GPS and report bogus values.
Trusting it for display would propagate bad client clocks into the
analyzer UI — the analyzer is the source of truth for UTC, not the
client.

Fixes #1366

---------

Co-authored-by: CoreScope Bot <bot@corescope>
Co-authored-by: bot <bot@kpa-clawbot.dev>
Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-25 17:45:32 -07:00
d9ba9937a6 fix(dbschema): canonical source for optional column migrations — fixes startup race (closes #1321) (#1322)
Red commit `2a8102b9` (failing test) → green commit `bb957c9f`. CI:
https://github.com/Kpa-clawbot/CoreScope/actions/workflows/ci.yml?query=branch%3Afix%2Fissue-1321

Fixes #1321.

## Why

On staging `/api/scope-stats` 500'd with `scope_name column not present`
despite the ingestor adding the column ~0.5s after server startup.
`cmd/server/db.go detectSchema()` runs in `OpenDB` and caches
`hasScopeName`/`hasDefaultScope`/`hasObsRawHex` booleans. With
supervisord launching server + ingestor simultaneously, the server's
PRAGMA can fire BEFORE the ingestor's `ALTER TABLE` completes — and the
boolean stays false until the server restarts. Same race class as #1283;
#1289 moved server-side ensures to `dbschema` but the optional columns
the ingestor still owned were left out.

## Fix — option (c) from the issue

Made `internal/dbschema/dbschema.go` the single source of truth for the
optional columns the server detects.

**Migrations moved from `cmd/ingestor/db.go applySchema` into
`dbschema.Apply`:**
- `transmissions.scope_name` + `idx_tx_scope_name` partial index
- `nodes.default_scope`
- `inactive_nodes.default_scope`
- `observations.raw_hex`

**`AssertReady` now asserts** every one of those columns. The server
cannot start with stale-false booleans because `AssertReady` will fatal
first if the columns are missing. The ingestor's old gated blocks are
replaced with pointer comments so anyone hunting for them lands in
`dbschema.go`. The `_migrations` marker rows are preserved (`INSERT OR
IGNORE`) to keep legacy DBs idempotent.

**Documented invariant** in the package doc: any new optional column the
server PRAGMA-detects belongs in `internal/dbschema/dbschema.go`, NOT in
`cmd/ingestor/db.go applySchema`.

## Tests

Added `internal/dbschema/dbschema_test.go` (RED in `2a8102b9`):
- `TestApplyAddsOptionalColumns_CanonicalSource` — post-`Apply`, all
four columns must exist.
- `TestAssertReady_RequiresOptionalColumns` — `AssertReady` must refuse
a DB missing them AND pass after full `Apply`.

`cmd/ingestor` and `cmd/server` full suites green.

---------

Co-authored-by: openclaw-bot <bot@openclaw.local>
2026-05-23 08:33:21 -07:00
9383201c07 refactor(db): finish #1283 — Option 4: ingestor owns neighbor-graph + schema migrations; server is read-only (fixes #1287) (#1289)
Red commit:
https://github.com/Kpa-clawbot/CoreScope/commit/eae179b99b5fd34924547632aa8f8025c405aa53
(CI: pending — opens with this PR)

Finishes #1283. RED test `TestServerSourceHasNoCachedRWCalls` goes from
failing (13 writer call-sites) to GREEN (zero). Per #1287 Option 4
(https://github.com/Kpa-clawbot/CoreScope/issues/1287#issuecomment-4485099992):
ingestor owns the neighbor graph build + persist; server reads the
snapshot.

**Category A — Schema migrations** → new `internal/dbschema` package.
`dbschema.Apply(rw)` runs in `cmd/ingestor` startup (in `OpenStore`).
`dbschema.AssertReady(ro)` runs in `cmd/server/main.go` and
FATAL-LOG-EXITS if any expected column/index/table is missing — the
operator must restart the ingestor first. Covers indexes,
`neighbor_edges`, `observations.resolved_path`,
`observers.{inactive,last_packet_at,iata}`,
`(inactive_)nodes.foreign_advert`, `transmissions.from_pubkey`.

**Category B — Backfill** → ingestor.
`BackfillFromPubkey` and observer-blacklist soft-delete moved to
`cmd/ingestor/maintenance.go`. Server keeps an inert
`fromPubkeyBackfillSnapshot` stub for `/api/healthz` API compatibility.

**Category C — Neighbor-graph persistence (Option 4)** → ingestor
writes, server reads.
- Ingestor (`cmd/ingestor/neighbor_builder.go`): every 60s scans
`observations + transmissions`, extracts edges (originator↔first-hop for
ADVERTs; observer↔last-hop for all), resolves hop prefixes via a
node-table prefix index, upserts into `neighbor_edges`.
- Server (`cmd/server/neighbor_recomputer.go`): every 60s re-reads
`neighbor_edges` and atomic-swaps the resulting `NeighborGraph` into
`s.graph`. Initial load is synchronous on startup. All server-side
incremental edge writers (the two `asyncPersistResolvedPathsAndEdges`
paths in `cmd/server/store.go`) are gone.
- Neighbor-edge daily prune (`PruneNeighborEdges`) moved to ingestor.

**Why Option 4**: clean read/write separation, no startup CPU spike
(server loads existing snapshot instead of rebuilding from history), no
IPC/delta-protocol churn. Staleness budget ~60s — same model as the
analytics recomputers in #1240 / #1248 / #672 axis 2.

**Recomputer interval default for neighbor graph**: 60s
(`NeighborGraphRecomputerDefaultInterval`,
`NeighborEdgesBuilderInterval`).

**Invariants added**:
- `TestServerSourceHasNoCachedRWCalls` (RED commit eae179b9): grep
enforces zero `cachedRW(`, `mode=rw`, or `sql.Open(_journal_mode=WAL…)`
in non-test `cmd/server/` sources.
- `TestServerStartupRequiresMigratedSchema`: server refuses to start
against an unmigrated DB.
- `TestNeighborGraphRecomputerLoadsSnapshot`: post-write snapshot is
picked up on the next refresh.
- `TestNeighborEdgesBuilderUpsertsFromObservations`: end-to-end pipeline
writes the expected edge.

`grep cachedRW cmd/server/*.go | grep -v _test.go` → 0 matches.

Fixes #1287.

---------

Co-authored-by: MeshCore Bot <bot@meshcore.local>
Co-authored-by: Kpa-clawbot <Kpa-clawbot@users.noreply.github.com>
Co-authored-by: corescope-bot <bot@corescope.local>
2026-05-19 23:53:41 -07:00