Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/31679-job-key-package-coordinate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/runtime': patch
---

fix(runtime): a packaged job keeps the job-service key its catalogue holds for it across a restart (ADR-0150 D4)

Clause-②: no
Metadata-protocol: none

Who is affected: a runtime where two installed packages declare a job of the same name, on the database-backed job service (`sys_job` / `sys_job_run`). Nothing else moves: a runtime where no two packages share a job name schedules every job under its authored name, as before.

A job's identity is its package coordinate, the package id and the job name. The first package to hold a job name keeps the authored name on the job service, and a second package keeps the package-scoped `<packageId>:<name>`, as before. What changes is a restart. Before, the bare name went to whichever package the boot scheduled first, so a restart that rehydrated the packages in another order than they were installed in handed the first package's catalogue row and run history to the second package's job, and moved the first job under a new name. Now a package whose `<packageId>:<name>` the job catalogue still holds active (scheduled by an earlier run and never cancelled) is scheduled under it again, whatever the order. A job stored under its bare name before this release is scheduled under it again and fires as before. Nothing is refused and no author action is needed.
160 changes: 160 additions & 0 deletions packages/runtime/src/app-artifact-handlers.jobs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
*/

import { describe, it, expect, vi } from 'vitest';
// Paid at collection, not inside a clocked case: `AppPlugin` (imported below)
// loads `@objectstack/metadata` dynamically (check:test-source-alias).
import '@objectstack/metadata';
import type { PluginContext } from '@objectstack/core';
import { scheduleAppArtifactJobs, collectJobsWithoutBody, PACKAGE_JOBS_UNINSTALL_CLEANUP } from './app-artifact-handlers.js';
import { jobBodyRunnerFactory } from './sandbox/body-runner.js';
Expand Down Expand Up @@ -538,6 +541,163 @@ describe('#21602: two packages declaring the same job name — each job keeps it
});
});

describe('ADR-0150 E7: a job\'s package coordinate keeps the key the job catalogue holds for it, across a restart', () => {
const OTHER = 'com.example.other';
const markedJob = (name: string, marker: string) => ({
name,
schedule: INTERVAL,
body: { ...WRITE_BODY, source: `await ctx.api.object('${TICK}').insert({ name: '${marker}' });` },
});

/**
* One `sys_job` catalogue that outlives a process, read the way
* `DbJobAdapter.readArming` reads it — `schedule` writes the row active,
* `cancel` clears `active`, and a process that stops leaves every row as it
* was — and one job service per process (a restart is a new kernel, so
* nothing of the previous process's in-memory record survives).
*/
function catalogue(opts: { readFails?: boolean } = {}) {
const rows = new Map<string, { active: boolean }>();
const engine = recordingEngine();
const boot = () => {
const scheduled = new Map<string, (c: any) => Promise<unknown>>();
const cleanups = new Map<string, (args: { packageId: string }) => Promise<unknown>>();
const svc = {
schedule: async (name: string, _schedule: unknown, run: (c: any) => Promise<unknown>) => {
scheduled.set(name, run);
rows.set(name, { active: true });
},
cancel: async (name: string) => {
scheduled.delete(name);
const row = rows.get(name);
if (row) row.active = false;
},
trigger: async () => undefined,
readArming: async (name: string) => {
if (opts.readFails) throw new Error('sys_job unreadable');
const row = rows.get(name);
return row ? { active: row.active, schedule: null, lastAliveAt: null } : null;
},
};
const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const ctx = {
logger,
getService: (name: string) => {
if (name === 'job') return svc;
if (name === 'protocol') return { registerUninstallCleanup: (n: string, f: any) => cleanups.set(n, f) };
throw new Error(`no ${name}`);
},
} as unknown as PluginContext;
return {
logger,
keys: () => [...scheduled.keys()].sort(),
schedule: (packageId: string, marker: string) =>
scheduleAppArtifactJobs(ctx, { ...pkg([markedJob('shared_tick', marker)]), id: packageId }, { appId: packageId, ql: engine.ql as any, source: 'Test' }),
uninstall: (packageId: string) => cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId }),
/** Fire whatever this process holds under `key` once; the marker its body wrote. */
fire: async (key: string) => {
const before = engine.writes.length;
await scheduled.get(key)!({ jobId: key });
return engine.writes.slice(before).map((w) => (w.data as { name: string }).name);
},
};
};
return { rows, boot };
}

