From 8306cd770c48cbaecc4fe0a1e70650a20770a461 Mon Sep 17 00:00:00 2001 From: gadgethd <111318106+gadgethd@users.noreply.github.com> Date: Thu, 6 Aug 2026 22:40:37 +0000 Subject: [PATCH] =?UTF-8?q?fix(paths):=20split=20multibyte-paths=20into=20?= =?UTF-8?q?two=20simple=20queries=20joined=20in=20JS=20(timeout=20?= =?UTF-8?q?=E2=86=92=200.3s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN-spring-clean-2026-08-06.md | 7 +++ backend/src/db/index.ts | 108 ++++++++++++++++++-------------- 2 files changed, 68 insertions(+), 47 deletions(-) diff --git a/PLAN-spring-clean-2026-08-06.md b/PLAN-spring-clean-2026-08-06.md index 42b5c45..8d29eca 100644 --- a/PLAN-spring-clean-2026-08-06.md +++ b/PLAN-spring-clean-2026-08-06.md @@ -353,3 +353,10 @@ ROOT CAUSE (3 layers, all fixed): RESULTS: heardBy 80.6s→87ms, advert 1.8s→6ms, status 2.7s→44ms, recentPackets 11s→222ms, links 42.5s→40ms. Full owner/live battery <500ms. Zero owner/live timeouts since deploy. Health healthy, HopReach 200. Deploy notes: docker build used a STALE cache once (image lacked the patch) — verify image content (grep dist) before deploy; compose recreate uses the .env pin at recreate time — update pin BEFORE recreate or pass BACKEND_IMAGE= explicitly. FOLLOW-UP (pre-existing, not part of this fix): stats-page queries (multibyte/buckets in statsRepository.ts) still use per-row canonical joins + run on the analytics pool (hardcoded max 2) → 1-2 min under load, can transiently starve health synthetic checks. Same function→join fix applies when next touched. + +## PATHS OVERLAY + GNOME OFFLINE INVESTIGATION (2026-08-06 ~22:00-23:30Z, Ben-reported) +1. PATHS TOGGLE EMPTY — ROOT CAUSE: GET /path-beta/multibyte-paths timed out (25s+). The single JOIN blob made the planner estimate rows=1 on the scoped-node CTE (node_identity_sightings EXISTS is not estimable) → nested loops re-materialising the 13k-row identity view per row → 17.4M-row scans, 100-155s. ANALYZE + MATERIALIZED + UNION variants all still mis-estimated. FIX (commit 6e5d4c5): split into two simple statements (scoped nodes ~325ms + multibyte links ~92ms) joined + geometry-filtered in JS. Live: ukmesh 0.30s / 4,202 segments / maxCount 698,565; noparams 0.22s. Backend 273/273. +2. SITE-WIDE STALENESS — NONE. Ingest healthy (7.5k+5.3k packets/2h; top senders fresh; Sh@DoW nodes last seen 1-5 min). Multibyte evidence flowing (10,176 today). Other observers' traffic normal (NULL-src = encrypted, expected). +3. GNOME-STKTN-RPT-V4 OFFLINE — REAL, NOT A WEBSITE BUG. Steady ~13 payload-mentions/hour for days → abrupt stop 21:32:00.315Z. Zero evidence in any column since (src/rx/topic/payload/status). The site is correctly reporting the device went silent. GNOME-MSG-RPT online (22:13Z, 2,746 adverts). Likely device/power/RF at the STKTN site — physical check needed. +4. SIGHTINGS MV: node_identity_sightings (22.5k rows, zero indexes, per-query canonical grouping) → indexed MATERIALIZED VIEW (unique (node_id,network) + (network,node_id)), refreshed every 5 min with the links MV. All buildNodeScopeClause consumers (topology, viable links, paths, network filters) benefit. +5. recordMultibyteEvidence deadlock errors: 4 in 6h pre-fix, 0 in the 30 min after the MV swap. Watch. diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 617c5d0..6e3d558 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -1237,53 +1237,67 @@ export async function getMultibytePathSegments(network?: string, observer?: stri const scope = buildScopePlaceholders(1, network, observer); const params: unknown[] = [...scope.params]; - const res = await pool.query<{ - a_lat: number; - a_lon: number; - b_lat: number; - b_lon: number; - count: number; - }>( - `SELECT - a.lat AS a_lat, - a.lon AS a_lon, - b.lat AS b_lat, - b.lon AS b_lon, - nl.multibyte_observed_count AS count - FROM node_identity_links nl - JOIN node_identity_nodes a ON a.node_id = nl.node_a_id - JOIN node_identity_nodes b ON b.node_id = nl.node_b_id - WHERE nl.multibyte_observed_count > 0 - AND nl.itm_viable = true - AND a.lat IS NOT NULL - AND a.lon IS NOT NULL - AND b.lat IS NOT NULL - AND b.lon IS NOT NULL - AND a.lat BETWEEN -90 AND 90 - AND b.lat BETWEEN -90 AND 90 - AND a.lon BETWEEN -180 AND 180 - AND b.lon BETWEEN -180 AND 180 - AND NOT (ABS(a.lat) < 5 AND ABS(a.lon) < 5) - AND NOT (ABS(b.lat) < 5 AND ABS(b.lon) < 5) - AND (a.name IS NULL OR a.name NOT LIKE '%🚫%') - AND (b.name IS NULL OR b.name NOT LIKE '%🚫%') - AND SQRT( - POWER((a.lat - b.lat) * 111, 2) - + POWER((a.lon - b.lon) * 111 * COS(RADIANS((a.lat + b.lat) / 2)), 2) - ) <= ${MAX_MULTIBYTE_PATH_SEGMENT_KM} - ${buildNodeScopeClause(scope, 'a')} - ${buildNodeScopeClause(scope, 'b')} - ORDER BY nl.multibyte_observed_count DESC`, - params, - ); + // Two simple statements joined in JS. A single JOIN blob makes the planner + // guess rows=1 on the scoped-node CTE (the sightings EXISTS is not + // estimable) and pick nested loops that re-materialise the identity view + // per row: 100s+. Splitting keeps both statements simple enough that the + // planner cannot mis-estimate them (measured: ~325ms + ~92ms). + const [nodesRes, linksRes] = await Promise.all([ + pool.query<{ + node_id: string; + lat: number | null; + lon: number | null; + name: string | null; + }>( + `SELECT node_id, lat, lon, name + FROM node_identity_nodes n + WHERE 1=1${buildNodeScopeClause(scope, 'n')}`, + params, + ), + pool.query<{ + node_a_id: string; + node_b_id: string; + multibyte_observed_count: number; + }>( + `SELECT node_a_id, node_b_id, multibyte_observed_count + FROM node_identity_links + WHERE multibyte_observed_count > 0 + AND itm_viable = true`, + ), + ]); - const segments = res.rows.map((row) => ({ - positions: [ - [Number(row.a_lat), Number(row.a_lon)], - [Number(row.b_lat), Number(row.b_lon)], - ] as [[number, number], [number, number]], - count: Number(row.count ?? 0), - })); + const nodesById = new Map(nodesRes.rows.map((row) => [row.node_id, row])); + const segments: Array<{ + positions: [[number, number], [number, number]]; + count: number; + }> = []; + for (const link of linksRes.rows) { + const a = nodesById.get(link.node_a_id); + const b = nodesById.get(link.node_b_id); + if (!a || !b) continue; + if (a.lat == null || a.lon == null || b.lat == null || b.lon == null) continue; + if (a.lat < -90 || a.lat > 90 || b.lat < -90 || b.lat > 90) continue; + if (a.lon < -180 || a.lon > 180 || b.lon < -180 || b.lon > 180) continue; + if (Math.abs(a.lat) < 5 && Math.abs(a.lon) < 5) continue; + if (Math.abs(b.lat) < 5 && Math.abs(b.lon) < 5) continue; + if ((a.name ?? '').includes('🚫') || (b.name ?? '').includes('🚫')) continue; + const distKm = Math.sqrt( + Math.pow((a.lat - b.lat) * 111, 2) + + Math.pow( + (a.lon - b.lon) * 111 * Math.cos(((a.lat + b.lat) / 2) * (Math.PI / 180)), + 2, + ), + ); + if (distKm > MAX_MULTIBYTE_PATH_SEGMENT_KM) continue; + segments.push({ + positions: [ + [a.lat, a.lon], + [b.lat, b.lon], + ], + count: link.multibyte_observed_count, + }); + } + segments.sort((x, y) => y.count - x.count); const maxCount = segments.reduce((max, segment) => Math.max(max, segment.count), 0); return { maxCount, segments }; @@ -1311,7 +1325,7 @@ export async function getViableLinks(network?: string, observer?: string): Promi if (network && !observer) { const scopedNetworks = network === 'ukmesh' ? UKMESH_NETWORKS : [network]; const res = await pool.query( - `WITH net_nodes AS ( + `WITH net_nodes AS MATERIALIZED ( SELECT DISTINCT node_id FROM node_identity_nodes WHERE network = ANY($1::text[]) AND (name IS NULL OR name NOT LIKE '%🚫%')