Files
tessera-ctl/apps/api/src/tenders/tender-scheduler.service.ts
T
schalli e1358d70fd
Tessera CI/CD / Lint & Type Check (push) Successful in 48s
Tessera CI/CD / Tests (push) Successful in 48s
Tessera CI/CD / Build & Publish Images (push) Successful in 26s
fix(tenders): register poll cron in onApplicationBootstrap (fresh-DB bootstrap)
On a fresh database the DÖE poll cron was never registered: TenderScheduler
read the doe-opendata poll config in its onModuleInit, which raced ahead of
TendersModule.onModuleInit seeding that config. The scheduler saw the config
absent → skipped registering the single global cron that drives pollDueSources
(DÖE + RSS + email-alert) → the platform ingested NOTHING until a second restart.
Observed live on a fresh prod DB (0 tenders, 'doe-opendata config inactive —
cron job not registered', lastIngestedDay null despite isActive=true).

Move the scheduler to onApplicationBootstrap, which runs after every module's
onModuleInit, so the seed is guaranteed complete before the config is read.
Adds a regression test asserting the lifecycle choice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:30:04 +02:00

157 lines
6.9 KiB
TypeScript

import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { TenderIngestionService } from './tender-ingestion.service';
/**
* CronJob constructor — resolved at runtime via require() because `cron` is a
* transitive dependency of @nestjs/schedule (not a direct api dep under pnpm
* strict isolation, so `import { CronJob } from 'cron'` fails type-check).
* At runtime, cron IS on disk as @nestjs/schedule@6 declares it as a peer
* dep. Reuses the exact DkvSchedulerService resolution workaround verbatim.
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports
const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): void } =
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
require('cron').CronJob as new (cronTime: string, onTick: () => void) => { start(): void };
/**
* TenderSchedulerService — a SINGLE global cron job driving the shared DÖE
* poll (INGEST-06, D-04). This is the poll-once-fan-out-many heart of the
* phase: unlike `DkvSchedulerService`, there is NO tenant dimension at all.
*
* Deliberate deviations from the DKV scheduler this reuses mechanics from
* (RESEARCH.md Pitfall D / PATTERNS.md ANTI-PATTERN note):
* - No per-tenant "active tenant id" instance field at all — DÖE config is
* a genuine platform-wide singleton, not a per-tenant config to track.
* - `setInterval()` takes NO tenant argument — there is exactly one cron
* job (`tender-doe-poll`) for the whole platform, regardless of how many
* tenants activate the `tender-radar` module.
* - `onApplicationBootstrap()` loads the singleton config via `findUnique`
* on the fixed `sourceType: 'doe-opendata'` slug — a fixed-slug lookup,
* not an unfiltered/ordered "first match" query — to make the "one config,
* no tenant iteration" intent explicit and self-documenting against future
* copy-paste into a per-tenant source.
*
* Lifecycle = `onApplicationBootstrap`, NOT `onModuleInit` (deliberate, #prod-
* bootstrap): the poll config this reads is seeded by `TendersModule.onModuleInit`
* (`tenders.seed`). `onModuleInit` hooks run in an unspecified order relative to
* one another, so on a FRESH database the scheduler could read the config before
* it is seeded → see it absent/inactive → never register the single global cron
* that drives `pollDueSources()` (DÖE + RSS + email-alert) → the platform ingests
* NOTHING until a second restart. `onApplicationBootstrap` runs after EVERY
* module's `onModuleInit`, so the seed is guaranteed complete before this reads.
* - The day-cursor gate (whether an HTTP call actually happens) is NOT
* here — it lives inside `TenderIngestionService.pollDueSources()`. This
* scheduler only controls cron-tick frequency (Pitfall A separation).
*
* The two-tenant safety property (Success Criteria 4 & 5) is proven by
* `tender-scheduler.service.spec.ts` (Plan 04, Task 3): activating the
* module for a 2nd tenant must trigger zero additional DÖE calls, zero
* additional cron jobs, and zero additional Tender rows.
*/
@Injectable()
export class TenderSchedulerService implements OnApplicationBootstrap {
private readonly logger = new Logger(TenderSchedulerService.name);
/** Name of the single, platform-global managed cron job. */
private readonly JOB_NAME = 'tender-doe-poll';
constructor(
private readonly schedulerRegistry: SchedulerRegistry,
private readonly tenderIngestionService: TenderIngestionService,
private readonly prisma: PrismaService,
) {}
/**
* On application startup (AFTER all module `onModuleInit` seeding has run —
* see class doc): load the singleton doe-opendata poll config and register
* the single global cron job if active. Errors are caught and logged (never
* re-thrown) so a missing/broken config does not prevent the rest of the
* application from starting.
*/
async onApplicationBootstrap(): Promise<void> {
try {
const config = await this.prisma.tenderSourcePollConfig.findUnique({
where: { sourceType: 'doe-opendata' },
});
if (config?.isActive) {
this.setInterval(config.pollIntervalMin);
this.logger.log(
`Tender scheduler initialized: every ${config.pollIntervalMin} min (single global job — platform-wide, no tenant dimension)`,
);
} else {
this.logger.log(
'Tender scheduler: doe-opendata config inactive — cron job not registered',
);
}
} catch (err) {
this.logger.error(
`Tender scheduler init failed: ${(err as Error).message}`,
);
}
}
/**
* Create (or replace) the single global DÖE-poll cron job.
*
* Called on module init and by the (Plan 05) admin source-config route
* after the platform-wide config is updated, so the cron job reflects any
* admin change immediately — without a service restart.
*
* @param intervalMin - Cron-tick frequency in minutes (D-04 default 60).
* This is NOT the DÖE fetch frequency — the actual upstream HTTP call
* only happens when TenderIngestionService's day-cursor gate allows it
* (Pitfall A: cron tick frequency != actual-fetch frequency for this
* day-granular source).
*/
setInterval(intervalMin: number): void {
// Remove existing job if registered.
try {
this.schedulerRegistry.getCronJob(this.JOB_NAME).stop();
this.schedulerRegistry.deleteCronJob(this.JOB_NAME);
} catch {
/* Job not yet registered — this is expected on first call */
}
// Standard cron minute field only accepts 0-59; for longer intervals use the hours field.
const cronExpr =
intervalMin < 60
? `*/${intervalMin} * * * *`
: `0 */${Math.floor(intervalMin / 60)} * * *`;
const job = new CronJobClass(cronExpr, () => {
this.tenderIngestionService.pollDueSources().catch((err) =>
this.logger.error(
`Tender poll tick failed: ${(err as Error).message}`,
),
);
});
// Cast required: our minimal CronJob type doesn't match cron's full type signature.
// At runtime the object IS a full CronJob — SchedulerRegistry only calls stop() on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.schedulerRegistry.addCronJob(this.JOB_NAME, job as any);
job.start();
this.logger.log(
`Tender cron job registered: every ${intervalMin} minutes (single global job, no tenant parameter)`,
);
}
/**
* Stop and remove the DÖE-poll cron job.
* Called by the (Plan 05) admin route when isActive=false is saved.
*/
stopJob(): void {
try {
this.schedulerRegistry.getCronJob(this.JOB_NAME).stop();
this.schedulerRegistry.deleteCronJob(this.JOB_NAME);
this.logger.log('Tender cron job stopped and removed');
} catch {
/* Not registered — no-op */
}
}
}