SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%

engine: scheduler claims 4× slots and applies per-host overrides for large public APIs so shared hosts no longer leave workers idle

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 13 days ago (Sep 11, 2026) parent 8ab1001

2 changed files +23 −5

modified apps/engine/src/config.ts +12 −0
@@ -2,6 +2,16 @@ import pino from "pino";
2 2
3 3 const env = process.env;
4 4
5 +/** `WS_PER_HOST_OVERRIDES="github.com=8,data.sec.gov=6"` */
6 +function parseHostOverrides(v: string | undefined): Record<string, number> {
7 + const out: Record<string, number> = {};
8 + for (const part of (v ?? "").split(",")) {
9 + const [h, n] = part.split("=");
10 + if (h && n && Number(n) > 0) out[h.trim().toLowerCase()] = Number(n);
11 + }
12 + return out;
13 +}
14 +
5 15 export const config = {
6 16 env: env.NODE_ENV ?? "development",
7 17 logLevel: env.LOG_LEVEL ?? "info",
@@ -13,6 +23,8 @@ export const config = {
13 23 entitiesFile: env.WS_ENTITIES_FILE ?? "./config/entities.yaml",
14 24 fetchConcurrency: Number(env.WS_FETCH_CONCURRENCY ?? 16),
15 25 perHostConcurrency: Number(env.WS_PER_HOST_CONCURRENCY ?? 2),
26 + /** hosts that tolerate more parallel requests than the default (large public APIs) */
27 + perHostOverrides: { "github.com": 6, "api.github.com": 4, "data.sec.gov": 4, "huggingface.co": 4, "www.federalregister.gov": 4, "services.nvd.nist.gov": 1, "export.arxiv.org": 1, "rss.arxiv.org": 2, "api.crossref.org": 2, "api.openalex.org": 2, "clinicaltrials.gov": 3, "www.gov.uk": 4, "api.io.canada.ca": 4, "www.nature.com": 1, ...parseHostOverrides(env.WS_PER_HOST_OVERRIDES) } as Record<string, number>,
16 28 metricsPort: Number(env.ENGINE_METRICS_PORT ?? 8262),
17 29 metricsHost: env.ENGINE_METRICS_HOST ?? "127.0.0.1",
18 30 processingVersion: "event-pipeline-v2",
modified apps/engine/src/scheduler.ts +11 −5
@@ -26,7 +26,7 @@ export class Scheduler {
26 26 } catch (e) {
27 27 log.error({ err: (e as Error).message }, "scheduler tick failed");
28 28 }
29 this.timer = setTimeout(tick, this.inflight >= config.fetchConcurrency ? 500 : 1500);
29 + this.timer = setTimeout(tick, this.inflight >= config.fetchConcurrency ? 500 : 800);
30 30 };
31 31 void tick();
32 32 }
@@ -78,19 +78,25 @@ export class Scheduler {
78 78 }
79 79 const slots = config.fetchConcurrency - this.inflight;
80 80 if (slots <= 0) return;
81 // Priority-aware claim: critical sensors (priority 0) go first among what is due; within a priority the most overdue first.
81 + // Priority-aware claim: critical sensors (priority 0) go first among what is due; within a priority the most
82 + // overdue first. We claim more candidates than free slots because many sensors share a few big hosts
83 + // (github.com, data.sec.gov…) and the per-host limit would otherwise leave slots idle.
82 84 const claimed = await db.execute<Sensor>(sql`
83 85 update sensors set next_check_at = now() + interval '10 minutes'
84 where id in (select id from sensors where enabled and next_check_at <= now() order by priority asc, next_check_at asc limit ${slots} for update skip locked)
86 + where id in (select id from sensors where enabled and next_check_at <= now() order by priority asc, next_check_at asc limit ${slots * 4} for update skip locked)
85 87 returning *`);
88 + let started = 0;
86 89 for (const row of claimed.rows) {
87 90 const sensor = normalizeRow(row as unknown as Record<string, unknown>);
88 91 const host = safeHost(sensor.url);
89 const hostBusy = (this.perHost.get(host) ?? 0) >= config.perHostConcurrency;
92 + const hostLimit = config.perHostOverrides[host] ?? config.perHostConcurrency;
93 + const hostBusy = started >= slots || (this.perHost.get(host) ?? 0) >= hostLimit;
90 94 if (hostBusy) {
91 await db.update(sensors).set({ nextCheckAt: new Date(Date.now() + 5000) }).where(sql`id = ${sensor.id}`);
95 + // give back the lease quickly (5–20 s, jittered so a big host drains smoothly)
96 + await db.update(sensors).set({ nextCheckAt: new Date(Date.now() + 5000 + Math.random() * 15000) }).where(sql`id = ${sensor.id}`);
92 97 continue;
93 98 }
99 + started++;
94 100 const trip = this.hostFailures.get(host);
95 101 if (trip && trip.until > Date.now()) {
96 102 await db.update(sensors).set({ nextCheckAt: new Date(trip.until + Math.random() * 30_000) }).where(sql`id = ${sensor.id}`);
97 103