Skip to content

Commit 6946f2f

Browse files
fix(runtime): two packages declaring the same job name both run, and uninstalling one stops only its own job (#21633)
Fixes #21602 Clause-②: no ## What changes The metadata registry keys a packaged item by package and name (`PACKAGE_ID:JOB_NAME`), so two packages may each declare a job called `shared_tick`. `IJobService` keys a job by one string and replaces any job with the same name. Before this change, `scheduleAppArtifactJobs` passed the bare authored name, so the second package's install silently replaced the first package's job. `scheduleAppArtifactJobs` (`packages/runtime/src/app-artifact-handlers.ts`) now asks `jobKeyFor` for the job service's name for each job. The answers are checked in this order: 1. The key this app already holds the job under. A reinstall replaces its own job and keeps its catalogue row and run history. 2. Otherwise, the authored name, when no other package holds it on this job service. 3. Otherwise, the registry's package-scoped key `PACKAGE_ID:JOB_NAME`. No authored name can equal it, because `JobSchema.name` is snake_case and never contains a `:`. When this branch applies, an `info` line names the package that holds the authored name and the key used. The ownership record from the job-half PR (#21584) now maps each authored job name to the key it was scheduled under, per app. `retireAppJobs` (replace) and the `runtime.package-jobs` uninstall cleanup cancel by that key, so neither path ever cancels another package's job. There is no `IJobService` contract change, no `packages/spec` change and no `packages/services/**` change. Function-name resolution for hooks belongs to the maintainer's #21604 decision and is not touched here. ## A1: reach, at the public door, on `origin/main` `045b946256` The new pin `packages/cli/test/package-install-local-jobs-shared-name.integration.test.ts` was run against the base `dist/`, before the change. Three packages each declare a body job `shared_tick`, and each writes rows with its own marker into the host's object. Result: **4 failed, 5 passed**. - `ALPHA's job stopped once BETA and GAMMA installed: expected 8 to be greater than 8`. ALPHA wrote no new row after the later installs. - The `sys_job` catalogue read `[ 'shared_tick' ]`: one row for three declared jobs. - `ALPHA's job stopped at BETA's uninstall` (it had already stopped). - `ALPHA's job did not run after the restart`: the rehydrate displaced it again. ## A2: census of every reader of the scheduled identity Found by symbol walk: every `IJobService` implementation, every caller of `schedule` / `cancel` / `trigger` / `replay` / `getExecutions` / `listJobs` / `listExecutionsByStatus` outside `service-job`, and every `sys_job` / `sys_job_run` reader. | Reader | Keys by the scheduled name? | What the package-scoped key does to it | |:---|:---|:---| | `IntervalJobAdapter` (`jobs` map; `schedule`/`register`/`cancel`/`trigger`/`getExecutions`/`listJobs`) | yes | An opaque string, used consistently by every verb. No shape check. | | `CronJobAdapter` (`jobs` map; croner registry name `NAMESPACE::NAME`) | yes | Same. The registry name already contains `::`, so a `:` in the key is inert. | | `DbJobAdapter`: `sys_job` row (`upsertJobRow` / `setActive` / `bumpJob`, all `where: { name }`) | yes | The row is named by the key. `sys_job.name` is `unique: 'global'`, so two coexisting jobs **need** two strings there. | | Run history: `sys_job_run.job_name` (`startRun`), in-memory `executions`, `listExecutionsByStatus` (`jobId: r.job_name`) | yes | Run rows carry the key. | | `core` fallback `memory-job.ts` | yes | Same as the interval adapter. | | Handler context `{ jobId }` the adapters pass to a run | yes | Not visible to job code. A `handler` job's context overrides `jobId` with the **authored** name (unchanged line, pinned for a scoped job). A body's `ctx` carries no job name (`jobBodyRunnerFactory` logs and tags `origin` with the authored `job.name`). | | Admin listings | not directly | No REST route or MCP tool in this repo lists, triggers, cancels or replays a package job. The Setup "Background Jobs" grid reads `sys_job` / `sys_job_run` through the generic data API (`apiMethods: ['get','list']`). | | Cancel paths: `retireAppJobs`, the `runtime.package-jobs` uninstall cleanup | yes | Now cancel by the recorded key, so only the uninstalling or replacing package's job stops. | | Other schedulers on the same service (`flow-schedule:*`, `flow-time-relative:*`, `flow-wait:*`, `approvals-sla-escalation`) | their own names | They never read a package job's name. A snake_case authored name cannot equal theirs, because theirs contain `-`. | | Trigger / replay paths | n/a | No caller for a package job in this repo. | | Metrics (`jobScheduleFailuresTotal` label `job`), log lines, the binder's own return arrays | authored name | Unchanged. Logs add `scheduledAs` meta. | **What the readers show, measured at the door after the change:** - Single package: `sys_job` names `[shared_tick]` and `sys_job_run.job_name` `[shared_tick]`, the authored name. This matches the pre-change reading of the same phase. - After two more packages declared the same name: `sys_job` and `sys_job_run` both read `[com.example.sharedbeta:shared_tick, com.example.sharedgamma:shared_tick, shared_tick]`. The first package keeps the authored name, its row and its history. Only the later colliding packages read the scoped identity. ## A3: the carrier `JobScheduleOptions` (`packages/spec/src/contracts/job-service.ts`) holds only `retryPolicy` and `timeoutMs`, so no existing field can carry the package. The scope rides on the `name` argument, and only when another package already holds the name. A runtime in which no two packages share a job name schedules every job under its authored name, so its visible names and run history are unchanged. That is pinned in the unit suite and at the door. No reader outside `domain:cli` needed an edit. ## A4: pins **Unit** (`packages/runtime/src/app-artifact-handlers.jobs.test.ts`, new describe block): - A single package's jobs keep their authored names across a reinstall (control). - The second package gets `PACKAGE_ID:shared_tick`, nothing is cancelled, and each key runs its own package's body. The `info` line names the holder. - A reinstall of either package replaces only its own job. - Uninstalling the scoped holder cancels only its scoped key, and uninstalling the bare-name holder cancels only the bare name. - When the scoped holder's next version drops the job, only its scoped key is cancelled. - A third package is also scoped. Once the bare-name holder is gone, a newcomer takes the bare name and the scoped holder keeps its key. - A handler job under a scoped key still receives the authored `jobId`. - A failed uninstall cancel names `shared_tick (scheduled as PACKAGE_ID:shared_tick)`. The #21489 test that pinned last-scheduler-wins (`another app's jobs are never cancelled — not even one that took over a name…`) pinned exactly the branch this change removes. It is replaced: this app's empty version now cancels its own `shared_name` and `mine_only`, and the other app's `com.example.other:shared_name` and `theirs_only` keep running. **Install-local, real built packages** (`package-install-local-jobs-shared-name.integration.test.ts`, 9 cases): - Single-package catalogue reads the authored name. - ALPHA keeps running after BETA and GAMMA install, and both later jobs run. - The catalogue shows the scoped identities. - Uninstalling BETA (a scoped holder) stops BETA only. - ALPHA and GAMMA run after a restart. - Uninstalling ALPHA (the bare-name holder) stops ALPHA only. ## A5: reverse verification (ablation), run at `07d4deb5fb` - **Mutation**, through `scripts/ablation-replace.mjs` in WRAP mode. Anchor ``{ key: `${appId}:${jobName}`, heldBy }`` (x1 to x0), replaced by `{ key: jobName, heldBy }` (x0 to x1). Blob `dc88fbdf0207` changed to `e523712b60f5`. - **Unit**, mutated: **9 failed / 26 passed**. Every #21602 collision pin and the rewritten #21489 cross-app pin went red. The single-package control (`a single package: every job is scheduled under its AUTHORED name…`) stayed green. - **Dist**: `pnpm --filter @objectstack/runtime build`, then `ablation-dist-preflight.mjs @objectstack/runtime 'key: jobName, heldBy'` reported the marker present in 2 built files (`index.js`, `index.cjs`). - **Install-local**, mutated: **4 failed / 5 passed**. The same four that were red on base went red again. The single-package catalogue case stayed green. - **Restore**: blob after restore `dc88fbdf0207` equals blob at HEAD, and `git diff HEAD` is empty. The restore-leg rebuild was checked with `ablation-dist-preflight.mjs … --absent`: the marker is absent from all 6 built files and the tree is clean against HEAD. Then unit **35/35** and install-local **9/9**. ## Tests Final head `2279c378f6`. Its only change from `2f9097c7c2` is a one-line doc comment in `app-artifact-handlers.ts`. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 62 commands. All 62 were run on `2279c378f6`, each exit 0. `--ran` reports: `62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN`. I also ran the 5 roster gates whose roster sits under a touched directory (`check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:route-ledger-census`): all exit 0. - **Lint.** `pnpm lint` (full repo, `eslint . --no-inline-config`) exit 0 on `2279c378f6`. - **Runtime unit pins and typecheck** on `2279c378f6`: `app-artifact-handlers.jobs.test.ts` 35/35. `pnpm --filter @objectstack/runtime typecheck` OK. - **Full suites**, run on `2f9097c7c2`, which has the same source apart from the comment: - `@objectstack/runtime`: 318 files, 4530 passed, 19 skipped. - `@objectstack/cloud-connection`: 36 files, 437 passed. - `@objectstack/cli --project unit`: 255 files, 3745 passed. This includes the tier-partition pin, which classifies the new file as integration. - `@objectstack/cli` typecheck OK. The new test file is in `tsconfig.test.json`'s program (counted with `--listFilesOnly`). - **Install-local pins on built `dist/`:** - `package-install-local-jobs.integration.test.ts` and `…-jobs-shared-name.integration.test.ts`: 20/20. - `package-install-local-uninstall-cleanups.integration.test.ts`: 16/16. - **Liveness evidence.** `pnpm --filter @objectstack/spec exec vitest run --project repo scripts/liveness/evidence.test.ts`: 42/42. No exported symbol was renamed. The internal `claimJobName` became `claimJobKey`, and no ledger names it. ## Acceptance notes - **Collision case only: the later package's job is visible under its scoped key.** In `sys_job` / `sys_job_run` it reads `PACKAGE_ID:JOB_NAME`. This is forced by `sys_job.name` being `unique: 'global'`: two jobs that both run need two strings there. No reader shows a moved name in a runtime where names do not collide, and that is pinned. `Clause-②: no` is copied from the claim and not re-declared. - **Restart order, read from code, not measured.** On a restart, the bare name goes to the first package the boot schedules. Install-local rehydrates in ledger file-name order (`readdirSync`), while hot installs schedule in install order. Suppose a colliding package that was installed later sorts first. The two then swap names at the restart, and the `JOB_NAME` row's run history continues with the other package's runs. Both jobs still run. The pin's package ids sort in install order. Carrier: none. - **`sys_job.active` is not reconciled on boot, read from code, not measured.** `DbJobAdapter` never resets the flag at startup, so a key that nothing schedules after a restart keeps `active: true`. This already applies to any job dropped between boots. Carrier: none. - **Liveness ledger wording.** `packages/spec/liveness/job.json`'s `name` evidence says the name "is the scheduling key passed to `svc.schedule`". That is still true except in the collision case. The quoted anchor still resolves (evidence test green). `packages/spec` is outside this card's lane. Carrier: none. - #21604 is not addressed here (hook function-name resolution). --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a0debb commit 6946f2f

4 files changed

Lines changed: 760 additions & 43 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
fix(runtime): when two packages declare a job with the same name, both jobs now run. Uninstalling one stops only its own job.
6+
7+
Clause-②: no
8+
9+
The metadata registry keys a packaged item by package and name (`<packageId>:<name>`), so two packages may each declare a job called, say, `nightly_sync`. The job service keys a job by one string and replaces any job with the same name. Before this fix, installing the second package (`os package install`, or a second app on one boot) silently replaced the first package's job. That job stopped running while both installs reported success.
10+
11+
- **Both jobs run.** A job is scheduled under its authored name unless another package already holds that name on the job service. In that case it is scheduled under the registry's package-scoped identity, `<packageId>:<name>`, and an `info` line names the package that holds the name. A package's job body and its `handler`'s `jobId` still see the authored name.
12+
- **What an operator sees.** The Background Jobs catalogue (`sys_job`) and run history (`sys_job_run`) list the job under the name it is scheduled under. That is the authored name, or `<packageId>:<name>` for a package whose job name another package already holds. A reinstall keeps the name the job already has.
13+
- **Each package cancels only its own job.** When a reinstall drops a job, or the package is uninstalled (the `runtime.package-jobs` uninstall cleanup), the job is cancelled under the name it was scheduled under. Another package's job with the same name keeps running.
14+
- **Unchanged:** a runtime in which no two packages declare the same job name schedules every job under its authored name, so its catalogue and run history read exactly as before. No schema, export, `IJobService` contract or accept-set change.

0 commit comments

Comments
 (0)