fix(drug-reference): fold dataset freshness into the content-auto-update path

The drug dataset auto-updated on its own daily cron that ignored the
contentAutoUpdate.* master switch, so it would refresh even with content
auto-update turned off, and it didn't ride the content-update path the way
ZIMs and maps do.

Move the export_date freshness/apply orchestration onto
DrugReferenceService.attemptAutoUpdate(), add
ContentAutoUpdateService.attemptDrugDataset() gated on the same enabled +
window config, and have the hourly ContentAutoUpdateJob drive both. Retire
the standalone DrugAutoUpdateJob. The ZIM/map attempt() path is unchanged.

Addresses the upstream #1040 request to wire the openFDA export_date check
into the content updater so the dataset updates alongside ZIMs and maps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Chris 2026-07-14 17:32:42 -04:00
parent 22eda3040e
commit a6d94edbee
6 changed files with 128 additions and 149 deletions

View File

@ -30,7 +30,17 @@ export class ContentAutoUpdateJob {
const result = await contentAutoUpdateService.attempt() const result = await contentAutoUpdateService.attempt()
logger.info(`[ContentAutoUpdateJob] ${result.started} started: ${result.reason}`) logger.info(`[ContentAutoUpdateJob] ${result.started} started: ${result.reason}`)
return result
// The FDA drug dataset refreshes on the same cycle and master switch as the
// ZIM/map catalog (its apply path differs, so it's a separate step, not part
// of attempt()). Governed by the same `contentAutoUpdate.*` settings.
const drugResult = await contentAutoUpdateService.attemptDrugDataset()
logger.info(`[ContentAutoUpdateJob] ${drugResult.started} started: ${drugResult.reason}`)
return {
started: result.started + drugResult.started,
reason: `${result.reason}; ${drugResult.reason}`,
}
} }
static async schedule() { static async schedule() {

View File

@ -300,8 +300,8 @@ export class DownloadDrugDataJob {
/** /**
* Fetch + parse the openFDA download manifest. The SINGLE source of truth for * Fetch + parse the openFDA download manifest. The SINGLE source of truth for
* the openFDA manifest call (Maxim 4): the download job uses it on pass 0, and * the openFDA manifest call (Maxim 4): the download job uses it on pass 0, and
* the freshness check (DrugReferenceService.checkForUpdate DrugAutoUpdateJob) * the freshness check (DrugReferenceService.checkForUpdate, driven by
* reuses it so there is exactly one place that knows the URL and the * attemptAutoUpdate) reuses it so there is exactly one place that knows the URL and the
* offline-error translation. * offline-error translation.
*/ */
static async fetchManifest(): Promise<DrugLabelManifest> { static async fetchManifest(): Promise<DrugLabelManifest> {

View File

@ -1,137 +0,0 @@
import { Job } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { DrugReferenceService } from '#services/drug_reference_service'
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job'
import logger from '@adonisjs/core/services/logger'
/**
* Daily job that checks the openFDA manifest's `export_date` against the
* last-ingested one and, when newer, re-downloads the FDA drug dataset (the
* download auto-chains the ingest, which advances the stored export_date).
*
* Mirrors {@link ContentAutoUpdateJob} (the ZIM/map freshness loop) but stays a
* SEPARATE job rather than extending it: the drug dataset isn't an
* `InstalledResource` catalog row with a filename `YYYY-MM` version, so its
* freshness key (export_date) and apply path (the drug download/ingest chain)
* don't fit the ZIM loop. openFDA publishes ~weekly; a daily check is safe and
* bandwidth-respectful.
*
* Gating (kept deliberately simple for v1):
* - Only acts when the dataset is actually INSTALLED (rows present). An update
* for something not installed is meaningless.
* - Skips when a drug download OR ingest is already in flight, so it never
* stacks a refresh on top of a running install/update.
* The 1.7 GB transfer respects those guards; a window/cap analogous to the ZIM
* `contentAutoUpdate.*` keys is a follow-up (open question for the maintainer:
* share `contentAutoUpdate.*` or get `drugReference.autoUpdate.*`).
*/
export class DrugAutoUpdateJob {
static get queue() {
return 'system'
}
static get key() {
return 'drug-auto-update'
}
async handle(_job: Job) {
logger.info('[DrugAutoUpdateJob] Evaluating drug-reference freshness...')
const service = new DrugReferenceService()
// Only act when the dataset is installed.
const rowCount = await service.rowCount()
if (rowCount === 0) {
logger.info('[DrugAutoUpdateJob] Drug dataset not installed — skipping.')
return { started: false, reason: 'not-installed' }
}
// Don't stack on top of an in-flight download/ingest.
const [dlJob, ingJob] = await Promise.all([
DownloadDrugDataJob.getJob(),
IngestDrugDataJob.getJob(),
])
const activeStates = ['active', 'waiting', 'delayed']
const dlState = dlJob ? await dlJob.getState() : undefined
const ingState = ingJob ? await ingJob.getState() : undefined
if (
(dlState && activeStates.includes(dlState)) ||
(ingState && activeStates.includes(ingState))
) {
logger.info('[DrugAutoUpdateJob] A drug job is already in flight — skipping.')
return { started: false, reason: 'job-in-flight' }
}
let check: Awaited<ReturnType<DrugReferenceService['checkForUpdate']>>
try {
check = await service.checkForUpdate()
} catch (err) {
// Offline or manifest fetch failed — a transient miss, not an error worth
// failing the scheduled job over. Try again next day.
logger.warn(
`[DrugAutoUpdateJob] Freshness check failed (will retry next run): ${
err instanceof Error ? err.message : String(err)
}`
)
return { started: false, reason: 'check-failed' }
}
if (!check.updateAvailable) {
logger.info(
`[DrugAutoUpdateJob] Up to date (current=${check.currentExportDate ?? 'none'}, ` +
`latest=${check.latestExportDate ?? 'unknown'}).`
)
return { started: false, reason: 'up-to-date' }
}
logger.info(
`[DrugAutoUpdateJob] Newer export_date available ` +
`(current=${check.currentExportDate ?? 'none'} → latest=${check.latestExportDate}); ` +
'triggering re-download.'
)
const result = await service.triggerDownload()
return {
started: result.created,
reason: result.created ? 'update-dispatched' : result.message,
latestExportDate: check.latestExportDate,
}
}
static async schedule() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
await queue.upsertJobScheduler(
'daily-drug-auto-update',
{ pattern: '30 3 * * *' }, // 03:30 daily — off-peak, distinct from the hourly content loop
{
name: this.key,
opts: {
removeOnComplete: { count: 7 },
removeOnFail: { count: 5 },
},
}
)
logger.info('[DrugAutoUpdateJob] Drug auto-update evaluation scheduled with cron: 30 3 * * *')
}
static async dispatch() {
const queueService = QueueService.getInstance()
const queue = queueService.getQueue(this.queue)
const job = await queue.add(
this.key,
{},
{
attempts: 1,
removeOnComplete: { count: 7 },
removeOnFail: { count: 5 },
}
)
logger.info(`[DrugAutoUpdateJob] Dispatched ad-hoc drug auto-update evaluation job ${job.id}`)
return job
}
}

View File

@ -4,6 +4,7 @@ import KVStore from '#models/kv_store'
import InstalledResource from '#models/installed_resource' import InstalledResource from '#models/installed_resource'
import { DownloadService } from '#services/download_service' import { DownloadService } from '#services/download_service'
import { CollectionUpdateService } from '#services/collection_update_service' import { CollectionUpdateService } from '#services/collection_update_service'
import { DrugReferenceService } from '#services/drug_reference_service'
import { import {
KiwixCatalogService, KiwixCatalogService,
reconcileResourceUpdateState, reconcileResourceUpdateState,
@ -269,8 +270,11 @@ export class ContentAutoUpdateService {
await this.maybeResetWindowBudget(config, now) await this.maybeResetWindowBudget(config, now)
// Local catalog check + persist available-update state for every resource. // Local catalog check + persist available-update state for every resource.
// ZIM/map catalog path only — `dataset` resources are excluded (they get // ZIM/map catalog path only — `dataset` resources are excluded here because
// their own freshness path; no-op today as none are written in this slice). // their freshness key (openFDA `export_date`) and apply path (the drug
// download/ingest chain) don't fit the catalog-version model. They refresh
// via `attemptDrugDataset()`, which the same content-update job runs under
// the same master switch + window.
const installed = await InstalledResource.query().whereNot('resource_type', 'dataset') const installed = await InstalledResource.query().whereNot('resource_type', 'dataset')
const latestByKey = await this.catalog.getLatestForResources( const latestByKey = await this.catalog.getLatestForResources(
// `dataset` rows are filtered out above, so the type narrows to ZIM/map. // `dataset` rows are filtered out above, so the type narrows to ZIM/map.
@ -379,6 +383,39 @@ export class ContentAutoUpdateService {
} }
} }
/**
* Freshness pass for the FDA drug dataset (`resource_type` 'dataset'), run by
* the same hourly ContentAutoUpdateJob as the ZIM/map `attempt()` and gated on
* the SAME master switch + window. The dataset's apply path differs from the
* catalog loop (openFDA `export_date` + the drug download/ingest chain rather
* than an InstalledResource catalog-version row), so it runs as its own step
* instead of through `selectUnderCap` but sharing `contentAutoUpdate.*` means
* it never updates while content auto-update is off, and refreshes alongside
* ZIMs and maps when it is on. Isolated so a drug-side failure can't affect the
* catalog run.
*/
async attemptDrugDataset(): Promise<{ started: number; reason: string }> {
const config = await this.getConfig()
if (!config.enabled) {
return { started: 0, reason: 'Content auto-update is disabled' }
}
if (!isWithinWindow(config.windowStart, config.windowEnd, DateTime.now())) {
return {
started: 0,
reason: `Outside update window (${config.windowStart}-${config.windowEnd})`,
}
}
try {
const result = await new DrugReferenceService().attemptAutoUpdate()
return { started: result.started ? 1 : 0, reason: `drug dataset: ${result.reason}` }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
logger.warn(`[ContentAutoUpdateService] Drug dataset freshness check failed: ${message}`)
return { started: 0, reason: `drug dataset check failed: ${message}` }
}
}
/** /**
* Evaluate what the next run *would* do, without hitting the network, * Evaluate what the next run *would* do, without hitting the network,
* persisting state, or dispatching anything. Operates on the available-update * persisting state, or dispatching anything. Operates on the available-update

View File

@ -408,15 +408,15 @@ export class DrugReferenceService {
} }
/** /**
* Freshness check for the daily auto-update path: compare the live openFDA * Freshness check for the content-auto-update path: compare the live openFDA
* manifest `export_date` against the last-ingested one (KV * manifest `export_date` against the last-ingested one (KV
* `drugReference.lastUpdatedExportDate`). Reuses the job's single manifest * `drugReference.lastUpdatedExportDate`). Reuses the job's single manifest
* fetch (Maxim 4 one openFDA call site) and the pure `isExportDateNewer` * fetch (Maxim 4 one openFDA call site) and the pure `isExportDateNewer`
* compare (defensive about the unconfirmed date format; see its TODO). * compare (defensive about the unconfirmed date format; see its TODO).
* *
* Returns `{ updateAvailable, latestExportDate, currentExportDate }`. Never * Returns `{ updateAvailable, latestExportDate, currentExportDate }`. Never
* triggers a download itself the caller (DrugAutoUpdateJob) decides whether * triggers a download itself `attemptAutoUpdate` decides whether to, after
* to, after its own gating (only when installed, no active job). * its own gating (only when installed, no active job).
*/ */
async checkForUpdate(): Promise<{ async checkForUpdate(): Promise<{
updateAvailable: boolean updateAvailable: boolean
@ -433,6 +433,79 @@ export class DrugReferenceService {
} }
} }
/**
* Freshness pass for the drug dataset, invoked from
* `ContentAutoUpdateService.attemptDrugDataset()` inside the hourly content
* loop so the dataset refreshes alongside ZIMs and maps under the shared
* `contentAutoUpdate.*` master switch and window, not on a rogue schedule.
*
* Gating (unchanged from the prior standalone job): only acts when the dataset
* is installed, and never while a drug download or ingest is already in flight,
* so it can't stack a refresh on a running install. A failed manifest fetch is
* a transient miss (offline), reported rather than thrown.
*/
async attemptAutoUpdate(): Promise<{
started: boolean
reason: string
latestExportDate?: string | null
}> {
// Only act when the dataset is installed.
const rowCount = await this.rowCount()
if (rowCount === 0) {
return { started: false, reason: 'not-installed' }
}
// Don't stack on top of an in-flight download/ingest.
const [dlJob, ingJob] = await Promise.all([
DownloadDrugDataJob.getJob(),
IngestDrugDataJob.getJob(),
])
const activeStates = ['active', 'waiting', 'delayed']
const dlState = dlJob ? await dlJob.getState() : undefined
const ingState = ingJob ? await ingJob.getState() : undefined
if (
(dlState && activeStates.includes(dlState)) ||
(ingState && activeStates.includes(ingState))
) {
return { started: false, reason: 'job-in-flight' }
}
let check: Awaited<ReturnType<DrugReferenceService['checkForUpdate']>>
try {
check = await this.checkForUpdate()
} catch (err) {
// Offline or manifest fetch failed — transient, retried next run.
logger.warn(
`[DrugReferenceService] Freshness check failed (will retry next run): ${
err instanceof Error ? err.message : String(err)
}`
)
return { started: false, reason: 'check-failed' }
}
if (!check.updateAvailable) {
return {
started: false,
reason: `up-to-date (current=${check.currentExportDate ?? 'none'}, latest=${
check.latestExportDate ?? 'unknown'
})`,
latestExportDate: check.latestExportDate,
}
}
logger.info(
`[DrugReferenceService] Newer export_date available ` +
`(current=${check.currentExportDate ?? 'none'} → latest=${check.latestExportDate}); ` +
'triggering re-download.'
)
const result = await this.triggerDownload()
return {
started: result.created,
reason: result.created ? 'update-dispatched' : result.message,
latestExportDate: check.latestExportDate,
}
}
/** /**
* Dispatch the ingest phase from the already-downloaded on-disk parts (the * Dispatch the ingest phase from the already-downloaded on-disk parts (the
* manual "Ingest into search" path). Guards on the KV download-state marker so * manual "Ingest into search" path). Guards on the KV download-state marker so

View File

@ -14,7 +14,6 @@ import { AppAutoUpdateJob } from '#jobs/app_auto_update_job'
import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job' import { ContentAutoUpdateJob } from '#jobs/content_auto_update_job'
import { DownloadDrugDataJob } from '#jobs/download_drug_data_job' import { DownloadDrugDataJob } from '#jobs/download_drug_data_job'
import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job' import { IngestDrugDataJob } from '#jobs/ingest_drug_data_job'
import { DrugAutoUpdateJob } from '#jobs/drug_auto_update_job'
export default class QueueWork extends BaseCommand { export default class QueueWork extends BaseCommand {
static commandName = 'queue:work' static commandName = 'queue:work'
@ -151,7 +150,6 @@ export default class QueueWork extends BaseCommand {
await AutoUpdateJob.schedule() await AutoUpdateJob.schedule()
await AppAutoUpdateJob.schedule() await AppAutoUpdateJob.schedule()
await ContentAutoUpdateJob.schedule() await ContentAutoUpdateJob.schedule()
await DrugAutoUpdateJob.schedule()
// Safety net: log unhandled rejections instead of crashing the worker process. // Safety net: log unhandled rejections instead of crashing the worker process.
// Individual job errors are already caught by BullMQ; this catches anything that // Individual job errors are already caught by BullMQ; this catches anything that
@ -185,7 +183,6 @@ export default class QueueWork extends BaseCommand {
handlers.set(AutoUpdateJob.key, new AutoUpdateJob()) handlers.set(AutoUpdateJob.key, new AutoUpdateJob())
handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob()) handlers.set(AppAutoUpdateJob.key, new AppAutoUpdateJob())
handlers.set(ContentAutoUpdateJob.key, new ContentAutoUpdateJob()) handlers.set(ContentAutoUpdateJob.key, new ContentAutoUpdateJob())
handlers.set(DrugAutoUpdateJob.key, new DrugAutoUpdateJob())
handlers.set(DownloadDrugDataJob.key, new DownloadDrugDataJob()) handlers.set(DownloadDrugDataJob.key, new DownloadDrugDataJob())
handlers.set(IngestDrugDataJob.key, new IngestDrugDataJob()) handlers.set(IngestDrugDataJob.key, new IngestDrugDataJob())
@ -199,7 +196,6 @@ export default class QueueWork extends BaseCommand {
queues.set(AutoUpdateJob.key, AutoUpdateJob.queue) queues.set(AutoUpdateJob.key, AutoUpdateJob.queue)
queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue) queues.set(AppAutoUpdateJob.key, AppAutoUpdateJob.queue)
queues.set(ContentAutoUpdateJob.key, ContentAutoUpdateJob.queue) queues.set(ContentAutoUpdateJob.key, ContentAutoUpdateJob.queue)
queues.set(DrugAutoUpdateJob.key, DrugAutoUpdateJob.queue)
queues.set(DownloadDrugDataJob.key, DownloadDrugDataJob.queue) queues.set(DownloadDrugDataJob.key, DownloadDrugDataJob.queue)
queues.set(IngestDrugDataJob.key, IngestDrugDataJob.queue) queues.set(IngestDrugDataJob.key, IngestDrugDataJob.queue)