Skip to content

Commit 07d4deb

Browse files
committed
fix(runtime): a package's job keeps its own identity on the job service when another package declares the same name
The metadata registry keys a packaged item by <packageId>:<name>; the job service keys by one string and replaces. scheduleAppArtifactJobs now schedules a job under its authored name unless another package already holds that name, and then under the registry's package-scoped key, so both jobs run. The ownership record maps each authored name to the key it was scheduled under, so a replace or an uninstall cancels only its own package's job. A runtime where no two packages share a job name schedules every job under its authored name, unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
1 parent 913d8e8 commit 07d4deb

2 files changed

Lines changed: 273 additions & 43 deletions

File tree

‎packages/runtime/src/app-artifact-handlers.jobs.test.ts‎

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
* - the job's `timeoutMs` is the body's one limit; with none, the runner's
2222
* JOB default applies — not the hook's, not the action's;
2323
* - the body's return is read as a `JobRunOutcome`, in that shape only;
24-
* - a body's `ctx` carries no job name and no trigger data.
24+
* - a body's `ctx` carries no job name and no trigger data;
25+
* - (#21602) two packages' jobs of the same name each keep their own identity
26+
* on the job service — the first its authored name, a later one the
27+
* registry's package-scoped key — so neither replaces the other, and a
28+
* replace or an uninstall cancels only its own package's job; a package
29+
* whose names collide with no other's is scheduled under its authored names.
2530
*/
2631

2732
import { describe, it, expect, vi } from 'vitest';
@@ -311,16 +316,17 @@ describe('#21489: re-scheduling replaces — a job the new version does not sche
311316
expect(h.jobs.scheduled.size).toBe(0);
312317
});
313318

