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>
This commit is contained in:
@@ -87,7 +87,7 @@ describe('TenderSchedulerService — poll-once-fan-out-many (INGEST-06, Success
|
|||||||
|
|
||||||
// Platform boot: the scheduler initializes exactly once, independent of
|
// Platform boot: the scheduler initializes exactly once, independent of
|
||||||
// any tenant — this is the one and only cron-job registration.
|
// any tenant — this is the one and only cron-job registration.
|
||||||
await scheduler.onModuleInit();
|
await scheduler.onApplicationBootstrap();
|
||||||
expect(addCronJob).toHaveBeenCalledTimes(1);
|
expect(addCronJob).toHaveBeenCalledTimes(1);
|
||||||
expect(addCronJob.mock.calls[0][0]).toBe('tender-doe-poll');
|
expect(addCronJob.mock.calls[0][0]).toBe('tender-doe-poll');
|
||||||
|
|
||||||
@@ -139,4 +139,22 @@ describe('TenderSchedulerService — poll-once-fan-out-many (INGEST-06, Success
|
|||||||
expect(source).not.toMatch(/findFirst/);
|
expect(source).not.toMatch(/findFirst/);
|
||||||
expect(source).toMatch(/findUnique/);
|
expect(source).toMatch(/findUnique/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('registers the cron via onApplicationBootstrap (NOT onModuleInit) so the poll-config seed runs first on a fresh DB (#prod-bootstrap)', () => {
|
||||||
|
// Regression guard: TendersModule.onModuleInit seeds the doe-opendata
|
||||||
|
// poll config. onModuleInit hooks run in an unspecified order relative to
|
||||||
|
// one another, so on a FRESH database the scheduler must NOT read the
|
||||||
|
// config in its own onModuleInit — it would see the config absent and
|
||||||
|
// never register the single global cron that drives DÖE + RSS + email-alert
|
||||||
|
// ingestion (observed live on a fresh prod DB: 0 tenders, cron unregistered).
|
||||||
|
// onApplicationBootstrap runs after every module's onModuleInit → seed done.
|
||||||
|
const source = readFileSync(join(__dirname, 'tender-scheduler.service.ts'), 'utf8');
|
||||||
|
expect(source).toMatch(/implements OnApplicationBootstrap/);
|
||||||
|
expect(source).toMatch(/async onApplicationBootstrap\(/);
|
||||||
|
expect(source).not.toMatch(/async onModuleInit\(/);
|
||||||
|
// The lifecycle method actually exists on the instance.
|
||||||
|
expect(typeof TenderSchedulerService.prototype.onApplicationBootstrap).toBe(
|
||||||
|
'function',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
import { SchedulerRegistry } from '@nestjs/schedule';
|
import { SchedulerRegistry } from '@nestjs/schedule';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenderIngestionService } from './tender-ingestion.service';
|
import { TenderIngestionService } from './tender-ingestion.service';
|
||||||
@@ -27,11 +27,20 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
|
|||||||
* - `setInterval()` takes NO tenant argument — there is exactly one cron
|
* - `setInterval()` takes NO tenant argument — there is exactly one cron
|
||||||
* job (`tender-doe-poll`) for the whole platform, regardless of how many
|
* job (`tender-doe-poll`) for the whole platform, regardless of how many
|
||||||
* tenants activate the `tender-radar` module.
|
* tenants activate the `tender-radar` module.
|
||||||
* - `onModuleInit()` loads the singleton config via `findUnique` on the
|
* - `onApplicationBootstrap()` loads the singleton config via `findUnique`
|
||||||
* fixed `sourceType: 'doe-opendata'` slug — a fixed-slug lookup, not an
|
* on the fixed `sourceType: 'doe-opendata'` slug — a fixed-slug lookup,
|
||||||
* unfiltered/ordered "first match" query — to make the "one config, no
|
* not an unfiltered/ordered "first match" query — to make the "one config,
|
||||||
* tenant iteration" intent explicit and self-documenting against future
|
* no tenant iteration" intent explicit and self-documenting against future
|
||||||
* copy-paste into a per-tenant source.
|
* 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
|
* - The day-cursor gate (whether an HTTP call actually happens) is NOT
|
||||||
* here — it lives inside `TenderIngestionService.pollDueSources()`. This
|
* here — it lives inside `TenderIngestionService.pollDueSources()`. This
|
||||||
* scheduler only controls cron-tick frequency (Pitfall A separation).
|
* scheduler only controls cron-tick frequency (Pitfall A separation).
|
||||||
@@ -42,7 +51,7 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi
|
|||||||
* additional cron jobs, and zero additional Tender rows.
|
* additional cron jobs, and zero additional Tender rows.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenderSchedulerService implements OnModuleInit {
|
export class TenderSchedulerService implements OnApplicationBootstrap {
|
||||||
private readonly logger = new Logger(TenderSchedulerService.name);
|
private readonly logger = new Logger(TenderSchedulerService.name);
|
||||||
|
|
||||||
/** Name of the single, platform-global managed cron job. */
|
/** Name of the single, platform-global managed cron job. */
|
||||||
@@ -55,12 +64,13 @@ export class TenderSchedulerService implements OnModuleInit {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* On application startup: load the singleton doe-opendata poll config and
|
* On application startup (AFTER all module `onModuleInit` seeding has run —
|
||||||
* register the single global cron job if active. Errors are caught and
|
* see class doc): load the singleton doe-opendata poll config and register
|
||||||
* logged (never re-thrown) so a missing/broken config does not prevent
|
* the single global cron job if active. Errors are caught and logged (never
|
||||||
* the rest of the application from starting.
|
* re-thrown) so a missing/broken config does not prevent the rest of the
|
||||||
|
* application from starting.
|
||||||
*/
|
*/
|
||||||
async onModuleInit(): Promise<void> {
|
async onApplicationBootstrap(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const config = await this.prisma.tenderSourcePollConfig.findUnique({
|
const config = await this.prisma.tenderSourcePollConfig.findUnique({
|
||||||
where: { sourceType: 'doe-opendata' },
|
where: { sourceType: 'doe-opendata' },
|
||||||
|
|||||||
Reference in New Issue
Block a user