Skip to content

Commit f4240f9

Browse files
beacon: HOSES switch + gate legacy 3/10002 fallback
Make the firehose a switch so a deploy can run a single hose without double-writing against the legacy firehose/followshose processes during the phased migration. - planIngest(allHoses, env): pure, testable selector. HOSES env (comma list of hose names; default: all registered) picks active hoses; INDEX_LEGACY_KINDS (default on) gates the raw-upsert fallback for kinds not yet migrated to a hose (3, 10002). Subscription kinds = union of enabled hoses' kinds + legacy kinds, with a hose-owned kind removed from the legacy set so nothing is double-subscribed. - runIndexer: use the plan; warn on unknown hose names / empty kind set. - .env.example: document HOSES + INDEX_LEGACY_KINDS. - test/indexer-plan.test.js: 8 cases (defaults, legacy off, unknown names, single-hose deploy, hose-owned legacy kind removal). Default behaviour unchanged: profiles hose + legacy 3/10002.
1 parent 759ca05 commit f4240f9

3 files changed

Lines changed: 108 additions & 13 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,11 @@ MONGO_RELAY_DIRECTORY_COLLECTION=relays
1010

1111
RELAYS=wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net
1212
PORT=3000
13+
14+
# Firehose switch: which hoses run (comma list of names; default: all).
15+
# e.g. HOSES=profiles to run only the profiles hose during a phased rollout.
16+
# HOSES=profiles
17+
# Legacy raw-upsert path for kinds not yet migrated to a hose (3=follows,
18+
# 10002=relay lists). On by default; set to 0 to run a single hose without
19+
# writing collections still owned by the legacy firehose processes.
20+
# INDEX_LEGACY_KINDS=1

src/indexer.js

Lines changed: 42 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,52 @@
22
// Nostr is a simple protocol over WS: send ["REQ", subId, filter], receive
33
// ["EVENT", subId, event]. Node 24 has a built-in global WebSocket.
44
//
5-
// Subscribes to kind 0 (profile), 3 (follows / social graph), 10002 (relay
6-
// list) and upserts each raw event into Mongo.
5+
// Ingestion is composed of "hoses" (see src/hoses/), each owning a set of
6+
// event kinds. Which hoses run is a switch — the `HOSES` env (comma list of
7+
// names; default: all registered) — so a deploy can run a single hose without
8+
// double-writing against the legacy firehose processes during the migration.
79
import { upsertEvent, connect } from './db.js';
810
import profilesHose from './hoses/profiles.js';
911
import 'dotenv/config';
1012

