Skip to content

Commit ee2ff45

Browse files
os-zhuangclaude
andauthored
fix(core): the pre-injected job fallback must not fake capability — take job off the pre-injection list so scheduled reports actually run on ObjectKernel (#11205)
* fix(core): stop pre-injecting the job fallback — a fallback must not fake capability On an ObjectKernel without @objectstack/service-job, preInjectCoreFallbacks() registered createMemoryJob() for the 'job' slot before Phase 2, so getService('job') always resolved — and that fallback's schedule() records a job and never fires it. Every 'prefer the platform job service, else own a timer' consumer took the job-service branch and then silently never ran: plugin-reports logged 'dispatcher registered with job service' and dispatched nothing, ever (measured: 0 reads of sys_report_schedule in 5600 ms with the success line present). Per the maintainer ruling of 2026-08-22 (issue 10746, Option A — declare only what you enforce), 'job' comes off the pre-injection list (CORE_FALLBACK_FACTORIES). getService('job') now throws when no job plugin is installed; every consumer's documented no-job-service path takes over, and validateSystemRequirements() says the absence out loud at boot. createMemoryJob stays exported for deliberate, explicit registration. Acceptance pin: dispatcher-runs-on-object-kernel.test.ts boots ObjectKernel + ObjectQLPlugin + ReportsServicePlugin with no job plugin and asserts sys_report_schedule is actually polled — red before this fix (0 reads), green after. Discovery-honesty gates keep the memory-job product in their inventory via the still-exported factory. Part of #10746 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y * docs(kernel): services-checklist no longer advertises a pre-injected job fallback The page asserted in four places that the kernel pre-injects an in-memory job fallback — all four made false by taking job off the pre-injection list. Corrected: the key-architecture principle's slot list (job now named beside auth as deliberately without a kernel fallback), Service Overview row 15 (Plugin Required, with the throw-and-warn behavior), the Infrastructure Services intro (cache/queue keep the fallback claim; job's core criticality explicitly unchanged — it is what makes the absence loud), and the job row of the infrastructure table. Judged still true and left alone: the Framework legend (generic marker definition, names no job), the i18n fallback notes (i18n stays pre-injected), the plugin-layer ASCII diagram (lists job as plugin-delivered — no fallback claim), and the scheduled-tasks provider row (names service-job, claims no fallback). Part of #10746 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d25f700 commit ee2ff45

9 files changed

Lines changed: 242 additions & 22 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
`ObjectKernel` no longer pre-injects the in-memory `job` fallback for the `job` core-service slot — a fallback must not fake capability (#10746, maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a job and never fires it (it owns no timer), so pre-injecting it made every "prefer the platform job service, else own a timer" consumer take the job-service branch on a kernel without `@objectstack/service-job` and then silently never run: `plugin-reports` logged `dispatcher registered with job service` and dispatched nothing, ever.
6+
7+
Behavior change, FROM → TO: on an `ObjectKernel` without a registered `job` service, `getService('job')` FROM resolving a non-scheduling in-memory registry TO throwing `Service 'job' not found`. Consumers' documented no-job-service paths take over (`plugin-reports` falls through to its own `setInterval` and scheduled reports actually dispatch; schedule triggers and declarative jobs warn loudly instead of scheduling into the void), and the kernel says the absence out loud at boot: `Core service missing, functionality may be degraded: job`.
8+
9+
One-line fix if you relied on the old behavior: install `@objectstack/service-job` for real scheduling, or — if you deliberately want the manual-trigger in-memory registry — register it explicitly: `kernel.registerService('job', createMemoryJob())` (the factory is still exported from `@objectstack/core`).

content/docs/kernel/services-checklist.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ package catalog.
1616

1717
The ObjectStack protocol defines **15 kernel services** registered via the `CoreServiceName` enum (v17 removed the never-implemented `graphql` entry and retired the never-filled `workflow` slot, #4451). Each service maps to a set of protocol methods governed by its per-domain contract (`DataProtocol`, `MetadataProtocol`, ...) — the transitional `ObjectStackProtocol` composition alias was dissolved in v17 (ADR-0076 D9); capability availability comes from the runtime discovery `services` registry.
1818

19-
**Key architecture principle**: the kernel guarantees only **data** and **metadata**, and even those are filled by packages (`@objectstack/objectql`, `@objectstack/metadata`) rather than baked in — the kernel's own contribution is an in-memory fallback for the `core` slots that have one (`metadata`, `cache`, `queue`, `job`, `i18n`**not** `auth`). Everything else — including **auth** and **automation** — is delivered by plugins. `@objectstack/objectql` is an example kernel implementation to get the basic API running; production kernels will be rebuilt as separate plugins.
19+
**Key architecture principle**: the kernel guarantees only **data** and **metadata**, and even those are filled by packages (`@objectstack/objectql`, `@objectstack/metadata`) rather than baked in — the kernel's own contribution is an in-memory fallback for the `core` slots that have one (`metadata`, `cache`, `queue`, `i18n`**not** `auth`, and not `job`: an in-memory registry cannot fire a `schedule()`d job on its own, so pre-injecting one advertised a scheduler that never ran, and a fallback must not fake capability — #10746. The `job` slot stays empty, loudly, until `@objectstack/service-job` or an explicitly registered scheduler fills it). Everything else — including **auth** and **automation** — is delivered by plugins. `@objectstack/objectql` is an example kernel implementation to get the basic API running; production kernels will be rebuilt as separate plugins.
2020

2121
<Callout type="info">
2222
**Legend**
@@ -79,7 +79,7 @@ The ObjectStack protocol defines **15 kernel services** registered via the `Core
7979
| 12 | **search** | `optional` || ❌ Nothing ships ||
8080
| 13 | **cache** | `core` || ✅ Built-in (in-memory fallback) | `@objectstack/service-cache` |
8181
| 14 | **queue** | `core` || ✅ Built-in (in-memory fallback) | `@objectstack/service-queue` |
82-
| 15 | **job** | `core` || ✅ Built-in (in-memory fallback) | `@objectstack/service-job` |
82+
| 15 | **job** | `core` || ❌ Plugin Required (no pre-injected fallback since #10746 — with no job plugin, `getService('job')` throws and the boot warns) | `@objectstack/service-job` |
8383

8484
<Callout type="info">
8585
The Provider column mirrors `CORE_SERVICE_PROVIDER` in
@@ -480,15 +480,15 @@ AppPlugin will:
480480

481481
## 11–15. Infrastructure Services
482482

483-
`cache`, `queue`, and `job` are `core` services: like `i18n`, the kernel auto-injects an in-memory fallback when no plugin registers them (see `CORE_FALLBACK_FACTORIES` in `packages/core/src/fallbacks/`). The `optional` services (`storage`, `search`) stay disabled until a plugin provides them.
483+
`cache`, `queue`, and `job` are `core` services. For `cache` and `queue`like `i18n`the kernel auto-injects an in-memory fallback when no plugin registers them (see `CORE_FALLBACK_FACTORIES` in `packages/core/src/fallbacks/`). `job` is deliberately **not** on that list (#10746): an in-memory registry cannot fire a `schedule()`d job on its own, and a fallback must not fake capability — so with no job plugin installed, `getService('job')` throws, consumers take their documented no-scheduler paths (e.g. the reports dispatcher's own `setInterval`), and the boot warns `Core service missing, functionality may be degraded: job`. The `core` criticality itself is unchanged — it is exactly what makes the absence loud. Fill the slot with `@objectstack/service-job`, or register `createMemoryJob()` explicitly if a manual-`trigger()` registry is genuinely wanted. The `optional` services (`storage`, `search`) stay disabled until a plugin provides them.
484484

485485
| Service | Description |
486486
|:--------|:------------|
487487
| **storage** (deprecated v17 alias: `file-storage`, #9683) | Unified upload/download/delete via `@objectstack/service-storage`, which mounts `/api/v1/storage` itself. Adapters: local FS and S3 (the S3 adapter's `endpoint` + path-style options cover S3-compatible services such as MinIO and R2). |
488488
| **search** | **Nothing ships.** `ISearchService` and the engine enum (`elasticsearch`, `meilisearch`, …) exist in `@objectstack/spec`, but no package implements the contract or registers the `search` slot, so `CORE_SERVICE_PROVIDER.search` is `null`. |
489489
| **cache** | General-purpose cache. In-memory fallback; memory or Redis adapter via `@objectstack/service-cache`. |
490490
| **queue** | Message queue. In-memory fallback; durable DB-backed adapter (`sys_job_queue`) via `@objectstack/service-queue` (no BullMQ/Redis adapter is shipped). |
491-
| **job** | Scheduled task execution via `@objectstack/service-job`. In-memory fallback; interval, cron, and DB-backed adapters with concurrency policy. |
491+
| **job** | Scheduled task execution via `@objectstack/service-job`interval, cron, and DB-backed adapters with concurrency policy. No pre-injected fallback (#10746): install the plugin, or the slot stays empty and the boot says so. |
492492

493493
---
494494

packages/core/src/fallbacks/fallbacks.test.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@ import { CORE_FALLBACK_FACTORIES } from './index';
77
import { readServiceSelfInfo } from '@objectstack/spec/api';
88

99
describe('CORE_FALLBACK_FACTORIES', () => {
10-
it('should have exactly 5 entries: metadata, cache, queue, job, i18n', () => {
11-
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['metadata', 'cache', 'queue', 'job', 'i18n']);
10+
// [#10746] `job` is deliberately OFF this list — a fallback must not fake
11+
// capability (maintainer ruling 2026-08-22). `createMemoryJob().schedule()`
12+
// records a job and never fires it, so pre-injecting it made consumers
13+
// treat "a `job` service resolves" as "a working scheduler" and silently
14+
// never run. The factory stays exported for deliberate, explicit use; the
15+
// kernel must not hand it out as if it honoured `schedule()`.
16+
it('should have exactly 4 entries: metadata, cache, queue, i18n — job deliberately absent (#10746)', () => {
17+
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['metadata', 'cache', 'queue', 'i18n']);
1218
});
1319

1420
// [#4058] Every kernel fallback must be readable through the ONE standard

packages/core/src/fallbacks/index.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { createMemoryCache } from './memory-cache.js';
44
import { createMemoryQueue } from './memory-queue.js';
5-
import { createMemoryJob } from './memory-job.js';
65
import { createMemoryI18n } from './memory-i18n.js';
76
import { createMemoryMetadata } from './memory-metadata.js';
87

@@ -18,13 +17,30 @@ export {
1817

1918
/**
2019
* Map of core-criticality service names to their in-memory fallback factories.
21-
* Used by ObjectKernel.validateSystemRequirements() to auto-inject fallbacks
22-
* when no real plugin provides the service.
20+
* This IS the kernel's pre-injection list: `ObjectKernel.preInjectCoreFallbacks()`
21+
* registers an entry for every unprovided `core` service before Phase 2, and
22+
* `validateSystemRequirements()` consults the same map as its final check.
23+
*
24+
* [#10746] `job` is deliberately ABSENT — a fallback must not fake capability
25+
* (maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a
26+
* job and never fires it, so pre-injecting it made every "prefer the platform
27+
* job service, else own a timer" consumer take the job-service branch and then
28+
* silently never run: `plugin-reports` logged `dispatcher registered with job
29+
* service` and dispatched nothing, ever (measured: 0 reads of
30+
* `sys_report_schedule` in 5600 ms with the success line present). With no
31+
* entry here, `getService('job')` throws when no job plugin is installed,
32+
* every consumer's documented no-job-service path becomes reachable (they all
33+
* already run on `LiteKernel`, which injects no fallbacks), and the kernel
34+
* says the absence out loud at boot: `validateSystemRequirements()` warns
35+
* "Core service missing, functionality may be degraded: job". Do NOT re-add
36+
* the entry to quiet that warning — install `@objectstack/service-job`, or
37+
* register a real scheduler, instead. `createMemoryJob` stays exported below
38+
* for embedders who deliberately want a manual-trigger job registry and have
39+
* read its docblock.
2340
*/
2441
export const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>> = {
2542
metadata: createMemoryMetadata,
2643
cache: createMemoryCache,
2744
queue: createMemoryQueue,
28-
job: createMemoryJob,
2945
i18n: createMemoryI18n,
3046
};

packages/core/src/fallbacks/memory-job.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* In-memory job scheduler fallback.
4+
* In-memory job registry — schedule/cancel/trigger bookkeeping with NO timer.
55
*
6-
* Implements the IJobService contract with basic schedule/cancel/trigger
7-
* operations. Used by ObjectKernel as an automatic fallback when no real
8-
* job plugin (e.g. Agenda / BullMQ) is registered.
6+
* [#10746] NOT pre-injected by ObjectKernel any more (it used to be, via
7+
* `CORE_FALLBACK_FACTORIES`): a fallback must not fake capability (maintainer
8+
* ruling 2026-08-22). Advertising a `schedule()` that records and never fires
9+
* made every "prefer the platform job service, else own a timer" consumer
10+
* take the job-service branch and then silently never run. The export remains
11+
* for embedders who deliberately want a manual-trigger job registry — e.g. in
12+
* tests that drive handlers via `trigger()` — and have read this docblock.
913
*
1014
* [#4058] `degraded` (ADR-0076 D12), with the missing half named in the
1115
* message rather than left for a deployer to discover: `trigger()` really runs

packages/core/src/kernel.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1155,4 +1155,43 @@ describe('ObjectKernel', () => {
11551155
await kernel.shutdown();
11561156
});
11571157
});
1158+
1159+
describe('Core fallback pre-injection (#10746)', () => {
1160+
// The suite-level kernel sets `skipSystemValidation: true`, which skips
1161+
// pre-injection entirely — this pin needs the real path, so it boots
1162+
// its own kernel with validation ON (the production default).
1163+
it('pre-injects the honest core fallbacks but NOT job — a fallback must not fake capability', async () => {
1164+
const k = new ObjectKernel({
1165+
logger: { level: 'error' },
1166+
gracefulShutdown: false,
1167+
});
1168+
// `data` is `required` criticality; provide it so bootstrap
1169+
// survives validateSystemRequirements().
1170+
const dataProvider: Plugin = {
1171+
name: 'test.data-provider',
1172+
version: '1.0.0',
1173+
init: async (ctx: PluginContext) => {
1174+
ctx.registerService('data', { find: async () => [] });
1175+
},
1176+
};
1177+
await k.use(dataProvider);
1178+
await k.bootstrap();
1179+
try {
1180+
// The remaining core slots still pre-inject before Phase 2.
1181+
for (const slot of ['metadata', 'cache', 'queue', 'i18n']) {
1182+
expect(k.getService(slot), `fallback for '${slot}'`).toBeDefined();
1183+
}
1184+
// `job` must NOT resolve: `createMemoryJob()`'s `schedule()`
1185+
// records a job and never fires it, so handing it out made
1186+
// every "prefer the platform job service" consumer schedule
1187+
// into the void while logging success (maintainer ruling
1188+
// 2026-08-22: declare only what you enforce). Absence is the
1189+
// honest answer — consumers' documented no-job-service paths
1190+
// (setInterval fallbacks, loud warns) take over.
1191+
expect(() => k.getService('job')).toThrow(/Service 'job' not found/);
1192+
} finally {
1193+
await k.shutdown();
1194+
}
1195+
});
1196+
});
11581197
});

packages/objectql/src/protocol-discovery.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { describe, it, expect, beforeEach } from 'vitest';
44
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
5-
import { createMemoryMetadata, CORE_FALLBACK_FACTORIES } from '@objectstack/core';
5+
import { createMemoryMetadata, createMemoryJob, CORE_FALLBACK_FACTORIES } from '@objectstack/core';
66
import { ObjectQL } from './engine.js';
77

88
describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => {
@@ -232,7 +232,12 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
232232
it('reports every CORE_FALLBACK_FACTORIES product as degraded, never available (#3898)', async () => {
233233
expect(Object.keys(CORE_FALLBACK_FACTORIES).length).toBeGreaterThan(0);
234234

235-
for (const [slot, factory] of Object.entries(CORE_FALLBACK_FACTORIES)) {
235+
// [#10746] `job` came OFF the pre-injection list (a fallback must not
236+
// fake capability), but `createMemoryJob` stays exported for deliberate
237+
// registration — so its product stays in this gate's inventory: however
238+
// it reaches a slot, discovery must never call it `available`.
239+
const inventory = { ...CORE_FALLBACK_FACTORIES, job: createMemoryJob };
240+
for (const [slot, factory] of Object.entries(inventory)) {
236241
const mockServices = new Map<string, any>();
237242
mockServices.set(slot, factory());
238243

@@ -382,9 +387,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
382387
});
383388

384389
it('never advertises a route for a cache/queue/job fallback either (#4318)', async () => {
390+
// [#10746] `job` is off the pre-injection map but stays explicitly
391+
// registrable, so the slot keeps its fallback-occupant coverage here.
392+
const factoryFor = (slot: string) =>
393+
slot === 'job' ? createMemoryJob : CORE_FALLBACK_FACTORIES[slot];
385394
for (const slot of ['cache', 'queue', 'job']) {
386395
const mockServices = new Map<string, any>();
387-
mockServices.set(slot, CORE_FALLBACK_FACTORIES[slot]());
396+
mockServices.set(slot, factoryFor(slot)());
388397

389398
protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
390399
const reported = (await protocol.getDiscovery()).services[slot];

0 commit comments

Comments
 (0)