feat: optimize observations table — 478MB → 141MB

Schema v3 migration:
- Replace observer_id TEXT (64-char hex) with observer_idx INTEGER FK
- Drop redundant hash, observer_name, created_at columns
- Store timestamp as epoch integer instead of ISO string
- In-memory dedup Set replaces expensive unique index lookups
- Auto-migration on startup with timestamped backup (never overwrites)
- Detects already-migrated DBs via pragma user_version + column inspection

Fixes:
- disambiguateHops: restore 'known' field dropped during refactor (5dd0727)
- Skip MQTT connections when NODE_ENV=test
- e2e test: encodeURIComponent for # channel hashes in URLs
- VACUUM + TRUNCATE checkpoint after migration (not just VACUUM)
- Daily TRUNCATE checkpoint at 2:00 AM UTC to reclaim WAL space

Observability:
- SQLite stats in /api/perf (DB size, WAL size, freelist, row counts, busy pages)
- Rendered in perf dashboard with color-coded thresholds

Tests: 839 pass (89 db + 30 migration + 70 helpers + 200 routes + 34 packet-store + 52 decoder + 255 decoder-spec + 62 filter + 47 e2e)
This commit is contained in:
you
2026-03-25 22:33:39 +00:00
parent 133d259860
commit fe9bae08c4
8 changed files with 143 additions and 69 deletions
+38 -24
View File
@@ -67,21 +67,37 @@ db.exec(`
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_transmissions_hash ON transmissions(hash);
CREATE INDEX IF NOT EXISTS idx_transmissions_first_seen ON transmissions(first_seen);
CREATE INDEX IF NOT EXISTS idx_transmissions_payload_type ON transmissions(payload_type);
`);
// --- Determine schema version ---
let schemaVersion = 0;
try {
const row = db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
if (row) schemaVersion = row.version;
} catch {}
let schemaVersion = db.pragma('user_version', { simple: true }) || 0;
// Migrate from old schema_version table to pragma user_version
if (schemaVersion === 0) {
try {
const row = db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
if (row && row.version >= 3) {
db.pragma(`user_version = ${row.version}`);
schemaVersion = row.version;
db.exec('DROP TABLE IF EXISTS schema_version');
}
} catch {}
}
// Detect v3 schema by column presence (handles crash between migration and version write)
if (schemaVersion === 0) {
try {
const cols = db.pragma('table_info(observations)').map(c => c.name);
if (cols.includes('observer_idx') && !cols.includes('observer_id')) {
db.pragma('user_version = 3');
schemaVersion = 3;
console.log('[migration-v3] Detected already-migrated schema, set user_version = 3');
}
} catch {}
}
// --- v3 migration: lean observations table ---
function needsV3Migration() {
@@ -98,7 +114,7 @@ function runV3Migration() {
console.log('[migration-v3] Starting observations table optimization...');
// a. Backup DB
const backupPath = dbPath + '.pre-v3-backup';
const backupPath = dbPath + `.pre-v3-backup-${Date.now()}`;
try {
console.log(`[migration-v3] Backing up DB to ${backupPath}...`);
fs.copyFileSync(dbPath, backupPath);
@@ -137,15 +153,12 @@ function runV3Migration() {
`).run();
console.log(`[migration-v3] Migrated ${result.changes} rows (${Date.now() - stepStart}ms)`);
// d. Drop old table
// d. Drop view, old table, rename
stepStart = Date.now();
db.exec('DROP VIEW IF EXISTS packets_v');
db.exec('DROP TABLE observations');
console.log(`[migration-v3] Dropped old observations table (${Date.now() - stepStart}ms)`);
// e. Rename
stepStart = Date.now();
db.exec('ALTER TABLE observations_v3 RENAME TO observations');
console.log(`[migration-v3] Renamed observations_v3 → observations (${Date.now() - stepStart}ms)`);
console.log(`[migration-v3] Replaced observations table (${Date.now() - stepStart}ms)`);
// f. Create indexes
stepStart = Date.now();
@@ -158,22 +171,23 @@ function runV3Migration() {
console.log(`[migration-v3] Created indexes (${Date.now() - stepStart}ms)`);
// g. Set schema version
db.exec('DELETE FROM schema_version');
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(3);
db.pragma('user_version = 3');
schemaVersion = 3;
// h. Rebuild view (done below in common code)
// i. VACUUM
// i. VACUUM + checkpoint
stepStart = Date.now();
db.exec('VACUUM');
console.log(`[migration-v3] VACUUM complete (${Date.now() - stepStart}ms)`);
db.pragma('wal_checkpoint(TRUNCATE)');
console.log(`[migration-v3] VACUUM + checkpoint complete (${Date.now() - stepStart}ms)`);
console.log(`[migration-v3] Migration complete! Total time: ${Date.now() - startTime}ms`);
return true;
} catch (e) {
console.error(`[migration-v3] Migration failed: ${e.message}`);
console.error('[migration-v3] Old data should still be intact if observations table was not yet dropped');
console.error('[migration-v3] Restore from backup if needed: ' + dbPath + '.pre-v3-backup');
// Try to clean up v3 table if it exists
try { db.exec('DROP TABLE IF EXISTS observations_v3'); } catch {}
return false;
@@ -186,7 +200,7 @@ if (!isV3 && needsV3Migration()) {
runV3Migration();
}
// If schema_version < 3 and no migration happened (fresh DB or migration skipped), create old-style table
// If user_version < 3 and no migration happened (fresh DB or migration skipped), create old-style table
if (schemaVersion < 3) {
const obsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='observations'").get();
if (!obsExists) {
@@ -208,8 +222,8 @@ if (schemaVersion < 3) {
CREATE INDEX idx_observations_timestamp ON observations(timestamp);
CREATE UNIQUE INDEX idx_observations_dedup ON observations(transmission_id, observer_idx, COALESCE(path_json, ''));
`);
db.exec('DELETE FROM schema_version');
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(3);
db.pragma('user_version = 3');
schemaVersion = 3;
} else {
// Old-style observations table exists but migration wasn't run (or failed)
+26 -26
View File
@@ -22,9 +22,9 @@
<meta name="twitter:title" content="MeshCore Analyzer">
<meta name="twitter:description" content="Real-time MeshCore LoRa mesh network analyzer — live packet visualization, node tracking, channel decryption, and route analysis.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/Kpa-clawbot/meshcore-analyzer/master/public/og-image.png">
<link rel="stylesheet" href="style.css?v=1774418278">
<link rel="stylesheet" href="home.css?v=1774418278">
<link rel="stylesheet" href="live.css?v=1774418278">
<link rel="stylesheet" href="style.css?v=1774477855">
<link rel="stylesheet" href="home.css?v=1774477855">
<link rel="stylesheet" href="live.css?v=1774477855">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous">
@@ -81,28 +81,28 @@
<main id="app" role="main"></main>
<script src="vendor/qrcode.js"></script>
<script src="roles.js?v=1774418278"></script>
<script src="customize.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774418278"></script>
<script src="hop-resolver.js?v=1774418278"></script>
<script src="hop-display.js?v=1774418278"></script>
<script src="app.js?v=1774418278"></script>
<script src="home.js?v=1774418278"></script>
<script src="packet-filter.js?v=1774418278"></script>
<script src="packets.js?v=1774418278"></script>
<script src="map.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774418278" onerror="console.error('Failed to load:', this.src)"></script>
<script src="roles.js?v=1774477855"></script>
<script src="customize.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="region-filter.js?v=1774477855"></script>
<script src="hop-resolver.js?v=1774477855"></script>
<script src="hop-display.js?v=1774477855"></script>
<script src="app.js?v=1774477855"></script>
<script src="home.js?v=1774477855"></script>
<script src="packet-filter.js?v=1774477855"></script>
<script src="packets.js?v=1774477855"></script>
<script src="map.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="channels.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="nodes.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="traces.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="analytics.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v1-constellation.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-v2-constellation.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="audio-lab.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="live.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observers.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="observer-detail.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="node-analytics.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
<script src="perf.js?v=1774477855" onerror="console.error('Failed to load:', this.src)"></script>
</body>
</html>
+19
View File
@@ -84,6 +84,25 @@
</div>`;
}
// SQLite stats
if (server.sqlite && !server.sqlite.error) {
const sq = server.sqlite;
const walColor = sq.walSizeMB > 50 ? 'var(--status-red)' : sq.walSizeMB > 10 ? 'var(--status-yellow)' : 'var(--status-green)';
const freelistColor = sq.freelistMB > 10 ? 'var(--status-yellow)' : 'var(--status-green)';
html += `<h3>SQLite</h3><div style="display:flex;gap:16px;flex-wrap:wrap;margin:8px 0;">
<div class="perf-card"><div class="perf-num">${sq.dbSizeMB}MB</div><div class="perf-label">DB Size</div></div>
<div class="perf-card"><div class="perf-num" style="color:${walColor}">${sq.walSizeMB}MB</div><div class="perf-label">WAL Size</div></div>
<div class="perf-card"><div class="perf-num" style="color:${freelistColor}">${sq.freelistMB}MB</div><div class="perf-label">Freelist</div></div>
<div class="perf-card"><div class="perf-num">${(sq.rows.transmissions || 0).toLocaleString()}</div><div class="perf-label">Transmissions</div></div>
<div class="perf-card"><div class="perf-num">${(sq.rows.observations || 0).toLocaleString()}</div><div class="perf-label">Observations</div></div>
<div class="perf-card"><div class="perf-num">${sq.rows.nodes || 0}</div><div class="perf-label">Nodes</div></div>
<div class="perf-card"><div class="perf-num">${sq.rows.observers || 0}</div><div class="perf-label">Observers</div></div>`;
if (sq.walPages) {
html += `<div class="perf-card"><div class="perf-num">${sq.walPages.busy}</div><div class="perf-label">WAL Busy Pages</div></div>`;
}
html += `</div>`;
}
// Server endpoints table
const eps = Object.entries(server.endpoints);
if (eps.length) {
+1 -1
View File
@@ -210,7 +210,7 @@ function disambiguateHops(hops, allNodes, maxHopDist) {
else if (!prev && next && dNext > MAX_HOP_DIST) { r.unreliable = true; r.lat = null; r.lon = null; }
}
return resolved.map(r => ({ hop: r.hop, name: r.name, lat: r.lat, lon: r.lon, pubkey: r.pubkey, ambiguous: !!r.candidates, unreliable: !!r.unreliable }));
return resolved.map(r => ({ hop: r.hop, name: r.name, lat: r.lat, lon: r.lon, pubkey: r.pubkey, known: !!r.known, ambiguous: !!r.candidates, unreliable: !!r.unreliable }));
}
// Update hash_size maps for a single packet
+39
View File
@@ -395,6 +395,28 @@ app.get('/api/perf', (req, res) => {
slowQueries: perfStats.slowQueries.slice(-20),
cache: { size: cache.size, hits: cache.hits, misses: cache.misses, staleHits: cache.staleHits, recomputes: cache.recomputes, hitRate: cache.hits + cache.misses > 0 ? Math.round(cache.hits / (cache.hits + cache.misses) * 1000) / 10 : 0 },
packetStore: pktStore.getStats(),
sqlite: (() => {
try {
const walInfo = db.db.pragma('wal_checkpoint(PASSIVE)');
const pageSize = db.db.pragma('page_size', { simple: true });
const pageCount = db.db.pragma('page_count', { simple: true });
const freelistCount = db.db.pragma('freelist_count', { simple: true });
const dbSizeMB = Math.round(pageSize * pageCount / 1048576 * 10) / 10;
const freelistMB = Math.round(pageSize * freelistCount / 1048576 * 10) / 10;
const fs = require('fs');
const dbPath = process.env.DB_PATH || require('path').join(__dirname, 'data', 'meshcore.db');
let walSizeMB = 0;
try { walSizeMB = Math.round(fs.statSync(dbPath + '-wal').size / 1048576 * 10) / 10; } catch {}
const stats = db.getStats();
return {
dbSizeMB,
walSizeMB,
freelistMB,
walPages: walInfo[0] ? { total: walInfo[0].busy + walInfo[0].checkpointed, checkpointed: walInfo[0].checkpointed, busy: walInfo[0].busy } : null,
rows: { transmissions: stats.totalTransmissions, observations: stats.totalObservations, nodes: stats.totalNodes, observers: stats.totalObservers },
};
} catch (e) { return { error: e.message }; }
})(),
});
});
@@ -425,6 +447,19 @@ setInterval(() => {
} catch (e) { console.error('[wal] checkpoint error:', e.message); }
}, 300000).unref();
// Daily TRUNCATE checkpoint at 2:00 AM UTC — reclaims WAL file space
setInterval(() => {
const h = new Date().getUTCHours();
const m = new Date().getUTCMinutes();
if (h === 2 && m === 0) {
try {
const t0 = Date.now();
db.db.pragma('wal_checkpoint(TRUNCATE)');
console.log(`[wal] daily TRUNCATE checkpoint: ${Date.now() - t0}ms`);
} catch (e) { console.error('[wal] TRUNCATE checkpoint error:', e.message); }
}
}, 60000).unref();
// --- Health / Telemetry Endpoint ---
app.get('/api/health', (req, res) => {
const mem = process.memoryUsage();
@@ -522,6 +557,9 @@ if (config.mqttSources && Array.isArray(config.mqttSources)) {
});
}
if (process.env.NODE_ENV === 'test') {
console.log('[mqtt] Skipping MQTT connections in test mode');
} else {
for (const source of mqttSources) {
try {
const opts = { reconnectPeriod: 5000 };
@@ -800,6 +838,7 @@ for (const source of mqttSources) {
console.error(`MQTT [${source.name || source.broker}] connection failed (non-fatal):`, e.message);
}
}
} // end NODE_ENV !== 'test'
// --- Express ---
app.use(express.json());
+16 -14
View File
@@ -126,13 +126,13 @@ function runDbModule(dbPath) {
process.env.DB_PATH = ${JSON.stringify(dbPath)};
const db = require(${JSON.stringify(path.resolve(__dirname, 'db'))});
const cols = db.db.pragma('table_info(observations)').map(c => c.name);
const sv = db.db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
const sv = db.db.pragma('user_version', { simple: true });
const obsCount = db.db.prepare('SELECT COUNT(*) as c FROM observations').get().c;
const viewRows = db.db.prepare('SELECT * FROM packets_v ORDER BY id').all();
const rawObs = db.db.prepare('SELECT * FROM observations ORDER BY id').all();
console.log(JSON.stringify({
columns: cols,
schemaVersion: sv ? sv.version : 0,
schemaVersion: sv || 0,
obsCount,
viewRows,
rawObs
@@ -193,7 +193,8 @@ console.log('Migration from old schema:');
assert(vr2.path_json === null, 'null path_json preserved');
// Verify backup file created
assert(fs.existsSync(dbPath + '.pre-v3-backup'), 'backup file exists');
const backups1 = fs.readdirSync(tmpDir).filter(f => f.includes('.pre-v3-backup-'));
assert(backups1.length === 1, 'backup file exists');
fs.rmSync(tmpDir, { recursive: true });
}
@@ -211,7 +212,8 @@ console.log('\nMigration idempotency:');
assert(info.schemaVersion === 3, 'first run migrates to v3');
// Second run — should NOT re-run migration (no backup overwrite, same data)
const backupMtime = fs.statSync(dbPath + '.pre-v3-backup').mtimeMs;
const backups2pre = fs.readdirSync(tmpDir).filter(f => f.includes('.pre-v3-backup-'));
const backupMtime = fs.statSync(path.join(tmpDir, backups2pre[0])).mtimeMs;
info = runDbModule(dbPath);
assert(info.schemaVersion === 3, 'second run still v3');
assert(info.obsCount === 3, 'rows still intact');
@@ -219,24 +221,24 @@ console.log('\nMigration idempotency:');
fs.rmSync(tmpDir, { recursive: true });
}
// --- Test 3: Backup failure aborts migration ---
console.log('\nBackup failure aborts migration:');
// --- Test 3: Each migration creates a unique backup ---
console.log('\nUnique backup per migration:');
{
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'meshcore-mig-test3-'));
const dbPath = path.join(tmpDir, 'test-mig3.db');
createOldSchemaDB(dbPath);
// Create backup path as a directory so copyFileSync fails
fs.mkdirSync(dbPath + '.pre-v3-backup');
// Run db.js — migration should abort, old schema preserved
const info = runDbModule(dbPath);
// Old schema should be preserved
assert(info.columns.includes('observer_id'), 'old observer_id column preserved');
assert(info.schemaVersion < 3, 'schema version not updated');
assert(info.obsCount === 3, 'old rows still intact');
// Migration should have completed
assert(info.columns.includes('observer_idx'), 'migration completed');
assert(info.schemaVersion === 3, 'schema version is 3');
// A timestamped backup should exist
const backups = fs.readdirSync(tmpDir).filter(f => f.includes('.pre-v3-backup-'));
assert(backups.length === 1, 'exactly one backup created');
assert(fs.statSync(path.join(tmpDir, backups[0])).size > 0, 'backup is non-empty');
fs.rmSync(tmpDir, { recursive: true });
}
+3 -3
View File
@@ -288,9 +288,9 @@ console.log('\nv3 schema:');
assert(typeof viewRow.timestamp === 'string', 'packets_v timestamp is ISO string');
}
// schema_version table exists with version 3
const sv = db.db.prepare('SELECT version FROM schema_version ORDER BY version DESC LIMIT 1').get();
assert(sv && sv.version === 3, 'schema_version table has version 3');
// user_version is 3
const sv = db.db.pragma('user_version', { simple: true });
assert(sv === 3, 'user_version is 3');
}
// --- v3 ingestion: observer resolved via observer_idx ---
+1 -1
View File
@@ -375,7 +375,7 @@ async function main() {
if (chList.length > 0) {
const someCh = chList[0];
assert(someCh.messageCount > 0, `channel has messages (${someCh.messageCount})`);
const msgResp = (await get(`/api/channels/${someCh.hash}/messages`)).data;
const msgResp = (await get(`/api/channels/${encodeURIComponent(someCh.hash)}/messages`)).data;
assert(msgResp.messages.length > 0, 'channel has message list');
assert(msgResp.messages[0].sender !== undefined, 'message has sender');
console.log(` ✓ Channels: ${chList.length} channels\n`);