Repository navigation
Commit 6946f2f
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
- .changeset
- packages
- cli/test
- runtime/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
0 commit comments