Skip to content

Commit d37ac1c

Browse files
os-zhuangclaude
andauthored
fix(service-job): destroy the cron adapter on kernel eviction so scheduled flows re-bind (#8362) (#8462)
DbJobAdapter.destroy() only destroyed `inner`, never the CronJobAdapter it was constructed with. Kernel eviction is routine in the cloud runtime, so every evicted kernel left its croner timers running and holding their PROCESS-GLOBAL croner names forever; the rebuilt kernel then failed to re-bind that flow permanently, with one unwatched WARN as the only signal. - DbJobAdapter.destroy() destroys the cron adapter too (the single-point cause), reporting a failure to do so at error level (persisted vs runtime state disagreeing, invisible everywhere else). - JobServicePlugin releases the cron adapter it owns on the `adapter: 'cron'` path, where nothing else would. - CronJobAdapter scopes its croner registry key to the adapter INSTANCE, which also fixes cross-environment collisions in one container with no eviction involved. Per-instance rather than per-environment on purpose: a rebuilt kernel reuses the environment id, which is the collision itself. - Claiming a registry name a foreign job still holds now STOPS that job and replaces it, rather than warning and giving up. Stopping matters: a leaked croner job is a live timer closed over a shut-down kernel, so taking the name while leaving it running would trade a silent death for a zombie double-write. - Both triggers report a failed bind at error with consequence and remedy. Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4018fc1 commit d37ac1c

11 files changed

Lines changed: 626 additions & 13 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"@objectstack/service-job": patch
3+
"@objectstack/trigger-schedule": patch
4+
---
5+
6+
Fix scheduled and time-relative flows permanently failing to re-bind after a kernel rebuild.
7+
8+
`DbJobAdapter.destroy()` destroyed only its interval adapter, never the cron adapter it
9+
was handed — so every evicted kernel left its croner timers running, holding their names
10+
in croner's process-global registry for the life of the process. Because kernel eviction
11+
is routine in the cloud runtime, the normal path was: a scheduled automation binds once,
12+
the next metadata edit evicts the kernel, and the flow never binds again ("name already
13+
taken") while Studio, the metadata API and `verify_build` all keep reporting it healthy.
14+
15+
Four changes close it:
16+
17+
- `DbJobAdapter.destroy()` now also destroys the cron adapter, and `JobServicePlugin`
18+
releases the cron adapter it owns on the `adapter: 'cron'` path.
19+
- `CronJobAdapter` scopes its entry in croner's process-global registry to the adapter
20+
INSTANCE (`CronJobAdapter.cronRegistryName()` exposes the key). This also fixes a
21+
second defect with no eviction involved: two environments in one container binding the
22+
same flow name no longer collide.
23+
- Registering a name something else still holds now REPLACES it — the previous job is
24+
stopped, never left running alongside the new one.
25+
- A flow that fails to bind to the job service is now reported at `error` with the
26+
consequence and the remedy, instead of a `warn` nobody reads.

packages/services/service-job/src/cron-job-adapter.test.ts

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

33
import { describe, it, expect, afterEach } from 'vitest';
4+
import { Cron, scheduledJobs } from 'croner';
45
import { CronJobAdapter } from './cron-job-adapter.js';
56

67
describe('CronJobAdapter', () => {
@@ -132,3 +133,95 @@ describe('CronJobAdapter retryPolicy / timeout (#3494)', () => {
132133
expect(execs[0].error).toMatch(/timed out after 25ms/);
133134
});
134135
});
136+
137+
// ─── #8362 — croner's PROCESS-GLOBAL named registry ─────────────────────────
138+
//
139+
// `new Cron(expr, { name }, fn)` pushes into a module-level array inside croner
140+
// and throws `name already taken` when that name is live. That array is scoped
141+
// to the PROCESS, not to this adapter, not to a kernel and not to an
142+
// environment — so two adapter instances that are each perfectly consistent
143+
// with themselves can still collide, and a stopped-but-never-destroyed instance
144+
// keeps its names forever.
145+
//
146+
// Two live-fire consequences these cases pin, both reproduced on a real rig
147+
// before the fix:
148+
// 1. two environments in one container, same AI-generated flow name, NO
149+
// eviction involved — the second environment's automation never binds;
150+
// 2. an evicted kernel whose cron adapter was never destroyed holds the name
151+
// forever, so every later rebind of that flow fails permanently.
152+
//
153+
// The pins deliberately go through the `cron` path: `interval` schedules use
154+
// `setInterval` and never enter croner's named registry at all, so an
155+
// interval-shaped fixture would pass on a completely unfixed tree.
156+
describe('CronJobAdapter — process-global croner name registry (#8362)', () => {
157+
const live: CronJobAdapter[] = [];
158+
const make = (options?: ConstructorParameters<typeof CronJobAdapter>[0]) => {
159+
const a = new CronJobAdapter(options);
160+
live.push(a);
161+
return a;
162+
};
163+
afterEach(async () => {
164+
while (live.length) await live.pop()!.destroy();
165+
});
166+
167+
/** Croner's process-global registry, narrowed to one PUBLIC job name. */
168+
const registeredFor = (jobName: string) =>
169+
scheduledJobs.filter((j) => (j.name ?? '').endsWith(jobName));
170+
171+
const DAILY = { type: 'cron', expression: '0 8 * * *' } as const;
172+
173+
it('lets two live adapters hold the SAME job name — two environments, one container', async () => {
174+
const NAME = 'flow-time-relative:contract_expiry_reminder_flow';
175+
const fired: string[] = [];
176+
177+
const envA = make();
178+
await envA.schedule(NAME, DAILY, async () => { fired.push('A'); });
179+
// The FIRST bind must really have entered the named registry: a rebind pin
180+
// whose first bind registered nothing passes for the wrong reason.
181+
expect(registeredFor(NAME)).toHaveLength(1);
182+
183+
const envB = make();
184+
await envB.schedule(NAME, DAILY, async () => { fired.push('B'); });
185+
186+
expect(registeredFor(NAME)).toHaveLength(2);
187+
188+
// Each environment's timer drives its OWN handler.
189+
for (const job of registeredFor(NAME)) await job.trigger();
190+
expect([...fired].sort()).toEqual(['A', 'B']);
191+
});
192+
193+
it('frees the process-global name on destroy() — the job is STOPPED, not renamed around', async () => {
194+
const NAME = 'flow-schedule:nightly_rollup';
195+
const adapterA = make();
196+
await adapterA.schedule(NAME, DAILY, async () => {});
197+
198+
const [job] = registeredFor(NAME);
199+
expect(job).toBeDefined();
200+
expect(job.isStopped()).toBe(false);
201+
202+
await adapterA.destroy();
203+
204+
expect(job.isStopped()).toBe(true);
205+
expect(registeredFor(NAME)).toHaveLength(0);
206+
});
207+
208+
it('reclaims its registry name from a foreign holder instead of warning and giving up', async () => {
209+
const NAME = 'flow-schedule:reclaim_me';
210+
const adapterA = make();
211+
let calls = 0;
212+
213+
// Somebody else already holds the exact name this adapter will register
214+
// under — the residual shape once per-instance namespacing rules out our
215+
// own collisions. Replace semantics: the holder is stopped, not tolerated.
216+
const squatter = new Cron(DAILY.expression, { name: adapterA.cronRegistryName(NAME) }, () => {});
217+
expect(registeredFor(NAME)).toHaveLength(1);
218+
219+
await adapterA.schedule(NAME, DAILY, async () => { calls++; });
220+
221+
expect(squatter.isStopped()).toBe(true);
222+
const held = registeredFor(NAME);
223+
expect(held).toHaveLength(1);
224+
await held[0].trigger();
225+
expect(calls).toBe(1);
226+
});
227+
});

packages/services/service-job/src/cron-job-adapter.ts

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

3-
import { Cron } from 'croner';
3+
import { Cron, scheduledJobs } from 'croner';
44
import type {
55
IJobService,
66
JobSchedule,
@@ -10,6 +10,21 @@ import type {
1010
} from '@objectstack/spec/contracts';
1111
import { runWithPolicy, JobTimeoutError } from './run-with-policy.js';
1212

13+
/**
14+
* Monotonic counter that makes every adapter instance's registry prefix unique
15+
* within the process. Uniqueness has to be PER INSTANCE, not per environment:
16+
* a kernel rebuild produces a new adapter for the *same* environment id, which
17+
* is exactly the collision an environment-scoped namespace would fail to
18+
* prevent (#8362).
19+
*/
20+
let ADAPTER_SEQUENCE = 0;
21+
22+
/** Namespace labels ride in a croner job name — keep them boring. */
23+
function sanitizeNamespaceLabel(label: string | undefined): string {
24+
const trimmed = (label ?? '').trim().replace(/[^A-Za-z0-9._-]+/g, '-');
25+
return trimmed.length > 0 ? trimmed.slice(0, 48) : 'kernel';
26+
}
27+
1328
/** Minimal cluster lock surface for scheduler leader-election (structural — no hard dep on the cluster contract). */
1429
interface SchedulerCluster {
1530
lock?: {
@@ -31,6 +46,16 @@ export interface CronJobAdapterOptions {
3146
cluster?: SchedulerCluster;
3247
/** Lease TTL (ms) held while a scheduled fire runs. Default 60000. */
3348
leaseMs?: number;
49+
/**
50+
* Human-readable label folded into this adapter's entry in croner's
51+
* process-global name registry — an environment id, a kernel id, anything
52+
* that makes `scheduledJobs` readable while debugging a multi-tenant
53+
* container. Purely cosmetic: uniqueness is guaranteed by the per-instance
54+
* discriminator and NEVER depends on this value being supplied or distinct.
55+
*/
56+
namespace?: string;
57+
/** Surface for registry-level anomalies (a reclaimed job name). */
58+
logger?: { warn(msg: string, meta?: unknown): void };
3459
}
3560

3661
interface CronJobRecord {
@@ -55,12 +80,40 @@ export class CronJobAdapter implements IJobService {
5580
private readonly jobs = new Map<string, CronJobRecord>();
5681
private readonly cluster?: SchedulerCluster;
5782
private readonly leaseMs: number;
83+
private readonly logger?: { warn(msg: string, meta?: unknown): void };
84+
85+
/**
86+
* This instance's prefix in croner's PROCESS-GLOBAL name registry.
87+
*
88+
* croner keys named jobs in a module-level array shared by everything in the
89+
* process, so a bare job name is a process-wide claim — which is why two
90+
* environments in one container used to collide on the same AI-generated
91+
* flow name with no kernel eviction involved at all, and why an evicted
92+
* kernel's leftovers used to block every later rebind (#8362). Scoping the
93+
* registry key to the adapter INSTANCE makes both collisions unreachable:
94+
* one kernel builds one adapter, and a rebuilt kernel builds a new one.
95+
*/
96+
readonly registryNamespace: string;
5897

5998
constructor(options: CronJobAdapterOptions = {}) {
6099
this.defaultTimezone = options.timezone ?? 'UTC';
61100
this.maxExecutions = options.maxExecutions ?? 100;
62101
this.cluster = options.cluster;
63102
this.leaseMs = options.leaseMs ?? 60_000;
103+
this.logger = options.logger;
104+
this.registryNamespace = `${sanitizeNamespaceLabel(options.namespace)}#${++ADAPTER_SEQUENCE}.${Math.random()
105+
.toString(36)
106+
.slice(2, 8)}`;
107+
}
108+
109+
/**
110+
* The name `jobName` is registered under in croner's process-global
111+
* registry. Public because that registry is shared with everything else in
112+
* the process: this is the only way an operator (or a test) can tell which
113+
* entry of `scheduledJobs` belongs to which kernel.
114+
*/
115+
cronRegistryName(jobName: string): string {
116+
return `${this.registryNamespace}::${jobName}`;
64117
}
65118

66119
async schedule(name: string, schedule: JobSchedule, handler: JobHandler, options?: JobScheduleOptions): Promise<void> {
@@ -72,9 +125,11 @@ export class CronJobAdapter implements IJobService {
72125
if (!schedule.expression) {
73126
throw new Error(`CronJobAdapter: cron schedule for "${name}" missing expression`);
74127
}
128+
const registryName = this.cronRegistryName(name);
129+
this.reclaimRegistryName(registryName);
75130
const task = new Cron(
76131
schedule.expression,
77-
{ timezone: schedule.timezone ?? this.defaultTimezone, name },
132+
{ timezone: schedule.timezone ?? this.defaultTimezone, name: registryName },
78133
async () => { await this.runScheduled(name); },
79134
);
80135
record.task = task;
@@ -119,7 +174,38 @@ export class CronJobAdapter implements IJobService {
119174
return [...this.jobs.keys()];
120175
}
121176

122-
/** Stop all timers — call from plugin destroy. */
177+
/**
178+
* Replace semantics for the process-global registry: if anything still holds
179+
* the name we are about to claim, STOP it and take the name — never warn and
180+
* give up, which is how a failed rebind used to end (#8362).
181+
*
182+
* Stopping is the whole point and not a detail. A leaked croner job is not
183+
* merely holding a string: it is a live timer whose closure still references
184+
* the kernel that created it. Taking the name while leaving that timer
185+
* running would turn a silent death into a zombie double-write — two live
186+
* jobs for one flow, one of them driving a shut-down kernel — which is
187+
* strictly worse than the bug being fixed. `stop()` both kills the timer and
188+
* splices the entry out of croner's registry, so the reclaim is complete.
189+
*
190+
* With per-instance namespacing our own adapters can no longer collide, so
191+
* reaching this at all means a foreign holder — worth a line in the log.
192+
*/
193+
private reclaimRegistryName(registryName: string): void {
194+
const holder = scheduledJobs.find((job) => job.name === registryName);
195+
if (!holder) return;
196+
try { holder.stop(); } catch { /* ignore — the retake below is what matters */ }
197+
this.logger?.warn(
198+
`CronJobAdapter: reclaimed croner job name "${registryName}" from a job this adapter did not schedule; ` +
199+
'the previous job was STOPPED and replaced.',
200+
);
201+
}
202+
203+
/**
204+
* Stop all timers and release every process-global croner name this adapter
205+
* holds. Called from `DbJobAdapter.destroy()` and `JobServicePlugin.destroy()`
206+
* — i.e. from the kernel eviction chain, which until #8362 stopped one level
207+
* above this method and left every evicted kernel's timers running forever.
208+
*/
123209
async destroy(): Promise<void> {
124210
for (const rec of this.jobs.values()) {
125211
try { rec.task?.stop(); } catch { /* ignore */ }

packages/services/service-job/src/db-job-adapter.test.ts

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

33
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4+
import { scheduledJobs } from 'croner';
45
import { DbJobAdapter } from './db-job-adapter.js';
6+
import { CronJobAdapter } from './cron-job-adapter.js';
57

68
function makeFakeEngine() {
79
const tables = new Map<string, any[]>();
@@ -142,3 +144,76 @@ describe('DbJobAdapter', () => {
142144
expect(triggers).toEqual(['replay', 'schedule']);
143145
});
144146
});
147+
148+
// ─── #8362 — the destroy chain, and what an evicted kernel leaves behind ─────
149+
//
150+
// Kernel eviction is ROUTINE in the cloud runtime: a freshness probe runs every
151+
// few seconds and every auto-publish bumps freshness, so the eviction chain
152+
// `KernelManager.evict() -> kernel.shutdown() -> plugin.destroy() ->
153+
// JobServicePlugin.destroy() -> dbAdapter.destroy()` runs constantly. It used
154+
// to stop one level short — `destroy()` destroyed `inner` and never `cron` — so
155+
// every evicted kernel left its croner timers running and holding their
156+
// PROCESS-GLOBAL names, and the rebuilt kernel could never re-bind that flow
157+
// again. The only signal was one WARN.
158+
//
159+
// Why the ordering of the two fixes matters, pinned by the second case below:
160+
// the leaked job is not merely holding a name, it is still ALIVE with a closure
161+
// over the shut-down kernel's engine. Namespacing the names WITHOUT closing the
162+
// destroy chain would therefore convert a silent death into a zombie
163+
// double-write — two live jobs, one driving a dead kernel. Hence the assertion
164+
// is `oldJob.isStopped()`, not "a new job exists somewhere".
165+
describe('DbJobAdapter — kernel rebuild (#8362)', () => {
166+
/** Croner's process-global registry, narrowed to one PUBLIC job name. */
167+
const registeredFor = (jobName: string) =>
168+
scheduledJobs.filter((j) => (j.name ?? '').endsWith(jobName));
169+
170+
const DAILY = { type: 'cron', expression: '0 8 * * *' } as const;
171+
172+
/** One kernel's job-service wiring: the pair JobServicePlugin builds. */
173+
function kernel() {
174+
const cron = new CronJobAdapter();
175+
return { cron, db: new DbJobAdapter({ engine: makeFakeEngine(), cron }) };
176+
}
177+
178+
it('destroy() destroys the CRON adapter too, freeing the process-global name', async () => {
179+
const NAME = 'flow-time-relative:xqao_contract_expiry_reminder_flow';
180+
const k = kernel();
181+
await k.db.schedule(NAME, DAILY, async () => {});
182+
183+
const [job] = registeredFor(NAME);
184+
expect(job, 'the first bind must register a REAL croner named job').toBeDefined();
185+
expect(job.isStopped()).toBe(false);
186+
187+
// Exactly what the eviction chain reaches, one call short of which was the
188+
// whole defect.
189+
await k.db.destroy();
190+
191+
expect(job.isStopped()).toBe(true);
192+
expect(registeredFor(NAME)).toHaveLength(0);
193+
});
194+
195+
it('a rebuilt kernel re-binds the same flow: scheduled exactly once, and it fires', async () => {
196+
const NAME = 'flow-time-relative:xqao_contract_expiry_reminder_flow';
197+
const fired: string[] = [];
198+
199+
const old = kernel();
200+
await old.db.schedule(NAME, DAILY, async () => { fired.push('old-kernel'); });
201+
// Assert the FIRST bind landed before asserting anything about the second.
202+
expect(registeredFor(NAME)).toHaveLength(1);
203+
const oldJob = registeredFor(NAME)[0];
204+
205+
await old.db.destroy(); // kernel evicted by the freshness probe
206+
207+
const rebuilt = kernel();
208+
await rebuilt.db.schedule(NAME, DAILY, async () => { fired.push('new-kernel'); });
209+
210+
const held = registeredFor(NAME);
211+
expect(held).toHaveLength(1); // exactly once — not one live + one zombie
212+
expect(oldJob.isStopped()).toBe(true); // the old job is STOPPED, not merely renamed around
213+
214+
await held[0].trigger();
215+
expect(fired).toEqual(['new-kernel']); // the dead kernel's closure never runs
216+
217+
await rebuilt.db.destroy();
218+
});
219+
});

0 commit comments

Comments
 (0)