Persist message history to SQLite with 1s poller (#15) (#19)
CI / build + unit (push) Successful in 1m4s
CI / build + unit (push) Successful in 1m4s
This commit was merged in pull request #19.
This commit is contained in:
@@ -6,6 +6,8 @@ import {
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import * as net from 'node:net';
|
||||
// type-only import: node:sqlite is only required at runtime by whoever opens a database (mesh.module.ts / tests)
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type * as Proto from '@meshtastic/protobufs';
|
||||
import {
|
||||
BROADCAST_ADDR,
|
||||
@@ -56,8 +58,28 @@ export interface TelemetryRecord {
|
||||
data: object;
|
||||
}
|
||||
|
||||
// bounded history: keep the newest N records, in-memory only
|
||||
// bounded in-memory buffer: keep the newest N records (SQLite keeps more — see MESSAGES_DDL)
|
||||
const HISTORY_LIMIT = 100;
|
||||
/** Rows kept in SQLite across restarts; older rows are pruned on every poller tick. */
|
||||
const DB_RETENTION = 1000;
|
||||
|
||||
// one row per MessageRecord, keyed by packet id (tx ids and injected ids are random uint32, rx uses the wire id)
|
||||
export const MESSAGES_DDL = `CREATE TABLE IF NOT EXISTS messages(
|
||||
id INTEGER PRIMARY KEY,
|
||||
dir TEXT NOT NULL,
|
||||
to_node INTEGER,
|
||||
from_node INTEGER,
|
||||
text TEXT NOT NULL,
|
||||
channel INTEGER NOT NULL,
|
||||
at INTEGER NOT NULL,
|
||||
status TEXT,
|
||||
ack_error TEXT
|
||||
)`;
|
||||
|
||||
/** Create the messages table if missing (idempotent — safe on every startup). */
|
||||
export function initMessagesTable(db: DatabaseSync): void {
|
||||
db.exec(MESSAGES_DDL);
|
||||
}
|
||||
|
||||
/** What we know about a node so far: full NodeInfo after sync, possibly just {num}+position for live-only nodes. */
|
||||
type NodeEntry = Pick<Proto.Mesh.NodeInfo, 'num'> &
|
||||
@@ -135,6 +157,16 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
process.env.MESH_SEND_CHANNEL ?? '3',
|
||||
10,
|
||||
);
|
||||
// how often the poller re-upserts the history buffer into SQLite (ms)
|
||||
private readonly pollMs = parseInt(process.env.MESH_POLL_MS ?? '1000', 10);
|
||||
|
||||
/** SQLite database backing the message history; null = persistence disabled (tests construct without one). */
|
||||
private db: DatabaseSync | null = null;
|
||||
private pollTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(db?: DatabaseSync) {
|
||||
this.db = db ?? null;
|
||||
}
|
||||
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer: Uint8Array = new Uint8Array(0);
|
||||
@@ -160,13 +192,21 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
private shuttingDown = false;
|
||||
|
||||
onModuleInit(): void {
|
||||
if (this.db) {
|
||||
initMessagesTable(this.db);
|
||||
this.seedHistory(); // history survives process restarts
|
||||
// ponytail: full re-upsert of the ≤100-row buffer every tick — trivial load, zero bookkeeping
|
||||
this.pollTimer = setInterval(() => this.flushHistory(), this.pollMs);
|
||||
}
|
||||
this.connect();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.shuttingDown = true;
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
if (this.pollTimer) clearInterval(this.pollTimer);
|
||||
this.socket?.destroy();
|
||||
this.db?.close();
|
||||
}
|
||||
|
||||
/** Bridge connection state: `{ connected, myNodeNum, host, port }`. */
|
||||
@@ -298,12 +338,66 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* Message history, newest first: inbound TEXT_MESSAGE_APP packets (dir 'rx') and our sends (dir 'tx').
|
||||
* tx records start 'pending' and move to 'delivered'/'failed' when the routing ACK/NACK arrives;
|
||||
* broadcasts stay 'pending' because firmware strips want_ack before TX. In-memory only, last 100, reset on reconnect.
|
||||
* broadcasts stay 'pending' because firmware strips want_ack before TX. Backed by SQLite: the buffer is
|
||||
* seeded from the database on startup (newest 100) and re-upserted every MESH_POLL_MS, so history
|
||||
* survives process restarts and bridge reconnects; the DB keeps the newest 1000 rows.
|
||||
*/
|
||||
messages(): MessageRecord[] {
|
||||
return [...this.history].reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-upsert the entire in-memory history buffer into SQLite (INSERT OR REPLACE per row) and enforce
|
||||
* retention: only the newest DB_RETENTION rows survive. Called by the poller every MESH_POLL_MS;
|
||||
* public so tests can drive a tick without real timers. No-op when no database is configured.
|
||||
*/
|
||||
flushHistory(): void {
|
||||
const db = this.db;
|
||||
if (!db) return;
|
||||
const upsert = db.prepare(
|
||||
'INSERT OR REPLACE INTO messages(id, dir, to_node, from_node, text, channel, at, status, ack_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
for (const r of this.history) {
|
||||
upsert.run(
|
||||
r.id,
|
||||
r.dir,
|
||||
r.to ?? null,
|
||||
r.from ?? null,
|
||||
r.text,
|
||||
r.channel,
|
||||
r.at,
|
||||
r.status ?? null,
|
||||
r.ackError ?? null,
|
||||
);
|
||||
}
|
||||
db.exec(
|
||||
`DELETE FROM messages WHERE id NOT IN (SELECT id FROM messages ORDER BY at DESC LIMIT ${DB_RETENTION})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Load the newest HISTORY_LIMIT rows from SQLite into the buffer (oldest-first) so history survives restarts. */
|
||||
private seedHistory(): void {
|
||||
const rows = this.db!.prepare(
|
||||
'SELECT id, dir, to_node, from_node, text, channel, at, status, ack_error FROM messages ORDER BY at DESC LIMIT ?',
|
||||
).all(HISTORY_LIMIT) as Array<Record<string, unknown>>;
|
||||
// rows come back newest first — the buffer is oldest-first
|
||||
for (const row of [...rows].reverse()) {
|
||||
const rec: MessageRecord = {
|
||||
id: Number(row.id),
|
||||
dir: row.dir === 'tx' ? 'tx' : 'rx',
|
||||
text: String(row.text),
|
||||
channel: Number(row.channel),
|
||||
at: Number(row.at),
|
||||
};
|
||||
if (row.to_node != null) rec.to = Number(row.to_node);
|
||||
if (row.from_node != null) rec.from = Number(row.from_node);
|
||||
if (row.status != null)
|
||||
rec.status = row.status as MessageRecord['status'];
|
||||
if (row.ack_error != null) rec.ackError = String(row.ack_error);
|
||||
this.history.push(rec);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a synthetic inbound (rx) record into the message history — as if received on the mesh.
|
||||
* NOT transmitted over RF: nothing is written to the bridge or air; the record exists so external
|
||||
@@ -401,13 +495,13 @@ export class MeshService implements OnModuleInit, OnModuleDestroy {
|
||||
this.socket = socket;
|
||||
socket.on('connect', () => {
|
||||
this.connected = true;
|
||||
// fresh sync on every (re)connect — drop stale channels/nodes/device info/history from the old session
|
||||
// fresh sync on every (re)connect — drop stale channels/nodes/device info/telemetry from the old session;
|
||||
// message history is NOT reset: it's an append-only log backed by SQLite and survives reconnects
|
||||
this.channelMap = new Map();
|
||||
this.nodeMap = new Map();
|
||||
this.metadata = null;
|
||||
this.configSections.clear();
|
||||
this.moduleConfigSections.clear();
|
||||
this.history = [];
|
||||
this.telemetryHistory = [];
|
||||
this.pendingTx.clear();
|
||||
this.logger.log(`bridge connected ${this.host}:${this.port}`);
|
||||
|
||||
Reference in New Issue
Block a user