1import { loadDotenv } from './lib/env.ts';2loadDotenv();3process.env.RI_SERVICE ??= 'rareindex-worker';45import { env, logger } from '@rareindex/shared';6import { createQueue, JOBS } from './lib/queue.ts';7import { closeDb, db } from './lib/db.ts';8import { flushCosts } from './lib/costs.ts';9import { runCrawl } from './crawler/run.ts';10import { scheduleDueCrawls } from './crawler/scheduler.ts';11import { normalizeBatch } from './normalizer/index.ts';12import { sql } from 'drizzle-orm';13import { pendingCount, resolveBatch } from './entity-resolution/index.ts';14import { assetsNeedingValuation, computePremiums, valueMany } from './valuation/run.ts';15import { runCategorySnapshots, runIndices, runRadar } from './indices/run.ts';16import { syncFx } from './fx.ts';17import { syncBenchmarks } from './benchmarks.ts';18import { computeHealth } from './health.ts';19import { expireListings } from './listings-expire.ts';2021/**22 * RareIndex worker process (PM2: rareindex-worker). One process runs the queue handlers and the23 * scheduler; scale out by starting more processes against the same database.24 */25export async function startWorker(): Promise<() => Promise<void>> {26 const log = logger.child({ component: 'worker' });27 const queue = createQueue();28 await queue.start();29 // Single-worker deployment: jobs left 'active' by a previous process (restart/crash) would wait for30 // their expiry (hours). Re-queue them so crawls resume immediately.31 await db().execute(sql`update pgboss.job set state = 'retry', started_on = null where state = 'active' and started_on < now() - interval '90 seconds'`);32 const concurrency = env().WORKER_CONCURRENCY;3334 // ---- handlers ----35 await queue.work<{ connectorId: string; mode?: 'incremental' | 'backfill' | 'probe'; limit?: number; trigger?: string }>(JOBS.crawlRun, { concurrency: Math.max(1, Math.floor(concurrency / 2)), pollingIntervalSeconds: 5 }, async (data) => {36 const res = await runCrawl(data.connectorId, { mode: data.mode ?? 'incremental', limit: data.limit, trigger: data.trigger ?? 'schedule' });37 if (res.recordsRaw > 0) await queue.send(JOBS.normalizeBatch, { connectorId: data.connectorId }, { singletonKey: `normalize:${data.connectorId}`, singletonSeconds: 30 });38 });39 await queue.work<{ connectorId?: string }>(JOBS.normalizeBatch, { concurrency: 2, pollingIntervalSeconds: 5 }, async (data) => {40 let total = 0;41 for (let i = 0; i < 40; i++) {42 const r = await normalizeBatch({ connectorId: data.connectorId, limit: 500 });43 total += r.processed;44 if (r.processed < 500) break;45 }46 if (total > 0) for (let k = 0; k < Number(process.env.RESOLVE_CONCURRENCY ?? 3); k++) await queue.send(JOBS.resolveBatch, {}, { singletonKey: `resolve:${k}`, singletonSeconds: 30 });47 });48 const RESOLVE_WORKERS = Number(process.env.RESOLVE_CONCURRENCY ?? 3);49 // Release rows claimed by a previous worker process that died mid-batch.50 await db().execute(sql`update normalized_records set status = 'pending' where status = 'processing'`);51 await queue.work<Record<string, never>>(JOBS.resolveBatch, { concurrency: RESOLVE_WORKERS, pollingIntervalSeconds: 5 }, async () => {52 const touched = new Set<string>();53 for (let i = 0; i < 40; i++) {54 const r = await resolveBatch({ limit: 500 });55 for (const a of r.touchedAssets) touched.add(a);56 if (r.processed < 500) break;57 }58 if ((await pendingCount()) > 0) for (let k = 0; k < RESOLVE_WORKERS; k++) await queue.send(JOBS.resolveBatch, {}, { singletonKey: `resolve:${k}`, singletonSeconds: 30, startAfterSeconds: 5 });59 if (touched.size) await queue.send(JOBS.valuationAsset, { assetIds: [...touched].slice(0, 5000) }, { singletonKey: `value:${Date.now()}` });60 });61 await queue.work<{ assetIds: string[] }>(JOBS.valuationAsset, { concurrency: 1, pollingIntervalSeconds: 5 }, async (data) => {62 await valueMany(data.assetIds, { concurrency: 4 });63 });64 await queue.work<{ all?: boolean; rebuildHistory?: boolean }>(JOBS.valuationRebuild, { concurrency: 1, pollingIntervalSeconds: 30 }, async (data) => {65 await computePremiums();66 const ids = await assetsNeedingValuation({ all: data.all, limit: 200_000 });67 await valueMany(ids, { rebuildHistory: data.rebuildHistory, concurrency: 4 });68 });69 await queue.work(JOBS.indicesDaily, { concurrency: 1, pollingIntervalSeconds: 30 }, async () => {70 await runIndices();71 });72 await queue.work(JOBS.snapshotsDaily, { concurrency: 1, pollingIntervalSeconds: 30 }, async () => {73 await runCategorySnapshots();74 });75 await queue.work(JOBS.radarScan, { concurrency: 1, pollingIntervalSeconds: 30 }, async () => {76 await runRadar();77 });78 await queue.work(JOBS.healthCompute, { concurrency: 1, pollingIntervalSeconds: 30 }, async () => {79 await computeHealth();80 });81 await queue.work<{ backfill?: boolean }>(JOBS.fxSync, { concurrency: 1, pollingIntervalSeconds: 30 }, async (data) => {82 await syncFx({ backfill: data.backfill });83 });84 await queue.work(JOBS.benchmarksSync, { concurrency: 1, pollingIntervalSeconds: 60 }, async () => {85 await syncBenchmarks();86 });87 await queue.work(JOBS.listingsExpire, { concurrency: 1, pollingIntervalSeconds: 60 }, async () => {88 await expireListings();89 });90 await queue.work(JOBS.accountJobs, { concurrency: 1, pollingIntervalSeconds: 60 }, async () => {91 try {92 const accountModule = './account/index.ts'; // provided by the account module when present93 const mod = (await import(accountModule)) as { runAccountJobs?: (d: ReturnType<typeof db>) => Promise<unknown> };94 if (mod.runAccountJobs) await mod.runAccountJobs(db());95 } catch (err) {96 if (!/Cannot find module|ERR_MODULE_NOT_FOUND/.test(String(err))) log.error({ err }, 'account jobs failed');97 }98 });99100 // ---- schedules (UTC) ----101 await queue.schedule(JOBS.fxSync, '15 16 * * 1-5', {}); // after ECB 16:00 CET publication102 await queue.schedule(JOBS.benchmarksSync, '30 22 * * *', {});103 await queue.schedule(JOBS.valuationRebuild, '0 3 * * *', { all: false });104 await queue.schedule(JOBS.snapshotsDaily, '20 4 * * *', {});105 await queue.schedule(JOBS.indicesDaily, '40 4 * * *', {});106 await queue.schedule(JOBS.radarScan, '0 5 * * *', {});107 await queue.schedule(JOBS.healthCompute, '*/30 * * * *', {});108 await queue.schedule(JOBS.listingsExpire, '10 * * * *', {});109 await queue.schedule(JOBS.accountJobs, '*/10 * * * *', {});110111 // ---- crawl scheduler loop ----112 const tick = async () => {113 try {114 await scheduleDueCrawls(queue);115 } catch (err) {116 log.error({ err }, 'scheduler tick failed');117 }118 };119 await tick();120 if ((await pendingCount()) > 0) for (let k = 0; k < RESOLVE_WORKERS; k++) await queue.send(JOBS.resolveBatch, {}, { singletonKey: `resolve:${k}`, singletonSeconds: 30 });121 const timer = setInterval(tick, 60_000);122 // hourly incremental valuation for assets with fresh evidence123 const hourly = setInterval(() => void queue.send(JOBS.valuationRebuild, { all: false }, { singletonKey: 'valuation:hourly', singletonSeconds: 3000 }), 3600_000);124 log.info({ concurrency }, 'worker started');125126 return async () => {127 clearInterval(timer);128 clearInterval(hourly);129 await queue.stop();130 await flushCosts();131 await closeDb();132 log.info('worker stopped');133 };134}135136const isMain = process.argv[1] && /workers\/main\.ts$/.test(process.argv[1]);137if (isMain) {138 startWorker()139 .then((stop) => {140 const shutdown = () => void stop().then(() => process.exit(0));141 process.on('SIGINT', shutdown);142 process.on('SIGTERM', shutdown);143 })144 .catch((err) => {145 logger.error({ err }, 'worker failed to start');146 process.exit(1);147 });148}149