Found while fixing #10371 (teardown hook naming) in plugin-reports. Different defect, filed rather than fixed — that PR is one defect with one proof, and this one changes whether scheduled reports run at all.
The claim in the code
packages/plugins/plugin-reports/src/reports-plugin.ts:33-36:
The dispatcher uses IJobService.schedule when one is registered; otherwise it falls back to a plain setInterval so single-kernel deployments work without service-job.
start() implements exactly that: it tries ctx.getService<IJobService>('job') and only reaches setInterval when the lookup yields nothing with a schedule method.
Why the fallback can never be reached on ObjectKernel
ObjectKernel.preInjectCoreFallbacks() (packages/core/src/kernel.ts:261-275) walks ServiceRequirementDef and registers a CORE_FALLBACK_FACTORIES entry for every unprovided core service before Phase 2, i.e. before any kernel:ready hook runs. job is one of the five (packages/core/src/fallbacks/index.ts). So ctx.getService('job') ALWAYS resolves on an ObjectKernel, and the setInterval branch is dead code there.
And the thing it resolves to does not schedule. createMemoryJob() (packages/core/src/fallbacks/memory-job.ts) is honest about it in its own docblock:
trigger() really runs the registered handler, but nothing here owns a timer, so a schedule()d job NEVER fires on its own.
Its schedule() is jobs.set(name, { schedule, handler }) and nothing else.
Net effect
On an ObjectKernel stack without @objectstack/service-job, ReportsServicePlugin logs
ReportsServicePlugin: dispatcher registered with job service {"intervalMs":60000}
…and then dispatches nothing, ever. sys_report_schedule is never polled, no scheduled report is ever emailed, and the log line reads like success. The setInterval path the docblock offers as the single-kernel answer is unreachable.
Measured
Booting ObjectKernel + ObjectQLPlugin + ReportsServicePlugin({ dispatchIntervalMs: 5000 }) with no job plugin and a real SqlDriver, then counting engine.find('sys_report_schedule', …) on the engine the plugin captured at kernel:ready: 0 reads in 5600 ms, with the log line above present. The same stack on LiteKernel — which injects no fallbacks — takes the setInterval branch and reads on schedule. That contrast is what identifies the cause as the pre-injected fallback rather than anything in the reports plugin's own logic.
(The LiteKernel half of that measurement is now pinned in packages/plugins/plugin-reports/src/plugin-shutdown-releases-dispatcher.test.ts as part of #10371, with the mechanism written down at bootReportsLiteKernel. #10371 pins that the dispatcher is released at shutdown; nothing pins that it runs on the kernel real deployments use.)
Not asserted here
Three questions this finding does not decide:
- Where the repair belongs. Either
plugin-reports stops treating "a job service exists" as "a working scheduler" (e.g. by reading __serviceInfo.status === 'degraded', which the fallback already declares), or the job fallback stops advertising a schedule() it cannot honour, or ServiceRequirementDef stops classifying job as pre-injectable. These are materially different contracts. - How wide the class is. Any plugin written as "prefer the platform job service, else own a timer" has the same shape against the same pre-injection. This finding measured only
plugin-reports. - Whether the degraded-fallback surface is meant to be consulted.
createMemoryJob() publishes __serviceInfo: { status: 'degraded', message } — a declared, machine-readable "I am not really scheduling". If consumers are expected to read it, no consumer in this tree does; if they are not, it is inert.
Found while fixing #10371 (teardown hook naming) in
plugin-reports. Different defect, filed rather than fixed — that PR is one defect with one proof, and this one changes whether scheduled reports run at all.The claim in the code
packages/plugins/plugin-reports/src/reports-plugin.ts:33-36:start()implements exactly that: it triesctx.getService<IJobService>('job')and only reachessetIntervalwhen the lookup yields nothing with aschedulemethod.Why the fallback can never be reached on
ObjectKernelObjectKernel.preInjectCoreFallbacks()(packages/core/src/kernel.ts:261-275) walksServiceRequirementDefand registers aCORE_FALLBACK_FACTORIESentry for every unprovidedcoreservice before Phase 2, i.e. before anykernel:readyhook runs.jobis one of the five (packages/core/src/fallbacks/index.ts). Soctx.getService('job')ALWAYS resolves on anObjectKernel, and thesetIntervalbranch is dead code there.And the thing it resolves to does not schedule.
createMemoryJob()(packages/core/src/fallbacks/memory-job.ts) is honest about it in its own docblock:Its
schedule()isjobs.set(name, { schedule, handler })and nothing else.Net effect
On an
ObjectKernelstack without@objectstack/service-job,ReportsServicePluginlogs…and then dispatches nothing, ever.
sys_report_scheduleis never polled, no scheduled report is ever emailed, and the log line reads like success. ThesetIntervalpath the docblock offers as the single-kernel answer is unreachable.Measured
Booting
ObjectKernel+ObjectQLPlugin+ReportsServicePlugin({ dispatchIntervalMs: 5000 })with no job plugin and a realSqlDriver, then countingengine.find('sys_report_schedule', …)on the engine the plugin captured atkernel:ready: 0 reads in 5600 ms, with the log line above present. The same stack onLiteKernel— which injects no fallbacks — takes thesetIntervalbranch and reads on schedule. That contrast is what identifies the cause as the pre-injected fallback rather than anything in the reports plugin's own logic.(The
LiteKernelhalf of that measurement is now pinned inpackages/plugins/plugin-reports/src/plugin-shutdown-releases-dispatcher.test.tsas part of #10371, with the mechanism written down atbootReportsLiteKernel. #10371 pins that the dispatcher is released at shutdown; nothing pins that it runs on the kernel real deployments use.)Not asserted here
Three questions this finding does not decide:
plugin-reportsstops treating "ajobservice exists" as "a working scheduler" (e.g. by reading__serviceInfo.status === 'degraded', which the fallback already declares), or thejobfallback stops advertising aschedule()it cannot honour, orServiceRequirementDefstops classifyingjobas pre-injectable. These are materially different contracts.plugin-reports.createMemoryJob()publishes__serviceInfo: { status: 'degraded', message }— a declared, machine-readable "I am not really scheduling". If consumers are expected to read it, no consumer in this tree does; if they are not, it is inert.