- config: bloque matcher (MATCHER_MODE off/shadow/full, IRON_PASSWORD, audiencia, poll, batch) + validacion - db: colecciones subscriptions/activefires/notifications_shadow/matcher_state + ensureMatcherIndexes (2dsphere en notifications_shadow para el dedupe 500m) - matcher/context: MatchContext sobre Mongo real (candidatas geo $near 1000km + audiencia; dedupe geo $near 500m; lang del owner) - matcher/ingest: polling de activefires por createdAt>checkpoint (Mongo 3.2 sin change streams), checkpoint persistido en matcher_state - matcher/runner: loop shadow(->notifications_shadow)/full(->notifications), idempotente por dedupe+checkpoint - matcher/compare: comparador de shadow (shadowOnly=spam, realOnly=faltantes, contentMismatches) — el gate antes del cutover - index: arranca el matcher; fake-repo ampliado (upsert, sort, $gt/$lt) - docs/env/README actualizados 73 tests en verde, typecheck y build OK.
77 lines
2.7 KiB
TypeScript
77 lines
2.7 KiB
TypeScript
import type { Config } from '../config.js';
|
|
import type { Repos } from '../db.js';
|
|
import type { Logger } from '../logger.js';
|
|
import { createMatchContext } from './context.js';
|
|
import { advanceCheckpoint, newestCreatedAt, pollNewFires } from './ingest.js';
|
|
import { matchFire, type MatchContext } from './matcher.js';
|
|
|
|
export interface MatcherRunner {
|
|
runOnce(): Promise<{ fires: number; generated: number }>;
|
|
start(): void;
|
|
stop(): void;
|
|
}
|
|
|
|
/**
|
|
* Fase 1b matcher loop: poll new fires, match each against subscriptions, and
|
|
* write the resulting notification docs — to `notifications_shadow` (shadow) or
|
|
* `notifications` (full). Idempotent across restarts: docs are written before
|
|
* the checkpoint advances, and the 500 m dedupe (against the target collection)
|
|
* means reprocessing the same fires produces no duplicates.
|
|
*/
|
|
export function createMatcherRunner(
|
|
cfg: Config,
|
|
repos: Repos,
|
|
log: Logger,
|
|
ctx: MatchContext = createMatchContext(cfg, repos),
|
|
): MatcherRunner {
|
|
let timer: NodeJS.Timeout | null = null;
|
|
let running = false;
|
|
const target = cfg.matcher.mode === 'shadow' ? repos.notificationsShadow : repos.notifications;
|
|
|
|
async function runOnce(): Promise<{ fires: number; generated: number }> {
|
|
if (cfg.matcher.mode === 'off' || running) return { fires: 0, generated: 0 };
|
|
running = true;
|
|
try {
|
|
const fires = await pollNewFires(cfg, repos);
|
|
if (fires.length === 0) return { fires: 0, generated: 0 };
|
|
|
|
let generated = 0;
|
|
for (const fire of fires) {
|
|
const notifs = await matchFire(fire, ctx);
|
|
if (notifs.length > 0) {
|
|
await target.insertMany(notifs as never[]);
|
|
generated += notifs.length;
|
|
}
|
|
}
|
|
|
|
const checkpoint = newestCreatedAt(fires);
|
|
if (checkpoint) await advanceCheckpoint(repos, checkpoint);
|
|
|
|
log.info({ fires: fires.length, generated, mode: cfg.matcher.mode, target: target.collectionName }, 'matcher: cycle');
|
|
return { fires: fires.length, generated };
|
|
} finally {
|
|
running = false;
|
|
}
|
|
}
|
|
|
|
function start(): void {
|
|
if (cfg.matcher.mode === 'off') {
|
|
log.info('matcher: disabled (MATCHER_MODE=off)');
|
|
return;
|
|
}
|
|
log.info({ mode: cfg.matcher.mode, intervalMs: cfg.matcher.pollIntervalMs, audience: cfg.matcher.audienceTypes }, 'matcher: starting');
|
|
timer = setInterval(() => {
|
|
void runOnce().catch((err) => log.error({ err: String(err) }, 'matcher: cycle error'));
|
|
}, cfg.matcher.pollIntervalMs);
|
|
void runOnce().catch((err) => log.error({ err: String(err) }, 'matcher: initial cycle error'));
|
|
}
|
|
|
|
function stop(): void {
|
|
if (timer) {
|
|
clearInterval(timer);
|
|
timer = null;
|
|
}
|
|
}
|
|
|
|
return { runOnce, start, stop };
|
|
}
|