From e1358d70fdc62c997b3766f1b387a944efc3a861 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 15:30:04 +0200 Subject: [PATCH] fix(tenders): register poll cron in onApplicationBootstrap (fresh-DB bootstrap) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../tenders/tender-scheduler.service.spec.ts | 20 +++++++++++- .../src/tenders/tender-scheduler.service.ts | 32 ++++++++++++------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/api/src/tenders/tender-scheduler.service.spec.ts b/apps/api/src/tenders/tender-scheduler.service.spec.ts index f534148..965711f 100644 --- a/apps/api/src/tenders/tender-scheduler.service.spec.ts +++ b/apps/api/src/tenders/tender-scheduler.service.spec.ts @@ -87,7 +87,7 @@ describe('TenderSchedulerService — poll-once-fan-out-many (INGEST-06, Success // Platform boot: the scheduler initializes exactly once, independent of // any tenant — this is the one and only cron-job registration. - await scheduler.onModuleInit(); + await scheduler.onApplicationBootstrap(); expect(addCronJob).toHaveBeenCalledTimes(1); 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).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', + ); + }); }); diff --git a/apps/api/src/tenders/tender-scheduler.service.ts b/apps/api/src/tenders/tender-scheduler.service.ts index aff2e2a..e674168 100644 --- a/apps/api/src/tenders/tender-scheduler.service.ts +++ b/apps/api/src/tenders/tender-scheduler.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { SchedulerRegistry } from '@nestjs/schedule'; import { PrismaService } from '../prisma/prisma.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 * job (`tender-doe-poll`) for the whole platform, regardless of how many * tenants activate the `tender-radar` module. - * - `onModuleInit()` 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 + * - `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). @@ -42,7 +51,7 @@ const CronJobClass: new (cronTime: string, onTick: () => void) => { start(): voi * additional cron jobs, and zero additional Tender rows. */ @Injectable() -export class TenderSchedulerService implements OnModuleInit { +export class TenderSchedulerService implements OnApplicationBootstrap { private readonly logger = new Logger(TenderSchedulerService.name); /** 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 - * 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. + * 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 onModuleInit(): Promise { + async onApplicationBootstrap(): Promise { try { const config = await this.prisma.tenderSourcePollConfig.findUnique({ where: { sourceType: 'doe-opendata' },