it('a job stored under its bare key before the upgrade is scheduled under it again, and fires', async () => {
const store = catalogue();
// The catalogue as an earlier release left it: the job's row under its authored name.
store.rows.set('shared_tick', { active: true });

const after = store.boot();
const out = await after.schedule(APP_ID, 'mine');

expect(out.bodies).toEqual(['shared_tick']);
expect(after.keys()).toEqual(['shared_tick']);
expect(await after.fire('shared_tick')).toEqual(['mine']);
});

it('a restart in the install order schedules every job under the key it had (control)', async () => {
const store = catalogue();
const first = store.boot();
await first.schedule(APP_ID, 'mine');
await first.schedule(OTHER, 'theirs');

const second = store.boot();
await second.schedule(APP_ID, 'mine');
await second.schedule(OTHER, 'theirs');

expect(second.keys()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']);
expect(await second.fire('shared_tick')).toEqual(['mine']);
expect(await second.fire(`${OTHER}:shared_tick`)).toEqual(['theirs']);
});

it('a restart that schedules the second package FIRST still hands it its own scoped key — never the first package\'s bare one', async () => {
const store = catalogue();
const first = store.boot();
await first.schedule(APP_ID, 'mine');
await first.schedule(OTHER, 'theirs');

const second = store.boot();
await second.schedule(OTHER, 'theirs');
await second.schedule(APP_ID, 'mine');

expect(second.keys()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']);
// Each catalogue row keeps running its OWN package's body.
expect(await second.fire('shared_tick')).toEqual(['mine']);
expect(await second.fire(`${OTHER}:shared_tick`)).toEqual(['theirs']);
// Said once, naming the key and where it came from.
const said = second.logger.info.mock.calls.filter((c) => String(c[0]).includes('keeps its package-scoped identity'));
expect(said).toHaveLength(1);
expect(said[0][1]).toEqual({ appId: OTHER, job: 'shared_tick', scheduledAs: `${OTHER}:shared_tick` });
});

it('a scoped holder whose bare holder was uninstalled keeps its scoped key across a restart, as it does in-process', async () => {
const store = catalogue();
const first = store.boot();
await first.schedule(APP_ID, 'mine');
await first.schedule(OTHER, 'theirs');
await first.uninstall(APP_ID);
expect(first.keys()).toEqual([`${OTHER}:shared_tick`]);

const second = store.boot();
await second.schedule(OTHER, 'theirs');

expect(second.keys()).toEqual([`${OTHER}:shared_tick`]);
expect(await second.fire(`${OTHER}:shared_tick`)).toEqual(['theirs']);
});

it('a scoped key the catalogue holds CANCELLED keeps nothing: the package, alone after a restart, takes its authored name', async () => {
const store = catalogue();
const first = store.boot();
await first.schedule(APP_ID, 'mine');
await first.schedule(OTHER, 'theirs');
await first.uninstall(OTHER);
await first.uninstall(APP_ID);

const second = store.boot();
await second.schedule(OTHER, 'theirs');

expect(second.keys()).toEqual(['shared_tick']);
expect(second.logger.info.mock.calls.some((c) => String(c[0]).includes('keeps its package-scoped identity'))).toBe(false);
});

it('a catalogue read that fails schedules the job anyway, by this run\'s order, and says so at warn', async () => {
const store = catalogue({ readFails: true });
const boot = store.boot();

const out = await boot.schedule(OTHER, 'theirs');

expect(out.bodies).toEqual(['shared_tick']);
expect(out.failed).toEqual([]);
expect(boot.keys()).toEqual(['shared_tick']);
const warned = boot.logger.warn.mock.calls.filter((c) => String(c[0]).includes('could not read the job catalogue'));
expect(warned).toHaveLength(1);
expect(warned[0][1]).toMatchObject({ appId: OTHER, job: 'shared_tick' });
});
});

describe('#21489: the sandbox job origin', () => {
it("a job body with no timeoutMs gets the runner's JOB default — not the hook's, not the action's", async () => {
const runner = new QuickJSScriptRunner({ jobTimeoutMs: 30, hookTimeoutMs: 5_000, actionTimeoutMs: 5_000 });
Expand Down
Loading
Loading