|
21 | 21 | * - the job's `timeoutMs` is the body's one limit; with none, the runner's |
22 | 22 | * JOB default applies — not the hook's, not the action's; |
23 | 23 | * - 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. |
25 | 30 | */ |
26 | 31 |
|
27 | 32 | import { describe, it, expect, vi } from 'vitest'; |
@@ -311,16 +316,17 @@ describe('#21489: re-scheduling replaces — a job the new version does not sche |
311 | 316 | expect(h.jobs.scheduled.size).toBe(0); |
312 | 317 | }); |
313 | 318 |
|
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 () => { |
315 | 320 | const h = harness(); |
316 | 321 | await h.schedule(pkg([job('shared_name'), job('mine_only')]), APP_ID); |
317 | 322 | await h.schedule(pkg([job('shared_name'), job('theirs_only')]), 'com.example.other'); |
318 | 323 |
|
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. |
320 | 325 | const out = await h.schedule(pkg([]), APP_ID); |
321 | 326 |
|
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']); |
324 | 330 | }); |
325 | 331 |
|
326 | 332 | 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' |
382 | 388 | }); |
383 | 389 | }); |
384 | 390 |
|
| 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 | + |
385 | 537 | describe('#21489: the sandbox job origin', () => { |
386 | 538 | it("a job body with no timeoutMs gets the runner's JOB default — not the hook's, not the action's", async () => { |
387 | 539 | const runner = new QuickJSScriptRunner({ jobTimeoutMs: 30, hookTimeoutMs: 5_000, actionTimeoutMs: 5_000 }); |
|
0 commit comments