fix(#1854): move relay last_seen touch to the ingestor — server writes have been no-ops since mode=ro - #1855
Conversation
…last_seen Pins the behaviour that has been silently broken since Kpa-clawbot#1283/Kpa-clawbot#1289. The server's touchRelayLastSeen (cmd/server/store.go) calls TouchNodeLastSeen, which issues `UPDATE nodes SET last_seen`. The server opens SQLite with mode=ro (cmd/server/db.go:54), so that UPDATE fails with "attempt to write a readonly database" on every call. The call site discards the error: if err := s.db.TouchNodeLastSeen(pk, ts); err == nil { s.lastSeenTouched[pk] = now } Net effect: nodes.last_seen tracks ADVERT arrivals only. Relay participation has never refreshed it, so infra nodes on long advert cadences look stale while actively carrying traffic. Verified against a production store (live.saarmesh.de, 1388 nodes): 1362 have last_seen within one minute of their own most recent ADVERT. Five tests, all against the ingestor (the writer-owning process, which already resolves hop prefixes to full pubkeys for observations.resolved_path since Kpa-clawbot#1547): - AdvancesLastSeen — the core regression - NeverGoesBackwards — monotonic guard for out-of-order ingest - Debounces — write amplification guard on the hot path - IgnoresEmptyAndUnknown— unresolved hops must not create rows - MalformedTimestamp — unparsable rxTime is a no-op TouchRelayNodes is stubbed as a no-op and Stats.RelayTouches added so the suite compiles and reds on assertions rather than on a build error (AGENTS.md § Test-First red-commit bar). Red output: --- FAIL: TestTouchRelayNodes_AdvancesLastSeen last_seen = "2026-07-01T00:00:00Z", want "2026-07-10T12:00:00Z" RelayTouches = 0, want 1 --- FAIL: TestTouchRelayNodes_Debounces RelayTouches = 0, want 1 (second touch should be debounced) Refs Kpa-clawbot#1598, Kpa-clawbot#1611 Co-Authored-By: Claude <noreply@anthropic.com>
…iter-owning process) Green for the tests added in the previous commit. ## Problem cmd/server/db.go:54 opens SQLite with `mode=ro` (Kpa-clawbot#1283/Kpa-clawbot#1289). The server's touchRelayLastSeen called TouchNodeLastSeen, which issues `UPDATE nodes SET last_seen` on that handle — it has failed with "attempt to write a readonly database" on every call since, and the call site discarded the error: if err := s.db.TouchNodeLastSeen(pk, ts); err == nil { s.lastSeenTouched[pk] = now } Two consequences beyond the missing update: - The debounce map is only populated on success, so it never populated. The server retried the failing UPDATE for every resolved pubkey in every decode window rather than once per 5 minutes. - nodes.last_seen has tracked ADVERT arrivals only. Infra nodes on long advert cadences look stale while actively relaying, and MoveStaleNodes evicts them to inactive_nodes after nodeDays. Measured on live.saarmesh.de (1388 nodes): 1362 have last_seen within one minute of their own most recent ADVERT. Reproduced directly against the production DB with the server's DSN: sqlite3 'file:meshcore.db?mode=ro' \ "UPDATE nodes SET last_seen=... WHERE public_key=..." -> attempt to write a readonly database This is why Kpa-clawbot#1598 presents as "active nodes disappear": the backend signal the frontend fix in that thread relies on was never being written. ## Fix Move the writer to cmd/ingestor, which owns nodes per Kpa-clawbot#1283/Kpa-clawbot#1287 and since Kpa-clawbot#1547 already resolves hop prefixes to full pubkeys for observations.resolved_path. The touch hooks into that existing resolution point — no new IPC surface, no second resolver, and only unambiguously resolved hops qualify (a 1-byte prefix collision cannot keep a silent node alive). - Store.TouchRelayNodes: monotonic guard in SQL so out-of-order ingest never rewinds; 5-minute debounce keyed on rxTime; UPDATE only, so unknown pubkeys never create rows; unparsable rxTime is a no-op; Stats.RelayTouches for /api/perf visibility. - resolvedPubkeys() flattens a resolved path to its non-nil, deduped pubkeys. - Debounce records the attempt rather than the row match, so an unknown pubkey is not retried per observation. ## Server-side removal touchRelayLastSeen, DB.TouchNodeLastSeen, the lastSeenTouched map and the now-unused allResolvedPKs decode-window map are deleted. readonly_invariant_test.go gains `UPDATE\s+nodes\s+SET\s+last_seen` so the writer cannot drift back. cmd/server/touch_last_seen_test.go and two tests in resolved_index_test.go are removed with it. Worth noting why they were green throughout: they exercise the touch against setupTestDB, which opens read-write. The invariant test is the pattern that actually catches this class of bug, hence the added regex. ## Test notes cmd/ingestor: full suite green (100.7s). cmd/server: the suite is order-dependent on master today — unmodified upstream/master produced 8 failures in TestHandleNodePaths_* / TestHandleAnalytics* on this machine, this branch produced 5, and the set shifts between runs. All pass in isolation. Not touched by this change; flagging it rather than papering over it. Refs Kpa-clawbot#1598, Kpa-clawbot#1611, Kpa-clawbot#1845 Co-Authored-By: Claude <noreply@anthropic.com>
…-only invariant Addresses the dijkstra finding on Kpa-clawbot#1854: enumerating write shapes does not scale, and the enumeration is exactly what let this bug through. Kpa-clawbot#1324 added `UPDATE nodes SET multibyte_` when PR Kpa-clawbot#903's server-side writer was relocated. touchRelayLastSeen then introduced a second write shape on the same table — `SET last_seen` — which the grep did not cover. It failed on every call since Kpa-clawbot#1289 with the error discarded at the call site, and nodes.last_seen silently degraded into an advert-age proxy for months. The node directory is ingestor-owned (Kpa-clawbot#1283/Kpa-clawbot#1287). Forbid writes to the table rather than naming columns: UPDATE nodes SET (any column) UPDATE inactive_nodes SET INSERT [OR ...] INTO nodes INSERT [OR ...] INTO inactive_nodes DELETE FROM nodes DELETE FROM inactive_nodes Verified the guard bites: injecting `UPDATE nodes SET role = ?` into clock_skew.go fails the test at the injected line; removing it returns to green. cmd/server has no remaining DML against either table. Scope note: cmd/server does contain DML against other ingestor-owned tables — handlePostPacket (routes.go:1290,1320, POST /api/packets) INSERTs into transmissions/observers/observations, and migrateContentHashesAsync (hash_migrate.go:62,76,77, started from main.go:546) UPDATEs transmissions/observations and DELETEs from transmissions. Both run against the mode=ro handle and cannot succeed; the migration additionally sets hashMigrationComplete=true in a defer regardless of outcome. Neither is exercised on the deployment I checked (the migration finds no work, the endpoint is unused), so both are latent rather than actively failing. They need their own fix, so this change deliberately does not extend the invariant to those tables — filed separately rather than widened here. Refs Kpa-clawbot#1854, Kpa-clawbot#1598 Co-Authored-By: Claude <noreply@anthropic.com>
|
Pushed You're right that enumerating shapes is the wrong construction, and it's worth noting the enumeration is precisely what let this through: #1324 added So the patterns now forbid the table rather than naming columns: Verified the guard actually bites rather than passing vacuously — injecting One deliberate limit. I stopped at the node-directory tables rather than covering all ingestor-owned ones, because extending to
Neither is exercised on the deployment I checked (the migration finds no work, the endpoint is unused), so they're latent rather than actively failing. Filed as #1856 — the endpoint one needs a keep-or-delete decision, which didn't belong bundled into this fix. Once those are resolved the invariant should cover every ingestor-owned table, which is the full correctness-by-construction shape you're describing. |
|
🤖 Parallel-persona polish (carmack + kent-beck + dijkstra). Verdict: ISSUES — 2× MAJOR, several MINOR. MAJOR — cmd/server latent write DML on mode=ro handle (carmack, dijkstra concur) MAJOR — invariant regex has bypasses (dijkstra) MINOR
Tests: READY (kent-beck) CI has not run yet (no checks reported); auto-merge gate not evaluable. |
…drop redundant lock Addresses the parallel-persona review on PR Kpa-clawbot#1855. ## MAJOR — invariant regex bypasses (dijkstra) The flat patterns missed `UPDATE nodes AS n SET`, `UPDATE OR REPLACE nodes SET`, quoted identifiers and `REPLACE INTO nodes`. Replaced with nodeTableWritePattern(), which normalises the optional OR-conflict clause, optional "/`/[ quoting and optional alias, and is case-insensitive. Verified each named bypass is now caught by injecting it into clock_skew.go and asserting the test fails at that line: UPDATE nodes AS n SET -> caught UPDATE OR REPLACE nodes SET -> caught UPDATE OR IGNORE inactive_nodes -> caught UPDATE "nodes" SET -> caught REPLACE INTO nodes -> caught INSERT OR IGNORE INTO nodes -> caught DELETE FROM `nodes` -> caught update inactive_nodes set -> caught The runtime-assembled case (fmt.Sprintf("UPDATE %s SET", tbl)) is not solvable by source grepping and the helper doc says so rather than implying coverage. TestServerDBConnIsReadOnly (handle cannot write) and TestServerDBHasNoWriteMethods (helpers absent from the type) are the structural backstop; this test is the cheap first line that names file and line. ## MAJOR — deferred cmd/server DML Follow-up issue is filed: Kpa-clawbot#1856 (handlePostPacket, migrateContentHashesAsync). Not widening the invariant to transmissions/observations/observers here, because that reds the build on those two sites and one of them needs a keep-or-delete decision on a public endpoint. ## MINOR — monotonic asymmetry (dijkstra): no change, claim does not hold `MAX(MIN(COALESCE(last_seen,''), ingestNow), rxTime)` does not collapse to always-overwrite. MIN clamps a future-dated stored value to ingest time; for any non-future value it reduces to MAX(last_seen, rxTime), which is monotonic. Checked in sqlite: stored=10:00 rxTime=09:00 ingestNow=10:05 -> 10:00 (no rewind) stored=09:00 rxTime=10:00 ingestNow=10:05 -> 10:00 stored=2027 rxTime=10:00 ingestNow=10:05 -> 10:05 (clamp) Formatting concern checked too: UpsertNode uses time.RFC3339 for both timestamps and so does this writer. All 1385 last_seen values on the deployment I checked are 20 chars, no fractional seconds, so the lexicographic comparison is chronological. Documented on the function rather than changed. ## MINOR — others - relayTouchMu removed. writerMu is held across all of InsertTransmission, so it was redundant; renamed to touchRelayNodesLocked so the requirement is in the name, matching the convention the old server-side helper used. - relayTouched is now capped (relayTouchedMaxEntries, 50k) and compacted by dropping entries older than two debounce windows. Overflow can only cost an extra UPDATE, never miss one. - Double dedup dropped: resolvedPubkeys already dedups, so the second pass is gone and the doc comment records the dependency. - Debounce-on-attempt blindspot documented at the assignment. Refs Kpa-clawbot#1854, Kpa-clawbot#1855, Kpa-clawbot#1856 Co-Authored-By: Claude <noreply@anthropic.com>
|
Pushed MAJOR 1 — deferred cmd/server DML. Follow-up issue is #1856, filed before the previous push ( MAJOR 2 — regex bypasses. Taken, and thanks — every shape you named got through. Replaced with The MINOR — monotonic asymmetry. Pushing back: The formatting dependency you flagged alongside it is real and worth pinning, so I checked rather than assumed: MINOR — the rest, all taken.
Ingestor suite green (101.4s). Server suite unchanged from the order-dependence I noted in the description — still present on unmodified |
|
🤖 Re-review after 787e102 (carmack + dijkstra + kent-beck). Verdict: CLEAN. MAJOR resolved
MINOR resolved
MINOR refuted with proof (accepted)
Gate
|
Fixes #1854. Refs #1598, #1611, #1845.
The bug
cmd/server/db.go:54opens SQLitemode=ro(#1283/#1289).touchRelayLastSeen→TouchNodeLastSeenissuesUPDATE nodes SET last_seenon that handle. It has failed on every call since, with the error discarded at the call site:nodes.last_seenhas therefore tracked ADVERT arrivals only. Verified on live.saarmesh.de (1388 nodes): 1362 havelast_seenwithin one minute of their own most recent ADVERT. Reproduced directly with the server's DSN in #1854.Secondary effect:
lastSeenTouchedis populated only in the success branch, so the debounce never engaged — the server retried the failing UPDATE for every resolved pubkey in every decode window.The fix
The writer moves to
cmd/ingestor, which ownsnodesper #1283/#1287 and since #1547 already resolves hop prefixes to full pubkeys forobservations.resolved_path. The touch hooks into that existing resolution point, so there is no new IPC surface and no second resolver. Only unambiguously resolved hops qualify — a 1-byte prefix collision cannot keep a silent node alive.I considered the
internal/mbcapqueuesnapshot handoff used for #903/#1324 and did not need it: that pattern exists because the capability computation lives in the server's analytics cycle. Path resolution already happens in the ingestor, so a file handoff would add a hop for nothing.Store.TouchRelayNodes:last_seen IS NULL OR last_seen < ?) — out-of-order ingest never rewindsrxTime, matching the interval the server intendedrxTimeis a no-op rather than writing garbage into the node directoryStats.RelayTouchesfor/api/perfvisibilityServer-side removal
touchRelayLastSeen,DB.TouchNodeLastSeen, thelastSeenTouchedmap and the now-unusedallResolvedPKsdecode-window map are deleted.readonly_invariant_test.gogainsUPDATE\s+nodes\s+SET\s+last_seen.cmd/server/touch_last_seen_test.goand two tests inresolved_index_test.gogo with it. Worth stating why they were green for months: they build theirPacketStoreonsetupTestDB, which opens read-write. The production constraint is the one thing they did not reproduce, which is why the added invariant regex — not a replacement unit test — is the right guard here.Tests
Five tests in
cmd/ingestor/relay_touch_test.go, committed red first (573bbde) with a stubbedTouchRelayNodesso the suite compiles and reds on assertions:Coverage:
AdvancesLastSeen(core regression),NeverGoesBackwards(monotonic),Debounces(write amplification on the hot path),IgnoresEmptyAndUnknown(unresolved hops must not create rows),MalformedTimestamp.cmd/ingestor: full suite green, 100.7s.cmd/server: green for the invariant and the affected packages, but the suite is order-dependent on master today. Unmodifiedupstream/masterproduced 8 failures on this machine (TestHandleNodePaths_*,TestHandleAnalytics*,TestComputeAnalyticsDistanceLockHoldDuration); this branch produced 5, and the set shifts between runs. All pass in isolation. Untouched by this change — flagging rather than papering over, and happy to open a separate issue if that is not already known.Impact on the open threads
This is the backend half of #1598. The frontend work there keys on relay recency; that signal was never being written, so the two changes are complementary rather than alternatives. It also removes the eviction problem I raised in #1845 without touching
MoveStaleNodes: oncelast_seenreflects relay activity, the existinglast_seen < cutoffpredicate stops evicting nodes that are carrying traffic.Not addressed here: the duplicate-row behaviour between
nodesandinactive_nodes(609 keys in both on my deployment), which is an independent defect and wants its own change.Verification offer
I run a 1100-repeater MeshCore deployment and can run this against production traffic and report
RelayTouchesplus the resultinglast_seendistribution before/after, if that is useful for review.