1113
const RELAYS = (process.env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net')
1214
.split(',').map((s) => s.trim()).filter(Boolean);
1315

14-
// Registered hoses (phase 1: profiles only). Each owns its kinds + ingest.
15-
// Kinds with no hose fall back to the legacy raw upsert until their own phase.
16-
const HOSES = [profilesHose];
17-
const hoseFor = (kind) => HOSES.find((h) => h.kinds.includes(kind));
18-
const KINDS = [...new Set([...HOSES.flatMap((h) => h.kinds), 3, 10002])];
16+
// All hoses that exist (grows each phase). `planIngest` selects which run.
17+
const ALL_HOSES = [profilesHose];
18+
19+
// Kinds not yet migrated to a hose: 3 (follows), 10002 (relay lists). They use
20+
// the legacy raw-upsert path, on by default for local parity. Set
21+
// INDEX_LEGACY_KINDS=0 to turn it off so a single-hose deploy never writes
22+
// collections still owned by the legacy firehose/followshose processes.
23+
const LEGACY_KINDS = [3, 10002];
24+
25+
/**
26+
* Resolve the ingest plan from the registered hoses + env switches. Pure (env
27+
* passed in) so it's unit-testable. Returns the active hoses, a kind→hose
28+
* lookup, the union of kinds to subscribe to, and the legacy-fallback state.
29+
*/
30+
export function planIngest(allHoses = ALL_HOSES, env = process.env) {
31+
const want = (env.HOSES || allHoses.map((h) => h.name).join(','))
32+
.split(',').map((s) => s.trim()).filter(Boolean);
33+
const hoses = allHoses.filter((h) => want.includes(h.name));
34+
const unknown = want.filter((n) => !allHoses.some((h) => h.name === n));
35+
const legacy = env.INDEX_LEGACY_KINDS !== '0' && env.INDEX_LEGACY_KINDS !== 'false';
36+
const hoseFor = (kind) => hoses.find((h) => h.kinds.includes(kind));
37+
const legacyKinds = legacy ? LEGACY_KINDS.filter((k) => !hoseFor(k)) : [];
38+
const kinds = [...new Set([...hoses.flatMap((h) => h.kinds), ...legacyKinds])];
39+
return { hoses, hoseFor, kinds, legacy, legacyKinds, unknown };
40+
}
41+
1942
const SUB_ID = 'beacon';
2043
const RECONNECT_MS = 3000;
2144

22-
function connectRelay(url, onEvent) {
45+
function connectRelay(url, kinds, onEvent) {
2346
let ws, closed = false, timer;
2447
const open = () => {
2548
if (closed) return;
2649
ws = new WebSocket(url);
27-
ws.addEventListener('open', () => ws.send(JSON.stringify(['REQ', SUB_ID, { kinds: KINDS }])));
50+
ws.addEventListener('open', () => ws.send(JSON.stringify(['REQ', SUB_ID, { kinds }])));
2851
ws.addEventListener('message', (m) => {
2952
try {
3053
const msg = JSON.parse(typeof m.data === 'string' ? m.data : m.data.toString());
@@ -40,18 +63,24 @@ function connectRelay(url, onEvent) {
4063

4164
export async function runIndexer() {
4265
const db = await connect();
43-
for (const h of HOSES) await h.ensureIndexes(db);
44-
console.log(`[beacon] indexing kinds ${KINDS.join(',')} from ${RELAYS.length} relays`);
66+
const { hoses, hoseFor, kinds, legacy, unknown } = planIngest();
67+
if (unknown.length) console.warn(`[beacon] unknown hose(s) in HOSES, ignored: ${unknown.join(', ')}`);
68+
if (!kinds.length) { console.warn('[beacon] no hoses enabled and no legacy kinds — nothing to index'); return { stop() {} }; }
69+
for (const h of hoses) await h.ensureIndexes(db);
70+
console.log(`[beacon] hoses: [${hoses.map((h) => h.name).join(', ') || 'none'}] legacy kinds: ${legacy ? 'on' : 'off'} subscribing kinds ${kinds.join(',')} from ${RELAYS.length} relays`);
4571
const onEvent = async (event) => {
4672
try {
4773
const hose = hoseFor(event.kind);
48-
const stored = hose ? await hose.ingest(event, db) : await upsertEvent(event);
74+
let stored;
75+
if (hose) stored = await hose.ingest(event, db);
76+
else if (legacy) stored = await upsertEvent(event);
77+
else return; // not an enabled kind
4978
if (stored) console.log(`[beacon] kind ${event.kind} ${String(event.pubkey).slice(0, 12)}…`);
5079
} catch (e) {
5180
console.error('[beacon] ingest error:', e.message);
5281
}
5382
};
54-
const stoppers = RELAYS.map((url) => connectRelay(url, onEvent));
83+
const stoppers = RELAYS.map((url) => connectRelay(url, kinds, onEvent));
5584
const stop = () => stoppers.forEach((s) => s());
5685
process.on('SIGINT', () => { stop(); process.exit(0); });
5786
process.on('SIGTERM', () => { stop(); process.exit(0); });

test/indexer-plan.test.js

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// The HOSES / INDEX_LEGACY_KINDS switch: planIngest selects which hoses run
2+
// and which kinds get subscribed. Pure function — test with fake hoses.
3+
import test from 'node:test';
4+
import assert from 'node:assert/strict';
5+
import { planIngest } from '../src/indexer.js';
6+
7+
const profiles = { name: 'profiles', kinds: [0] };
8+
const follows = { name: 'follows', kinds: [3] };
9+
const ONE = [profiles]; // mirrors today's registry
10+
const TWO = [profiles, follows]; // a future phase
11+
12+
const sorted = (a) => [...a].sort((x, y) => x - y);
13+
14+
test('default (no env): all registered hoses on + legacy kinds', () => {
15+
const p = planIngest(ONE, {});
16+
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles']);
17+
assert.equal(p.legacy, true);
18+
assert.deepEqual(sorted(p.kinds), [0, 3, 10002]);
19+
assert.deepEqual(p.unknown, []);
20+
});
21+
22+
test('empty HOSES string falls back to the default (all on)', () => {
23+
assert.deepEqual(sorted(planIngest(ONE, { HOSES: '' }).kinds), [0, 3, 10002]);
24+
});
25+
26+
test('INDEX_LEGACY_KINDS=0 → only enabled hoses\' kinds, no legacy', () => {
27+
const p = planIngest(ONE, { HOSES: 'profiles', INDEX_LEGACY_KINDS: '0' });
28+
assert.equal(p.legacy, false);
29+
assert.deepEqual(p.kinds, [0]);
30+
});
31+
32+
test('INDEX_LEGACY_KINDS=false is also off', () => {
33+
assert.equal(planIngest(ONE, { INDEX_LEGACY_KINDS: 'false' }).legacy, false);
34+
});
35+
36+
test('unknown hose names are reported and ignored', () => {
37+
const p = planIngest(ONE, { HOSES: 'profiles,nope' });
38+
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles']);
39+
assert.deepEqual(p.unknown, ['nope']);
40+
});
41+
42+
test('selecting only an unknown hose leaves no hose kinds (legacy still applies)', () => {
43+
const p = planIngest(ONE, { HOSES: 'nope' });
44+
assert.deepEqual(p.hoses, []);
45+
assert.deepEqual(sorted(p.kinds), [3, 10002]); // legacy fallback only
46+
});
47+
48+
test('a hose that owns a legacy kind removes it from the legacy set (no double-subscribe)', () => {
49+
const p = planIngest(TWO, {}); // follows owns kind 3
50+
assert.deepEqual(p.hoses.map((h) => h.name), ['profiles', 'follows']);
51+
assert.deepEqual(p.legacyKinds, [10002]); // 3 now owned by the follows hose
52+
assert.deepEqual(sorted(p.kinds), [0, 3, 10002]);
53+
});
54+
55+
test('single-hose deploy: HOSES=follows + no legacy → just kind 3', () => {
56+
const p = planIngest(TWO, { HOSES: 'follows', INDEX_LEGACY_KINDS: '0' });
57+
assert.deepEqual(p.kinds, [3]);
58+
});

0 commit comments

Comments
 (0)