fix(privacy): rematerialize packet privacy asynchronously (#107)

This commit is contained in:
gadgethd
2026-09-30 23:00:07 +01:00
committed by GitHub
parent 846422e5ad
commit 71a54f2a2d
15 changed files with 809 additions and 35 deletions
+1 -1
View File
@@ -198,7 +198,7 @@ jobs:
done < <(sed -n 's/^[[:space:]]*file: //p' .github/workflows/release.yml)
compose_services="$(docker compose -f docker-compose.yml -f docker-compose.ci.yml --profile dev config --services)"
for service in \
db-migrate backend app-ukmesh website-ukmesh website-dev mesh-health-check \
db-migrate backend privacy-remat-worker app-ukmesh website-ukmesh website-dev mesh-health-check \
mosquitto-reloader link-worker link-backfill-worker hopreach \
timescaledb mosquitto; do
grep -Fxq "$service" <<<"$compose_services"
+3 -1
View File
@@ -33,7 +33,9 @@ test('public packet scopes keep legacy test-topic rows out', () => {
assert.match(filters.packets, /is_private IS NOT TRUE/);
assert.match(filters.packets, /packet_visibility_materialization_state/);
assert.match(filters.packets, /cached_visibility\.visibility_generation = current_visibility\.generation/);
assert.doesNotMatch(filters.packets, /private_node_prefixes|unnest\(/);
assert.match(filters.packets, /privacy_rematerialization_queue remat/);
assert.match(filters.packets, /remat\.status IN \('pending', 'processing', 'failed'\)/);
assert.doesNotMatch(filters.packets, /private_node_prefixes/);
assert.doesNotMatch(filters.packets, /private_node\.name LIKE '%🚫%'/);
});
+24
View File
@@ -44,6 +44,30 @@ export function publicPacketPrivacySql(alias?: string): string {
WHERE cached_visibility.singleton = TRUE
AND cached_visibility.visibility_generation = current_visibility.generation
)
AND NOT EXISTS (
SELECT 1
FROM privacy_rematerialization_queue remat
WHERE remat.target = 'packets'
AND remat.status IN ('pending', 'processing', 'failed')
AND (
${prefix}rx_node_id = remat.node_id
OR ${prefix}src_node_id = remat.node_id
OR (
(${prefix}network = remat.network
OR (${prefix}network IN ('ukmesh', 'northeast', 'teesside')
AND remat.network IN ('ukmesh', 'northeast', 'teesside')))
AND EXISTS (
SELECT 1
FROM unnest(COALESCE(${prefix}path_hashes, ARRAY[]::text[])) AS packet_prefix
WHERE UPPER(packet_prefix) IN (
UPPER(LEFT(remat.node_id, 2)),
UPPER(LEFT(remat.node_id, 4)),
UPPER(LEFT(remat.node_id, 6))
)
)
)
)
)
)`;
}
@@ -0,0 +1,140 @@
SET LOCAL lock_timeout = '5s';
SET LOCAL statement_timeout = '30s';
-- Node privacy transitions now enqueue stored-row rematerialization. The
-- worker advances public_visibility_state.generation and
-- packet_visibility_materialization_state.visibility_generation together only
-- after a pass completes. While work is pending, both remain equal at the
-- previous generation: public read gates remain open and node-name filters
-- reflect the new state. Neither generation may advance independently here.
-- The BEFORE nodes lock trigger and identity-table generation trigger retain
-- their existing behavior.
CREATE TABLE IF NOT EXISTS privacy_rematerialization_queue (
id BIGSERIAL PRIMARY KEY,
node_id TEXT NOT NULL,
network TEXT,
target TEXT NOT NULL DEFAULT 'packets' CHECK (target IN ('packets','packet_paths')),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','processing','done','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
UNIQUE (target, node_id)
);
CREATE INDEX IF NOT EXISTS privacy_remat_queue_due_idx
ON privacy_rematerialization_queue (status, requested_at);
CREATE OR REPLACE FUNCTION sync_private_node_prefixes()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
changed_node_id TEXT := COALESCE(NEW.node_id, OLD.node_id);
changed_network TEXT := COALESCE(NEW.network, OLD.network, 'ukmesh');
old_private BOOLEAN := FALSE;
new_private BOOLEAN := FALSE;
BEGIN
IF TG_OP <> 'INSERT' THEN
old_private := COALESCE(OLD.name, '') LIKE '%🚫%';
END IF;
IF TG_OP <> 'DELETE' THEN
new_private := COALESCE(NEW.name, '') LIKE '%🚫%';
END IF;
IF TG_OP = 'UPDATE'
AND OLD.name IS NOT DISTINCT FROM NEW.name
AND OLD.network IS NOT DISTINCT FROM NEW.network THEN
RETURN NEW;
END IF;
DELETE FROM private_node_prefixes WHERE node_id = changed_node_id;
IF TG_OP <> 'DELETE' AND COALESCE(NEW.name, '') LIKE '%🚫%' THEN
INSERT INTO private_node_prefixes (node_id, network, prefix_size_bytes, prefix)
VALUES
(NEW.node_id, COALESCE(NEW.network, 'ukmesh'), 1, UPPER(LEFT(NEW.node_id, 2))),
(NEW.node_id, COALESCE(NEW.network, 'ukmesh'), 2, UPPER(LEFT(NEW.node_id, 4))),
(NEW.node_id, COALESCE(NEW.network, 'ukmesh'), 3, UPPER(LEFT(NEW.node_id, 6)))
ON CONFLICT (node_id, network, prefix_size_bytes) DO UPDATE SET
prefix = EXCLUDED.prefix,
updated_at = NOW();
END IF;
IF old_private IS DISTINCT FROM new_private THEN
INSERT INTO privacy_rematerialization_queue (node_id, network, target)
VALUES (changed_node_id, changed_network, 'packets')
ON CONFLICT (target, node_id) DO UPDATE SET
requested_at = NOW(),
network = EXCLUDED.network,
status = CASE WHEN privacy_rematerialization_queue.status = 'processing'
THEN 'processing' ELSE 'pending' END,
attempts = CASE WHEN privacy_rematerialization_queue.status = 'processing'
THEN privacy_rematerialization_queue.attempts ELSE 0 END,
finished_at = CASE WHEN privacy_rematerialization_queue.status = 'processing'
THEN privacy_rematerialization_queue.finished_at ELSE NULL END;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS nodes_private_prefix_materialization ON nodes;
CREATE TRIGGER nodes_private_prefix_materialization
AFTER INSERT OR DELETE OR UPDATE ON nodes
FOR EACH ROW
EXECUTE FUNCTION sync_private_node_prefixes();
CREATE OR REPLACE FUNCTION rematerialize_packet_path_privacy()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
DECLARE
changed_node_id TEXT := COALESCE(NEW.node_id, OLD.node_id);
changed_network TEXT := COALESCE(NEW.network, OLD.network, 'ukmesh');
old_private BOOLEAN := TG_OP <> 'INSERT' AND COALESCE(OLD.name, '') LIKE '%🚫%';
new_private BOOLEAN := TG_OP <> 'DELETE' AND COALESCE(NEW.name, '') LIKE '%🚫%';
BEGIN
IF old_private IS DISTINCT FROM new_private THEN
INSERT INTO privacy_rematerialization_queue (node_id, network, target)
VALUES (changed_node_id, changed_network, 'packet_paths')
ON CONFLICT (target, node_id) DO UPDATE SET
requested_at = NOW(),
network = EXCLUDED.network,
status = CASE WHEN privacy_rematerialization_queue.status = 'processing'
THEN 'processing' ELSE 'pending' END,
attempts = CASE WHEN privacy_rematerialization_queue.status = 'processing'
THEN privacy_rematerialization_queue.attempts ELSE 0 END,
finished_at = CASE WHEN privacy_rematerialization_queue.status = 'processing'
THEN privacy_rematerialization_queue.finished_at ELSE NULL END;
END IF;
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS nodes_private_zz_packet_path_materialization ON nodes;
CREATE TRIGGER nodes_private_zz_packet_path_materialization
AFTER INSERT OR UPDATE OR DELETE ON nodes
FOR EACH ROW
EXECUTE FUNCTION rematerialize_packet_path_privacy();
-- The worker owns the generation pair. Nested prefix maintenance is already
-- ignored by bump_visibility_for_identity_table(); keep that function and its
-- direct identity-mutation behavior unchanged.
CREATE OR REPLACE FUNCTION bump_public_visibility_generation()
RETURNS TRIGGER
LANGUAGE plpgsql
AS $$
BEGIN
IF TG_OP = 'DELETE' THEN RETURN OLD; END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS nodes_public_visibility_generation ON nodes;
CREATE TRIGGER nodes_public_visibility_generation
AFTER INSERT OR UPDATE OR DELETE ON nodes
FOR EACH ROW
EXECUTE FUNCTION bump_public_visibility_generation();
+211 -5
View File
@@ -20,6 +20,7 @@ import {
flush,
type PacketBatchInput,
} from './packetBatch.js';
import { processNextPrivacyRematerialization } from '../workers/privacy-rematerialization.js';
const { Pool } = pg;
const databaseUrl = process.env['TEST_INGEST_DATABASE_URL'];
@@ -102,6 +103,7 @@ test('base schema and every migration apply to a brand-new database', {
assert.deepEqual(executed, [
'050_packet_paths_and_retention.sql',
'051_reset_readiness.sql',
'056_async_privacy_rematerialization.sql',
]);
assert.deepEqual(await runMigrations(freshPool), []);
@@ -249,6 +251,169 @@ test('base schema and every migration apply to a brand-new database', {
]);
});
test('privacy rename commits and rematerializes a compressed chunk above the old DML budget', {
skip: databaseUrl ? false : 'TEST_INGEST_DATABASE_URL is not configured',
}, async (t) => {
const adminPool = new Pool({ connectionString: databaseUrl, max: 1 });
const databaseName = `meshcore_remat_${process.pid}_${Date.now()}`;
const databaseIdentifier = `"${databaseName}"`;
const url = new URL(databaseUrl as string);
url.pathname = `/${databaseName}`;
let pool: pg.Pool | null = null;
t.after(async () => {
await pool?.end();
await adminPool.query(`DROP DATABASE IF EXISTS ${databaseIdentifier} WITH (FORCE)`);
await adminPool.end();
});
await adminPool.query(`CREATE DATABASE ${databaseIdentifier}`);
pool = new Pool({ connectionString: url.toString(), max: 2 });
await pool.query(fs.readFileSync(new URL('./schema/base.sql', import.meta.url), 'utf8'));
await runMigrations(pool);
const nodeId = 'F'.repeat(64);
await pool.query(`INSERT INTO nodes (node_id, name, network) VALUES ($1, 'incident public', 'ukmesh')
ON CONFLICT (node_id) DO UPDATE SET name = EXCLUDED.name`, [nodeId]);
configurePacketBatch((text, params) => pool!.query(text, params));
const now = new Date();
const smallPackets: PacketBatchInput[] = [0, 1, 2].map((index) => ({
time: new Date(now.getTime() + index),
packetHash: `incident-small-${index}`,
rxNodeId: nodeId,
srcNodeId: nodeId,
topic: 'meshcore/ZZZ/incident',
topicPrefix: 'meshcore',
iata: 'ZZZ',
packetType: 4,
routeType: 0,
hopCount: 1,
rssi: -80,
snr: 5,
payloadJson: '{}',
companionSender: null,
rawHex: '00',
advertCount: null,
pathHashes: [nodeId.slice(0, 4)],
pathHashSizeBytes: 2,
network: 'ukmesh',
transportCodes: null,
regionScope: null,
}));
const writes = smallPackets.map((packet) => enqueuePacket(packet));
await flush();
assert.equal((await Promise.all(writes)).length, 3);
await pool.query(`
INSERT INTO packets (time, packet_hash, topic, network)
VALUES ($1, 'incident-unrelated', 'meshcore/ZZZ/unrelated', 'ukmesh')
`, [new Date(now.getTime() + 10_000)]);
const fixtureTime = new Date(now);
fixtureTime.setUTCHours(12, 0, 0, 0);
fixtureTime.setUTCDate(fixtureTime.getUTCDate() - 2);
const fixtureRows = 100_001;
const inserted = await pool.query(`
INSERT INTO packets (time, packet_hash, rx_node_id, src_node_id, topic, network)
SELECT $1::timestamptz, 'incident-compressed-' || n::text, $2, $2,
'meshcore/ZZZ/incident-compressed', 'ukmesh'
FROM generate_series(1, $3::int) AS n
`, [fixtureTime, nodeId, fixtureRows]);
assert.equal(inserted.rowCount, fixtureRows);
const chunk = await pool.query<{ chunk_schema: string; chunk_name: string }>(`
SELECT chunk_schema, chunk_name FROM timescaledb_information.chunks
WHERE hypertable_name = 'packets' AND range_start <= $1 AND range_end > $1
`, [fixtureTime]);
assert.equal(chunk.rows.length, 1);
const chunkName = `"${chunk.rows[0]!.chunk_schema}"."${chunk.rows[0]!.chunk_name}"`;
await pool.query('SELECT compress_chunk($1::regclass)', [chunkName]);
const compressed = await pool.query<{ is_compressed: boolean }>(`
SELECT is_compressed FROM timescaledb_information.chunks
WHERE hypertable_name = 'packets' AND chunk_schema = $1 AND chunk_name = $2
`, [chunk.rows[0]!.chunk_schema, chunk.rows[0]!.chunk_name]);
assert.equal(compressed.rows[0]?.is_compressed, true);
const pair = async () => {
const result = await pool!.query<{ public_generation: string; materialized_generation: string }>(`
SELECT p.generation::text AS public_generation,
m.visibility_generation::text AS materialized_generation
FROM public_visibility_state p
JOIN packet_visibility_materialization_state m ON p.singleton = m.singleton
`);
assert.equal(result.rows[0]?.public_generation, result.rows[0]?.materialized_generation);
return Number(result.rows[0]?.public_generation);
};
const initialGeneration = await pair();
await pool.query("UPDATE nodes SET name = 'incident private 🚫' WHERE node_id = $1", [nodeId]);
assert.equal((await pool.query<{ name: string }>('SELECT name FROM nodes WHERE node_id = $1', [nodeId])).rows[0]?.name, 'incident private 🚫');
assert.equal(await pair(), initialGeneration);
const queued = await pool.query<{ target: string; status: string }>(`
SELECT target, status FROM privacy_rematerialization_queue WHERE node_id = $1 ORDER BY target
`, [nodeId]);
assert.deepEqual(queued.rows, [
{ target: 'packet_paths', status: 'pending' },
{ target: 'packets', status: 'pending' },
]);
const duringPendingPacket = {
...smallPackets[0]!,
time: new Date(now.getTime() + 5_000),
packetHash: 'incident-during-pending',
};
const pendingWrite = enqueuePacket(duringPendingPacket);
await flush();
assert.deepEqual(await pendingWrite, { isPrivate: true, visibilityOk: false });
const persistedPending = await pool.query<{ is_private: boolean; visibility_ok: boolean }>(
'SELECT is_private, visibility_ok FROM packets WHERE packet_hash = $1',
[duringPendingPacket.packetHash],
);
assert.deepEqual(persistedPending.rows, [{ is_private: true, visibility_ok: false }]);
const publicScope = networkFilters('ukmesh');
const visibleDuringPending = await pool.query<{ packet_hash: string }>(`
SELECT packet_hash FROM packets
WHERE packet_hash = ANY($2::text[]) ${publicScope.packets}
ORDER BY packet_hash
`, [publicScope.params[0], [smallPackets[0]!.packetHash, 'incident-unrelated']]);
assert.deepEqual(visibleDuringPending.rows, [{ packet_hash: 'incident-unrelated' }]);
// The old trigger's default 100k decompression budget cannot update this
// chunk; the worker transaction explicitly removes that per-DML limit.
const limitedClient = await pool.connect();
try {
await limitedClient.query('BEGIN');
await limitedClient.query('SET LOCAL timescaledb.max_tuples_decompressed_per_dml_transaction = 100000');
await assert.rejects(limitedClient.query(`
UPDATE packets SET is_private = is_private WHERE time = $1 AND rx_node_id = $2
`, [fixtureTime, nodeId]), /decompress|tuple|limit/i);
} finally {
await limitedClient.query('ROLLBACK');
limitedClient.release();
}
assert.equal(await processNextPrivacyRematerialization(pool, { chunkPauseMs: 0 }), true);
assert.ok(await pair() > initialGeneration);
assert.equal(await processNextPrivacyRematerialization(pool, { chunkPauseMs: 0 }), true);
const privateRows = await pool.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM packets
WHERE time = $1 AND rx_node_id = $2 AND is_private AND NOT visibility_ok
`, [fixtureTime, nodeId]);
assert.equal(privateRows.rows[0]?.count, String(fixtureRows));
const privatePaths = await pool.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM packet_paths
WHERE rx_node_id = $1 AND is_private AND NOT visibility_ok
`, [nodeId]);
assert.equal(privatePaths.rows[0]?.count, '4');
const beforePublicGeneration = await pair();
await pool.query("UPDATE nodes SET name = 'incident public' WHERE node_id = $1", [nodeId]);
assert.equal(await pair(), beforePublicGeneration);
assert.equal(await processNextPrivacyRematerialization(pool, { chunkPauseMs: 0 }), true);
assert.equal(await processNextPrivacyRematerialization(pool, { chunkPauseMs: 0 }), true);
assert.ok(await pair() > beforePublicGeneration);
const publicRows = await pool.query<{ count: string }>(`
SELECT COUNT(*)::text AS count FROM packets
WHERE time = $1 AND rx_node_id = $2 AND NOT is_private AND visibility_ok
`, [fixtureTime, nodeId]);
assert.equal(publicRows.rows[0]?.count, String(fixtureRows));
});
test('packet batch atomically coalesces observer, sighting, and stats writes', {
skip: databaseUrl ? false : 'TEST_INGEST_DATABASE_URL is not configured',
}, async (t) => {
@@ -296,7 +461,8 @@ test('packet batch atomically coalesces observer, sighting, and stats writes', {
process.env['MIGRATION_016_PRIVATE_PREFIXES_APPROVAL'] = previousApproval;
}
}
await pool.query('DELETE FROM nodes WHERE node_id = $1', [compatibilityNode]);
// Retain the public fixture: deleting a node cascades a direct prefix-table
// statement and deliberately invalidates the existing identity fence.
const migrationRunLengths = migrationRuns.map((run) => run.length).sort((a, b) => a - b);
assert.equal(migrationRunLengths[0], 0);
assert.ok((migrationRunLengths[1] ?? 0) > 0);
@@ -379,10 +545,38 @@ test('packet batch atomically coalesces observer, sighting, and stats writes', {
'DELETE FROM packets WHERE packet_hash = ANY($1::text[])',
[Object.values(legacyHashes)],
);
await pool.query(
'DELETE FROM nodes WHERE node_id = ANY($1::text[])',
[[legacyPrivateNode, legacyPublicNode]],
);
// Leave these isolated test identities in the scratch database. Their
// deletion would independently invalidate the legacy identity fence.
await pool.query('DELETE FROM privacy_rematerialization_queue');
const visibilityPair = async () => {
const result = await pool.query<{ public_generation: string; materialized_generation: string }>(`
SELECT public.generation::text AS public_generation,
materialized.visibility_generation::text AS materialized_generation
FROM public_visibility_state public
JOIN packet_visibility_materialization_state materialized ON materialized.singleton = public.singleton
WHERE public.singleton = TRUE
`);
assert.equal(result.rows[0]?.public_generation, result.rows[0]?.materialized_generation);
return Number(result.rows[0]?.public_generation);
};
const runQueuedRemat = async (nodeId: string) => {
const queue = await pool.query<{ target: string; status: string }>(`
SELECT target, status FROM privacy_rematerialization_queue
WHERE node_id = $1 ORDER BY target
`, [nodeId]);
assert.deepEqual(queue.rows, [
{ target: 'packet_paths', status: 'pending' },
{ target: 'packets', status: 'pending' },
]);
assert.equal(await processNextPrivacyRematerialization(pool, { chunkPauseMs: 0 }), true);
await visibilityPair();
assert.equal(await processNextPrivacyRematerialization(pool, { chunkPauseMs: 0 }), true);
const completed = await pool.query<{ status: string }>(
'SELECT status FROM privacy_rematerialization_queue WHERE node_id = $1', [nodeId],
);
assert.deepEqual(completed.rows.map((row) => row.status), ['done', 'done']);
};
const network = `ingest_${suffix}`;
const observerId = `A${suffix.padStart(63, 'A')}`.slice(0, 64);
@@ -574,10 +768,17 @@ test('packet batch atomically coalesces observer, sighting, and stats writes', {
}
assert.deepEqual(shadowWarnings, []);
const beforePrivateGeneration = await visibilityPair();
await pool.query(
`UPDATE nodes SET name = 'integration observer 🚫' WHERE node_id = $1`,
[observerId],
);
assert.equal((await pool.query<{ name: string }>(
'SELECT name FROM nodes WHERE node_id = $1', [observerId],
)).rows[0]?.name, 'integration observer 🚫');
assert.equal(await visibilityPair(), beforePrivateGeneration);
await runQueuedRemat(observerId);
assert.ok(await visibilityPair() > beforePrivateGeneration);
const privateBeforeRetention = await pool.query<{
private_packets: string;
private_paths: string;
@@ -593,10 +794,13 @@ test('packet batch atomically coalesces observer, sighting, and stats writes', {
private_paths: '3',
});
const beforePublicGeneration = await visibilityPair();
await pool.query(
`UPDATE nodes SET name = 'integration observer' WHERE node_id = $1`,
[observerId],
);
assert.equal(await visibilityPair(), beforePublicGeneration);
await runQueuedRemat(observerId);
const publicBeforeRetention = await pool.query<{
public_packets: string;
public_paths: string;
@@ -624,6 +828,7 @@ test('packet batch atomically coalesces observer, sighting, and stats writes', {
`UPDATE nodes SET name = 'integration observer 🚫' WHERE node_id = $1`,
[observerId],
);
await runQueuedRemat(observerId);
const privateAfterRetention = await pool.query<{
packet_count: string;
private_paths: string;
@@ -641,6 +846,7 @@ test('packet batch atomically coalesces observer, sighting, and stats writes', {
`UPDATE nodes SET name = 'integration observer' WHERE node_id = $1`,
[observerId],
);
await runQueuedRemat(observerId);
const visibility = await pool.query<{ generation: string }>(
`SELECT generation::text FROM public_visibility_state WHERE singleton = TRUE`,
+11
View File
@@ -246,6 +246,12 @@ async function writeBatch(batch: PendingPacket[], idempotent: boolean): Promise<
WHERE singleton = TRUE
FOR KEY SHARE
),
privacy_remat_state AS MATERIALIZED (
SELECT EXISTS (
SELECT 1 FROM privacy_rematerialization_queue
WHERE status IN ('pending', 'processing', 'failed')
) AS active
),
incoming (
row_id, time, packet_hash, rx_node_id, src_node_id, topic, topic_prefix,
iata, packet_type, route_type, hop_count, rssi, snr, payload, companion_sender, raw_hex,
@@ -256,12 +262,17 @@ async function writeBatch(batch: PendingPacket[], idempotent: boolean): Promise<
SELECT i.*,
gen_random_uuid() AS observation_id,
CASE
WHEN remat.active THEN meshcore_path_matches_private(
i.network, i.rx_node_id, i.src_node_id,
i.path_hashes, i.path_hash_size_bytes
)
WHEN i.prefix_cache_generation = visibility.generation THEN i.classified_private
ELSE TRUE
END AS is_private,
i.prefix_cache_generation = visibility.generation AS prefix_cache_fresh
FROM incoming i
CROSS JOIN current_visibility visibility
CROSS JOIN privacy_remat_state remat
),
${idempotencyCte}
inserted AS (
@@ -6,6 +6,10 @@ const migration = readFileSync(
new URL('./migrations/042_packet_visibility_fence.sql', import.meta.url),
'utf8',
);
const asyncMigration = readFileSync(
new URL('./migrations/056_async_privacy_rematerialization.sql', import.meta.url),
'utf8',
);
test('packet visibility materialization is fenced across privacy identity changes', () => {
assert.match(migration, /packet_visibility_materialization_state/);
@@ -16,3 +20,12 @@ test('packet visibility materialization is fenced across privacy identity change
assert.match(migration, /TG_TABLE_NAME = 'private_node_prefixes' AND pg_trigger_depth\(\) > 1/);
assert.match(migration, /deliberately leave the materialization\s+-- generation stale/);
});
test('node triggers enqueue rematerialization and leave paired generation advancement to worker', () => {
assert.match(asyncMigration, /CREATE TABLE IF NOT EXISTS privacy_rematerialization_queue/);
assert.match(asyncMigration, /VALUES \(changed_node_id, changed_network, 'packets'\)/);
assert.match(asyncMigration, /VALUES \(changed_node_id, changed_network, 'packet_paths'\)/);
assert.doesNotMatch(asyncMigration, /UPDATE packets p|UPDATE packet_paths path/);
assert.match(asyncMigration, /CREATE TRIGGER nodes_public_visibility_generation/);
assert.doesNotMatch(asyncMigration, /SET generation = generation \+ 1/);
});
@@ -6,9 +6,20 @@ const migration = readFileSync(
new URL('./migrations/041_stable_public_visibility_generation.sql', import.meta.url),
'utf8',
);
const asyncMigration = readFileSync(
new URL('./migrations/056_async_privacy_rematerialization.sql', import.meta.url),
'utf8',
);
test('public visibility generation changes only when node privacy changes', () => {
assert.match(migration, /old_private IS DISTINCT FROM new_private/);
assert.doesNotMatch(migration, /decode_inputs_changed|OLD\.lat|OLD\.lon|OLD\.role/);
assert.match(migration, /IF TG_OP = 'DELETE' THEN RETURN OLD/);
});
test('current node trigger defers visibility generation changes to rematerialization', () => {
const currentFunction = asyncMigration.split('CREATE OR REPLACE FUNCTION bump_public_visibility_generation()', 2)[1];
assert.ok(currentFunction);
assert.doesNotMatch(currentFunction, /UPDATE public_visibility_state/);
assert.match(currentFunction, /IF TG_OP = 'DELETE' THEN RETURN OLD/);
});
+50 -1
View File
@@ -633,7 +633,7 @@ export async function getWorkerHealthOverview() {
// Compute system stats once — cpuUsagePct() diffs against lastCpuSample,
// so calling it twice in one request gives a garbage near-zero second reading.
const sysStats = systemStats();
const [workers, history, errors1h, ingest, pathDiagnostics, operationalChecks, databaseMaintenance, databaseRuntime, redisDurability] = await Promise.all([
const [workers, history, errors1h, ingest, pathDiagnostics, operationalChecks, databaseMaintenance, databaseRuntime, redisDurability, privacyRemat] = await Promise.all([
currentWorkerStatuses(sysStats),
query<{
ts: string;
@@ -702,6 +702,23 @@ export async function getWorkerHealthOverview() {
WHERE datname = current_database()`,
),
redisDurabilityState(),
query<{
pending: string;
processing: string;
failed: string;
failed_retryable: string;
failed_exhausted: string;
oldest_pending_age_seconds: string | null;
}>(`
SELECT COUNT(*) FILTER (WHERE status = 'pending')::text AS pending,
COUNT(*) FILTER (WHERE status = 'processing')::text AS processing,
COUNT(*) FILTER (WHERE status = 'failed')::text AS failed,
COUNT(*) FILTER (WHERE status = 'failed' AND attempts < $1)::text AS failed_retryable,
COUNT(*) FILTER (WHERE status = 'failed' AND attempts >= $1)::text AS failed_exhausted,
MAX(EXTRACT(EPOCH FROM (NOW() - requested_at)))
FILTER (WHERE status = 'pending')::text AS oldest_pending_age_seconds
FROM privacy_rematerialization_queue
`, [Math.min(100, Math.max(1, Number(process.env['PRIVACY_REMAT_MAX_ATTEMPTS'] ?? 8) || 8))]),
]);
const ingestRow = ingest.rows[0];
@@ -777,6 +794,17 @@ export async function getWorkerHealthOverview() {
message: `${tablesNeedingVacuum} database table(s) exceed the dead-row vacuum threshold`,
});
}
const privacyRematRow = privacyRemat.rows[0];
const privacyRematProblem = privacyRematerializationBacklogProblem({
pending: Number(privacyRematRow?.pending ?? 0),
processing: Number(privacyRematRow?.processing ?? 0),
failed: Number(privacyRematRow?.failed ?? 0),
failedRetryable: Number(privacyRematRow?.failed_retryable ?? 0),
failedExhausted: Number(privacyRematRow?.failed_exhausted ?? 0),
oldestPendingAgeSeconds: privacyRematRow?.oldest_pending_age_seconds == null
? null : Number(privacyRematRow.oldest_pending_age_seconds),
});
if (privacyRematProblem) problems.push(privacyRematProblem);
if (redisDurability.maxmemory_policy !== 'noeviction') {
problems.push({
code: 'redis_eviction_policy_unsafe',
@@ -837,6 +865,27 @@ export type HealthProblem = {
message: string;
};
export function privacyRematerializationBacklogProblem(state: {
pending: number;
processing: number;
failed: number;
failedRetryable: number;
failedExhausted: number;
oldestPendingAgeSeconds: number | null;
}): HealthProblem | null {
const age = state.oldestPendingAgeSeconds ?? 0;
const severity = age > 6 * 60 * 60 || state.failedExhausted > 0
? 'critical'
: age > 30 * 60 || state.failedRetryable > 0 ? 'warning' : null;
if (!severity) return null;
return {
code: 'privacy_rematerialization_backlog',
severity,
message: `Privacy rematerialization pending=${state.pending} processing=${state.processing} `
+ `failed=${state.failed} oldest_pending_age_minutes=${Math.floor(age / 60)}`,
};
}
/**
* Anonymous browser diagnostics are reported to operators but are deliberately
* absent from the authoritative status calculation.
+15 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { summarizeAuthoritativeHealth } from './status.js';
import { privacyRematerializationBacklogProblem, summarizeAuthoritativeHealth } from './status.js';
test('anonymous frontend diagnostics cannot forge health severity', () => {
assert.deepEqual(summarizeAuthoritativeHealth([], 1_000_000), {
@@ -20,3 +20,17 @@ test('anonymous frontend diagnostics cannot forge health severity', () => {
frontendErrors: 0,
});
});
test('privacy rematerialization backlog ignores active work and classifies stale or failed work', () => {
const state = {
pending: 0, processing: 1, failed: 0, failedRetryable: 0,
failedExhausted: 0, oldestPendingAgeSeconds: null,
};
assert.equal(privacyRematerializationBacklogProblem(state), null);
assert.equal(privacyRematerializationBacklogProblem({ ...state, pending: 1, oldestPendingAgeSeconds: 1_801 })?.severity, 'warning');
assert.equal(privacyRematerializationBacklogProblem({ ...state, failed: 1, failedRetryable: 1 })?.severity, 'warning');
const exhausted = privacyRematerializationBacklogProblem({ ...state, failed: 1, failedExhausted: 1 });
assert.equal(exhausted?.severity, 'critical');
assert.match(exhausted?.message ?? '', /pending=0 processing=1 failed=1 oldest_pending_age_minutes=0/);
assert.equal(privacyRematerializationBacklogProblem({ ...state, pending: 1, oldestPendingAgeSeconds: 21_601 })?.severity, 'critical');
});
+3
View File
@@ -299,6 +299,7 @@ const METRIC_NETWORKS = new Set(['ukmesh', 'teesside', 'test']);
const METRIC_ANALYSIS_WORKLOADS = new Set(['spam-analysis', 'path-learning']);
const METRIC_WORKERS = new Set([
'health',
'privacy_remat',
'path_learning',
'synthetic',
'link_backfill',
@@ -306,6 +307,8 @@ const METRIC_WORKERS = new Set([
]);
const METRIC_WORKER_PHASES = new Set([
'snapshot',
'pass',
'chunk',
'cleanup',
'rebuild',
'refresh',
+31 -26
View File
@@ -738,33 +738,38 @@ async function handleMessage(topic: string, rawPayload: Buffer): Promise<void> {
}
if (!isSpam) {
const nodeUpdate = await upsertNode(nodeId, {
name: advertName,
lat: advertLat,
lon: advertLon,
role: appData?.['deviceRole'] as number | undefined,
iata: nodeIata,
publicKey: senderKey,
network,
advertHash: canonicalPacketId,
});
advertCount = nodeUpdate.advertCount;
try {
const nodeUpdate = await upsertNode(nodeId, {
name: advertName,
lat: advertLat,
lon: advertLon,
role: appData?.['deviceRole'] as number | undefined,
iata: nodeIata,
publicKey: senderKey,
network,
advertHash: canonicalPacketId,
});
advertCount = nodeUpdate.advertCount;
emitNodeUpsert({
node_id: nodeId,
name: advertName,
lat: advertLat,
lon: advertLon,
role: appData?.['deviceRole'] as number | undefined,
iata,
network,
observer_id: observerKey,
public_key: senderKey,
last_seen: new Date().toISOString(),
is_online: true,
advert_count: advertCount,
coordinates_changed: nodeUpdate.coordinatesChanged,
});
emitNodeUpsert({
node_id: nodeId,
name: advertName,
lat: advertLat,
lon: advertLon,
role: appData?.['deviceRole'] as number | undefined,
iata,
network,
observer_id: observerKey,
public_key: senderKey,
last_seen: new Date().toISOString(),
is_online: true,
advert_count: advertCount,
coordinates_changed: nodeUpdate.coordinatesChanged,
});
} catch (err) {
mqttIngestOutcomesTotal.inc({ outcome: 'advert_persist_failure' });
console.error('[mqtt] advert node upsert failed:', (err as Error).message);
}
}
}
@@ -0,0 +1,268 @@
import 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
import { pathToFileURL } from 'node:url';
import pg from 'pg';
import { observeWorkerOutcome } from '../metrics.js';
import { startWorkerMetrics } from './workerMetrics.js';
const { Pool } = pg;
function boundedEnvNumber(name: string, fallback: number, min: number, max: number): number {
const value = Number(process.env[name]);
return Number.isFinite(value) ? Math.min(max, Math.max(min, Math.trunc(value))) : fallback;
}
const INTERVAL_MS = boundedEnvNumber('PRIVACY_REMAT_INTERVAL_MS', 120_000, 1_000, 3_600_000);
export const MAX_ATTEMPTS = boundedEnvNumber('PRIVACY_REMAT_MAX_ATTEMPTS', 8, 1, 100);
const CHUNK_PAUSE_MS = boundedEnvNumber('PRIVACY_REMAT_CHUNK_PAUSE_MS', 500, 0, 60_000);
const CHUNK_STATEMENT_TIMEOUT_MS = boundedEnvNumber(
'PRIVACY_REMAT_CHUNK_STATEMENT_TIMEOUT_MS', 900_000, 1_000, 3_600_000,
);
type Target = 'packets' | 'packet_paths';
type QueueRow = {
id: string;
node_id: string;
network: string | null;
target: Target;
attempts: number;
requested_at: Date;
started_at: Date;
};
type Chunk = { range_start: Date; range_end: Date };
// The two assignment expressions below are copied verbatim from migration 049.
// Keep packet privacy semantics here aligned with the deployed DB function.
const PACKET_UPDATE_SQL = `UPDATE packets p
SET is_private = EXISTS (
SELECT 1
FROM private_node_prefixes pp
WHERE (
pp.network = p.network
OR (
pp.network IN ('ukmesh', 'northeast', 'teesside')
AND p.network IN ('ukmesh', 'northeast', 'teesside')
)
)
AND (
pp.node_id IN (p.rx_node_id, p.src_node_id)
OR (
p.path_hash_size_bytes = pp.prefix_size_bytes
AND EXISTS (
SELECT 1
FROM unnest(COALESCE(p.path_hashes, ARRAY[]::text[])) AS packet_prefix
WHERE UPPER(packet_prefix) = pp.prefix
)
)
)
),
visibility_ok = (
(COALESCE(cardinality(p.path_hashes), 0) = 0 OR p.path_hash_size_bytes BETWEEN 1 AND 3)
AND NOT EXISTS (
SELECT 1
FROM private_node_prefixes pp
WHERE (
pp.network = p.network
OR (
pp.network IN ('ukmesh', 'northeast', 'teesside')
AND p.network IN ('ukmesh', 'northeast', 'teesside')
)
)
AND (
pp.node_id IN (p.rx_node_id, p.src_node_id)
OR (
p.path_hash_size_bytes = pp.prefix_size_bytes
AND EXISTS (
SELECT 1
FROM unnest(COALESCE(p.path_hashes, ARRAY[]::text[])) AS packet_prefix
WHERE UPPER(packet_prefix) = pp.prefix
)
)
)
)
)
WHERE p.time >= $1 AND p.time < $2
AND (
p.rx_node_id = $3 OR p.src_node_id = $3
OR ((p.network = $4 OR (p.network IN ('ukmesh', 'northeast', 'teesside')
AND $4 IN ('ukmesh', 'northeast', 'teesside')))
AND EXISTS (
SELECT 1 FROM unnest(COALESCE(p.path_hashes, ARRAY[]::text[])) AS pp
WHERE UPPER(pp) IN (UPPER(LEFT($3, 2)), UPPER(LEFT($3, 4)), UPPER(LEFT($3, 6)))
))
)`;
const PATH_UPDATE_SQL = `UPDATE packet_paths path
SET is_private = meshcore_path_matches_private(
path.network, path.rx_node_id, path.src_node_id,
path.path_hashes, path.path_hash_size_bytes
),
visibility_ok = meshcore_path_is_valid(
path.path_hashes, path.path_hash_size_bytes
) AND NOT meshcore_path_matches_private(
path.network, path.rx_node_id, path.src_node_id,
path.path_hashes, path.path_hash_size_bytes
)
WHERE path.time >= $1 AND path.time < $2
AND (path.rx_node_id = $3 OR path.src_node_id = $3
OR ((path.network = $4 OR (path.network IN ('ukmesh', 'northeast', 'teesside')
AND $4 IN ('ukmesh', 'northeast', 'teesside')))
AND path.path_hashes && ARRAY[
UPPER(LEFT($3, 2)), UPPER(LEFT($3, 4)), UPPER(LEFT($3, 6))
]::text[]))`;
async function claim(pool: pg.Pool, maxAttempts: number): Promise<QueueRow | null> {
const result = await pool.query<QueueRow>(`
UPDATE privacy_rematerialization_queue
SET status = 'processing', started_at = NOW(), attempts = attempts + 1,
finished_at = NULL, last_error = NULL
WHERE id = (
SELECT id FROM privacy_rematerialization_queue
WHERE status = 'pending'
OR (status = 'failed' AND attempts < $1
AND finished_at < NOW() - (attempts * attempts || ' minutes')::interval)
ORDER BY requested_at LIMIT 1 FOR UPDATE SKIP LOCKED
)
RETURNING id::text, node_id, network, target, attempts, requested_at, started_at
`, [maxAttempts]);
return result.rows[0] ?? null;
}
async function processChunk(pool: pg.Pool, row: QueueRow, chunk: Chunk, timeoutMs: number): Promise<void> {
for (let attempt = 1; attempt <= 3; attempt += 1) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SET LOCAL lock_timeout = '5s'");
await client.query(`SET LOCAL statement_timeout = '${timeoutMs}ms'`);
await client.query('SET LOCAL timescaledb.max_tuples_decompressed_per_dml_transaction = 0');
await client.query(row.target === 'packets' ? PACKET_UPDATE_SQL : PATH_UPDATE_SQL, [
chunk.range_start, chunk.range_end, row.node_id, row.network,
]);
await client.query('COMMIT');
console.log(`[privacy-remat] chunk complete id=${row.id} target=${row.target} start=${new Date(chunk.range_start).toISOString()}`);
observeWorkerOutcome('privacy_remat', 'chunk', 'success');
return;
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
if (attempt === 3) throw error;
observeWorkerOutcome('privacy_remat', 'chunk', 'retry');
await delay(attempt * 1_000);
} finally {
client.release();
}
}
}
async function complete(pool: pg.Pool, row: QueueRow): Promise<void> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const completion = await client.query<{ status: string }>(`
UPDATE privacy_rematerialization_queue
SET status = CASE WHEN requested_at <= started_at THEN 'done' ELSE 'pending' END,
finished_at = CASE WHEN requested_at <= started_at THEN NOW() ELSE NULL END,
last_error = NULL
WHERE id = $1 AND status = 'processing'
RETURNING status
`, [row.id]);
if (completion.rowCount !== 1) throw new Error('PRIVACY_REMAT_CLAIM_LOST');
// A re-request that arrived during this pass still needs a complete pass;
// only a fully completed request may claim a new materialized generation.
if (completion.rows[0]?.status === 'done') await client.query(`
WITH next_generation AS (
UPDATE public_visibility_state
SET generation = generation + 1,
updated_at = NOW()
WHERE singleton = TRUE
RETURNING generation
)
INSERT INTO packet_visibility_materialization_state
(singleton, visibility_generation, updated_at)
SELECT TRUE, generation, NOW() FROM next_generation
ON CONFLICT (singleton) DO UPDATE SET
visibility_generation = EXCLUDED.visibility_generation,
updated_at = EXCLUDED.updated_at
`);
await client.query('COMMIT');
console.log(`[privacy-remat] pass complete id=${row.id} status=${completion.rows[0]?.status}`);
observeWorkerOutcome('privacy_remat', 'pass', 'success');
} catch (error) {
await client.query('ROLLBACK').catch(() => {});
throw error;
} finally {
client.release();
}
}
export async function processNextPrivacyRematerialization(
pool: pg.Pool,
options: { maxAttempts?: number; chunkPauseMs?: number; chunkStatementTimeoutMs?: number } = {},
): Promise<boolean> {
const row = await claim(pool, options.maxAttempts ?? MAX_ATTEMPTS);
if (!row) return false;
console.log(`[privacy-remat] pass start id=${row.id} target=${row.target} attempt=${row.attempts}`);
try {
const chunks = await pool.query<Chunk>(`
SELECT range_start, range_end
FROM timescaledb_information.chunks
WHERE hypertable_schema = 'public' AND hypertable_name = $1
ORDER BY range_start
`, [row.target]);
for (const [index, chunk] of chunks.rows.entries()) {
await processChunk(pool, row, chunk, options.chunkStatementTimeoutMs ?? CHUNK_STATEMENT_TIMEOUT_MS);
if (index < chunks.rows.length - 1) await delay(options.chunkPauseMs ?? CHUNK_PAUSE_MS);
}
await complete(pool, row);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
await pool.query(`
UPDATE privacy_rematerialization_queue
SET status = 'failed', last_error = $2, finished_at = NOW()
WHERE id = $1 AND status = 'processing'
`, [row.id, message.slice(0, 2_000)]);
observeWorkerOutcome('privacy_remat', 'pass', 'failure');
console.error(`[privacy-remat] pass failed id=${row.id} target=${row.target}: ${message}`);
}
return true;
}
async function main(): Promise<void> {
startWorkerMetrics();
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
application_name: process.env['DATABASE_APPLICATION_NAME'] ?? 'meshcore-privacy-remat',
max: 2,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 30_000,
});
pool.on('error', (error) => console.error('[privacy-remat] pool error:', error.message));
const shutdown = new AbortController();
process.once('SIGTERM', () => shutdown.abort());
process.once('SIGINT', () => shutdown.abort());
try {
while (!shutdown.signal.aborted) {
try {
await processNextPrivacyRematerialization(pool);
} catch (error) {
console.error('[privacy-remat] iteration failed:', error);
}
if (!shutdown.signal.aborted) {
try { await delay(INTERVAL_MS, undefined, { signal: shutdown.signal }); }
catch (error) {
if (!shutdown.signal.aborted) throw error;
}
}
}
} finally {
await pool.end();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
console.error('[privacy-remat] fatal startup error:', error);
process.exitCode = 1;
});
}
+27
View File
@@ -257,6 +257,7 @@ services:
DATABASE_SKIP_SCHEMA_INIT: "true"
STALE_MQTT_OBSERVER_CLEANUP_DAYS: ${STALE_MQTT_OBSERVER_CLEANUP_DAYS:-30}
STALE_MQTT_OBSERVER_CLEANUP_INTERVAL_MS: ${STALE_MQTT_OBSERVER_CLEANUP_INTERVAL_MS:-21600000}
PRIVACY_REMAT_MAX_ATTEMPTS: ${PRIVACY_REMAT_MAX_ATTEMPTS:-8}
HEALTH_VOLUME_PATHS: database=/health-volumes/database=${HEALTH_DATABASE_BUDGET_BYTES:-322122547200},redis=/health-volumes/redis=${HEALTH_REDIS_BUDGET_BYTES:-4294967296},terrain=/health-volumes/terrain=${HEALTH_TERRAIN_BUDGET_BYTES:-53687091200},logs=/health-volumes/logs=${HEALTH_LOG_BUDGET_BYTES:-21474836480}
DATA_LIFECYCLE_RETENTION_ENABLED: "true"
DATA_LIFECYCLE_RETENTION_TARGETS: ${DATA_LIFECYCLE_RETENTION_TARGETS:-packets,worker_health_snapshots,frontend_error_events,owner_alert_deliveries,operational_check_results,observer_region_packet_sightings,observer_region_observer_sightings,link_job_commits,ml_model_variant_packet_results,packet_decryptions,observer_registration_requests,operator_audit_events}
@@ -270,6 +271,32 @@ services:
- srtm_data:/health-volumes/terrain:ro
- loki_data:/health-volumes/logs:ro
privacy-remat-worker:
<<: [*internal-hosts, *application-hardening]
image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local}
build:
context: .
dockerfile: Dockerfile.backend
args:
SOURCE_REVISION: ${SOURCE_REVISION:-local}
restart: always
logging: *json-log-limits
mem_limit: "512m"
memswap_limit: "512m"
command: ["node", "dist/workers/privacy-rematerialization.js"]
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-meshcore}:${POSTGRES_PASSWORD}@timescaledb:5432/${POSTGRES_DB:-meshcore}
DATABASE_APPLICATION_NAME: meshcore-privacy-remat
DATABASE_SKIP_SCHEMA_INIT: "true"
DATABASE_STATEMENT_TIMEOUT_MS: 900000
PRIVACY_REMAT_INTERVAL_MS: ${PRIVACY_REMAT_INTERVAL_MS:-120000}
PRIVACY_REMAT_MAX_ATTEMPTS: ${PRIVACY_REMAT_MAX_ATTEMPTS:-8}
PRIVACY_REMAT_CHUNK_PAUSE_MS: ${PRIVACY_REMAT_CHUNK_PAUSE_MS:-500}
NODE_ENV: production
depends_on:
db-migrate:
condition: service_completed_successfully
synthetic-monitor:
<<: [*internal-hosts, *application-hardening]
image: ${BACKEND_IMAGE:-meshcore-analytics-backend:local}
+1
View File
@@ -68,6 +68,7 @@ declare -A image_variables=(
[db-migrate]=BACKEND_IMAGE
[path-learning-worker]=BACKEND_IMAGE
[health-worker]=BACKEND_IMAGE
[privacy-remat-worker]=BACKEND_IMAGE
[synthetic-monitor]=BACKEND_IMAGE
[link-backfill-worker]=BACKEND_IMAGE
[alert-receiver]=BACKEND_IMAGE