314-
it("another app's jobs are never cancelled — not even one that took over a name this app once scheduled", async () => {
319+
it("another app's jobs are never cancelled — not even its job of the same name", async () => {
315320
const h = harness();
316321
await h.schedule(pkg([job('shared_name'), job('mine_only')]), APP_ID);
317322
await h.schedule(pkg([job('shared_name'), job('theirs_only')]), 'com.example.other');
318323

319-
// This app's next version drops both: only its own remaining job stops.
324+
// This app's next version drops both: its own two stop, the other app's two run on.
320325
const out = await h.schedule(pkg([]), APP_ID);
321326

322-
expect(out.cancelled).toEqual(['mine_only']);
323-
expect([...h.jobs.scheduled.keys()].sort()).toEqual(['shared_name', 'theirs_only']);
327+
expect(out.cancelled.sort()).toEqual(['mine_only', 'shared_name']);
328+
expect(h.jobs.cancels.sort()).toEqual(['mine_only', 'shared_name']);
329+
expect([...h.jobs.scheduled.keys()].sort()).toEqual(['com.example.other:shared_name', 'theirs_only']);
324330
});
325331

326332
it('a cancel that throws is said at error, and the job stays on the record for the next attempt', async () => {
@@ -382,6 +388,152 @@ describe('#21489: the uninstall cleanup cancels the uninstalled package\'s jobs'
382388
});
383389
});
384390

391+
describe('#21602: two packages declaring the same job name — each job keeps its own identity on the job service', () => {
392+
const OTHER = 'com.example.other';
393+
const THIRD = 'com.example.third';
394+
/** A body job whose run writes a row carrying `marker`, so a run is attributable to its package. */
395+
const markedJob = (name: string, marker: string) => ({
396+
name,
397+
schedule: INTERVAL,
398+
body: { ...WRITE_BODY, source: `await ctx.api.object('${TICK}').insert({ name: '${marker}' });` },
399+
});
400+
/** Run whatever the job service holds under `key` once; the marker its body wrote. */
401+
const runKey = async (h: ReturnType<typeof harness>, key: string) => {
402+
const before = h.engine.writes.length;
403+
await h.jobs.scheduled.get(key)!.run({ jobId: key });
404+
return h.engine.writes.slice(before).map((w) => (w.data as { name: string }).name);
405+
};
406+
407+
it('a single package: every job is scheduled under its AUTHORED name, and a reinstall keeps it (control)', async () => {
408+
const h = harness();
409+
410+
const first = await h.schedule(pkg([markedJob('a_job', 'a'), markedJob('b_job', 'b')]));
411+
const again = await h.schedule(pkg([markedJob('a_job', 'a'), markedJob('b_job', 'b')]));
412+
413+
expect([...h.jobs.scheduled.keys()].sort()).toEqual(['a_job', 'b_job']);
414+
expect(first.bodies.sort()).toEqual(['a_job', 'b_job']);
415+
expect(again.bodies.sort()).toEqual(['a_job', 'b_job']);
416+
expect(again.cancelled).toEqual([]);
417+
expect(h.jobs.cancels).toEqual([]);
418+
expect(h.logger.info.mock.calls.some((c) => String(c[0]).includes('package-scoped identity'))).toBe(false);
419+
});
420+
421+
it('the second package is scheduled under the registry\'s package-scoped key — the first package\'s job is NOT replaced', async () => {
422+
const h = harness();
423+
await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID);
424+
425+
const out = await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER);
426+
427+
// Both are scheduled; nothing was cancelled or replaced.
428+
expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']);
429+
expect(h.jobs.cancels).toEqual([]);
430+
// Each key runs its OWN package's body.
431+
expect(await runKey(h, 'shared_tick')).toEqual(['mine']);
432+
expect(await runKey(h, `${OTHER}:shared_tick`)).toEqual(['theirs']);
433+
// The binder's own answer keeps the authored name.
434+
expect(out.bodies).toEqual(['shared_tick']);
435+
// Said once, naming the package that holds the name and the key it is catalogued under.
436+
const said = h.logger.info.mock.calls.filter((c) => String(c[0]).includes('package-scoped identity'));
437+
expect(said).toHaveLength(1);
438+
expect(said[0][1]).toEqual({ appId: OTHER, job: 'shared_tick', scheduledAs: `${OTHER}:shared_tick`, heldBy: APP_ID });
439+
});
440+
441+
it('a reinstall of either package replaces its OWN job under the key it holds — never the other package\'s', async () => {
442+
const h = harness();
443+
await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID);
444+
await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER);
445+
446+
await h.schedule(pkg([markedJob('shared_tick', 'theirs_v2')]), OTHER);
447+
await h.schedule(pkg([markedJob('shared_tick', 'mine_v2')]), APP_ID);
448+
449+
expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']);
450+
expect(h.jobs.cancels).toEqual([]);
451+
expect(await runKey(h, 'shared_tick')).toEqual(['mine_v2']);
452+
expect(await runKey(h, `${OTHER}:shared_tick`)).toEqual(['theirs_v2']);
453+
});
454+
455+
it('uninstalling the scoped holder cancels only its scoped key; the holder of the authored name runs on', async () => {
456+
const h = harness({ withProtocol: true });
457+
await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID);
458+
await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER);
459+
460+
const result = await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: OTHER });
461+
462+
expect(result).toEqual({ success: true, removed: 1 });
463+
expect(h.jobs.cancels).toEqual([`${OTHER}:shared_tick`]);
464+
expect([...h.jobs.scheduled.keys()]).toEqual(['shared_tick']);
465+
expect(await runKey(h, 'shared_tick')).toEqual(['mine']);
466+
});
467+
468+
it('uninstalling the holder of the authored name cancels only that; the scoped holder runs on', async () => {
469+
const h = harness({ withProtocol: true });
470+
await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID);
471+
await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER);
472+
473+
const result = await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: APP_ID });
474+
475+
expect(result).toEqual({ success: true, removed: 1 });
476+
expect(h.jobs.cancels).toEqual(['shared_tick']);
477+
expect([...h.jobs.scheduled.keys()]).toEqual([`${OTHER}:shared_tick`]);
478+
expect(await runKey(h, `${OTHER}:shared_tick`)).toEqual(['theirs']);
479+
});
480+
481+
it('a version of the scoped holder that drops the job cancels its scoped key only', async () => {
482+
const h = harness();
483+
await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID);
484+
await h.schedule(pkg([markedJob('shared_tick', 'theirs'), markedJob('other_only', 'o')]), OTHER);
485+
486+
const out = await h.schedule(pkg([markedJob('other_only', 'o')]), OTHER);
487+
488+
expect(out.cancelled).toEqual(['shared_tick']);
489+
expect(h.jobs.cancels).toEqual([`${OTHER}:shared_tick`]);
490+
expect([...h.jobs.scheduled.keys()].sort()).toEqual(['other_only', 'shared_tick']);
491+
});
492+
493+
it('a third package is scoped too; once the holder of the authored name is gone, a newcomer takes it', async () => {
494+
const h = harness({ withProtocol: true });
495+
await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID);
496+
await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER);
497+
await h.schedule(pkg([markedJob('shared_tick', 'third')]), THIRD);
498+
expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, `${THIRD}:shared_tick`, 'shared_tick']);
499+
500+
await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: APP_ID });
501+
await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: THIRD });
502+
await h.schedule(pkg([markedJob('shared_tick', 'fresh')]), 'com.example.fresh');
503+
504+
// The scoped holder keeps its key: a running job's catalogue name never moves under it.
505+
expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']);
506+
expect(await runKey(h, 'shared_tick')).toEqual(['fresh']);
507+
expect(await runKey(h, `${OTHER}:shared_tick`)).toEqual(['theirs']);
508+
});
509+
510+
it("a handler job scheduled under a scoped key still hands its handler the AUTHORED name as jobId", async () => {
511+
const h = harness();
512+
let seen: any;
513+
const tick = vi.fn(async (c: any) => { seen = c; });
514+
await h.schedule(pkg([{ name: 'shared_tick', schedule: INTERVAL, handler: 'tick' }], { functions: { tick } }), APP_ID);
515+
await h.schedule(pkg([{ name: 'shared_tick', schedule: INTERVAL, handler: 'tick' }], { functions: { tick } }), OTHER);
516+
517+
await h.jobs.scheduled.get(`${OTHER}:shared_tick`)!.run({ jobId: `${OTHER}:shared_tick` });
518+
519+
expect(seen.jobId).toBe('shared_tick');
520+
});
521+
522+
it('a scoped job the uninstall could not cancel is named with the key it is scheduled under', async () => {
523+
const h = harness({ withProtocol: true, failCancel: `${OTHER}:shared_tick` });
524+
await h.schedule(pkg([markedJob('shared_tick', 'mine')]), APP_ID);
525+
await h.schedule(pkg([markedJob('shared_tick', 'theirs')]), OTHER);
526+
527+
const result = await h.reg.cleanups.get(PACKAGE_JOBS_UNINSTALL_CLEANUP)!({ packageId: OTHER });
528+
529+
expect(result.success).toBe(false);
530+
expect(result.removed).toBe(0);
531+
expect(result.error).toContain(`shared_tick (scheduled as ${OTHER}:shared_tick)`);
532+
// The holder of the authored name was never touched.
533+
expect([...h.jobs.scheduled.keys()].sort()).toEqual([`${OTHER}:shared_tick`, 'shared_tick']);
534+
});
535+
});
536+
385537
describe('#21489: the sandbox job origin', () => {
386538
it("a job body with no timeoutMs gets the runner's JOB default — not the hook's, not the action's", async () => {
387539
const runner = new QuickJSScriptRunner({ jobTimeoutMs: 30, hookTimeoutMs: 5_000, actionTimeoutMs: 5_000 });

0 commit comments

Comments
 (0)