Skip to content

Commit f9a1e93

Browse files
fix(plugin-pinyin-search): the boot companion pass no longer holds readiness, and an unchanged restart reads no rows (#25690)
Fixes #25634 Clause-②: yes Metadata-protocol: none ## What was wrong (measured on `main` 9dac9eb) - `PinyinSearchPlugin.start()` hooks `kernel:bootstrapped` and awaits `backfillSearchCompanion(engine, …)`. - `ObjectKernel.bootstrap()` awaits `kernel:ready`, then `kernel:bootstrapped`, then `kernel:listening` (`packages/core/src/kernel.ts`, the boot-phase block). `kernel:listening` is where the HTTP server opens its socket. So the backfill held readiness for as long as the walk took. - The walk reads every row of every `__search`-bearing object (`keysetWalk`, 1,000 rows per page) and decides per row in JavaScript. A clean second boot repeats the same full read, because `__search = null` means both "never inspected" and "inspected, no CJK". ## What this changes All changes are inside `packages/plugins/plugin-pinyin-search`, plus the census regeneration listed below. 1. **Off the readiness path.** `kernel:bootstrapped` now *starts* the boot pass and returns without awaiting it. Writes made meanwhile are stamped by the companion hooks, which are bound at `kernel:ready` as before. A failure is logged at `warn`, not swallowed. `destroy()` aborts the pass between pages and awaits it, so it ends before the datasource plugin (destroyed after it) closes the pool. The kernel's hook dispatch is unchanged: no other plugin's contract moves. 2. **An unchanged second boot reads nothing.** When a walk of an object completes, the pass writes a completion record into **one `sys_migration` row** (`id: 'pinyin-search-companion'`). It stores, per object, the source fields and `since` (the walk's start, minus a 10-minute clock margin). A later boot walks only `updated_at >= since AND __search IS NULL`. This is the rows written since the last walk that still have no companion: rows written while the switch was off, seed rows written before the hooks were bound, and hook-bypassing writes that kept the audit stamp. Neither half of that filter works alone. `__search IS NULL` alone matches every non-CJK row forever, which is the card's point. `updated_at` alone matches every row the walk itself filled, because the walk's writes go through the non-bypassable audit stamp; that was measured red on the first draft of this branch. A changed source field, a different `SEARCH_COMPANION_RECONCILE_VERSION`, or a scan with a failed page or skipped row leaves that object to be walked in full again. 3. **Explicit rebuild unchanged.** `rebuildSearchCompanion()` (the `force: true` alias) still walks every row. It is the path for writers that bypass the hooks *and* back-date `updated_at`. 4. **Stale-blob guard.** The walk now runs while the server takes writes. Before writing a row, it re-reads that row by id (only rows about to be written pay this), so a rename that landed after the page was read is not overwritten with the old name's blob. ### The store: `sys_migration`, and why it is not a protocol surface - It is the existing deployment ledger for "has THIS deployment run this data pass" (`platform-objects/src/system/sys-migration.object.ts`). Its API is read-only (`apiMethods: ['get', 'list']`). - The receipt is written through the engine against the spec row contract `DataMigrationFlagSchema`. That is the same shape as the seed-tenancy receipt (`metadata-protocol/src/migrations/seed-tenancy-backfill.ts`, the ruled A″-2 route), whose id is spelled in its own package. - The id here is a constant of this package, not of `@objectstack/spec`: there is one writer and one reader, both here. Field reading: `verified_at: null` (no self-check), `blocking: 0` (no consumer is gated on it), `advisory` = rows skipped in the last run. - No new spec key, setting, env var or metadata marker. `Metadata-protocol: none` holds. - A kernel without `sys_migration` (no `PlatformObjectsPlugin`) keeps the old cost, a full walk each boot (now after readiness), and says so once at `warn`. - `sys_inbox_message` keeps `searchable` as it is. Search is untouched, so nothing a user reads is narrowed. ### Published surface (additive only) - `CompanionBackfillResult.pages` - `CompanionBackfillOptions.signal` - `PinyinSearchPlugin.whenReconciled()` - `@objectstack/spec` added as a runtime dependency (already in the closure through `@objectstack/objectql`). - One `patch` changeset. These three additions widen the package's public surface, so the PR declares `Clause-②: yes` (the API is additive, there is no metadata-protocol change, and `patch` stays). ## Proof All tests run through the real `ObjectKernel`, the real ObjectQL engine and `SqlDriver` on a **SQLite file** (two boots share one database), with the real `SysMigration` object (`src/boot-reconcile.integration.test.ts`): | pin | reading | |---|---| | first boot vs unchanged second boot (the card's regression test) | first boot **2500 rows / 3 queries**; unchanged second boot **0 rows / 1 query** (one filtered query answering no row) | | hook-bypassing row added after the last walk | second boot reads **1** row and fills it (`lina ln`) | | control: normal ObjectQL write while the pass is parked on page 1 | `__search` = `wangfang wf` at once | | control: explicit rebuild (`force`) | walks **2501 / 2501** rows and fills a back-dated row no watermark sees | | readiness | `bootstrap()` (through `kernel:listening`) resolves while the pass's first page is held; the pass then completes | | shutdown mid-walk | stops after page 2 of 3, writes no receipt; the next boot walks all 2500 | | no ledger | every boot walks every row (the old cost, never a missed row) | | failure logged | a throwing pass logs `warn` `companion reconcile failed` with the error | `pnpm --filter @objectstack/plugin-pinyin-search test` ⇒ Test Files 4 passed, Tests 39 passed. `typecheck` (tsc and `check:test-typecheck`) clean. `eslint --no-inline-config packages/plugins/plugin-pinyin-search` exit 0. **Ablations** (each through `scripts/ablation-replace.mjs`, restored to the HEAD blob with `git diff HEAD` empty; tests import `./*.js` relatively, so the run reads `src/` and no `dist` build is involved): - Drop the completion record (`previous = undefined`) ⇒ 2 red: `expected { queries: 3, rows: 2500 } to deeply equal { queries: 1, rows: 0 }`, and `expected 2501 to be 1`. Restored blob `08208bf4cabb`. - Await the pass inside `kernel:bootstrapped` again ⇒ the readiness pin goes red (`bootstrap()` never resolves while the page is held: test timed out). The first attempt at this leg was refused by the tool as a no-op (the replacement contained its anchor) and did not run; it was re-run with a disjoint replacement. Restored blob `28739311a522`. ## Scale note: 100 k rows, boot-to-ready, same harness both times 100,000 rows inserted straight through the driver (no hooks, `updated_at` back-dated, every 10th row CJK). SQLite file, one run each on a shared box. "Ready" = `kernel.bootstrap()` resolved, which includes `kernel:listening`. | | `main` 9dac9eb | this branch | |---|---|---| | first boot of an existing deployment, ready | **11,946 ms** | **293 ms** (the 100 k-row walk then finishes 14,352 ms after start, after readiness) | | unchanged second boot, ready | **1,033 ms** (re-reads all 100 k rows) | **676 ms** (pass done at 696 ms, 0 rows read) | The branch's first-boot walk is about 20% slower than `main`'s, from the re-read before each of the 10 k writes. It is paid once, after readiness. `main`'s second boot grows linearly with rows (production: 6.7 M rows, about 4 min 50 s on PostgreSQL). The branch's second boot reads no row. The filtered query is a scan the database runs, not rows moved into JavaScript. ## Gates (local) - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **111** commands at head `fbbc0bc41`. All 111 were run, each exit code captured before any pipe, and every one **exited 0**. `--ran` verdict: `111 derived famil(ies) accounted for — 111 run, 0 NOT-MEASURED`. Two of them, `check:skill-examples` and `check:dual-build-cjs-loads`, first exited 3 (no built `dist/` in a fresh worktree); they exited 0 after `pnpm build`. - Red on the way, fixed in this PR: - `check:tenant-audit-census`: the receipt upsert is one new engine write call site (system context, object name an imported constant). After merging `main` (which had itself moved to 239), the census is 240. Regenerated with `node scripts/tenant-audit-census.mjs --write`. The eight hand-written prose figures the gate names were updated in `content/docs/permissions/tenant-audit-census.mdx` (239 → 240, 76 → 77, 136 → 137). The historical "135 (77%)" claim the page quotes was left as quoted. - `check:engine-double-contract`: the failure-path test's fake engine carried an unused `update`; dropped. - `check:test-source-alias`: `@objectstack/core`, `@objectstack/driver-sql`, `@objectstack/platform-objects/system` and `@objectstack/spec/system` are aliased to source in this package's `vitest.config.ts`. The registry entry is unchanged. - `check:query-options-erasure`: the new test's three `as any` casts on query options were removed. - `check:optional-error-sink` (red in CI's `Lint · docs & code guards` at `8b1dea30b`): this PR had added an optional `error` channel to `MinimalLogger` beside an optional `warn`, a sink type that allows silence. Nothing calls `logger.error`, so the member was removed (`fbbc0bc41`), restoring `main`'s shape. Local exit 0. `dispatch-gates --commands` does not name this gate for this diff, so the derived list misses it. - Merge of `origin/main` at head `8b1dea30b` (merge commit, no rebase). `content/docs/permissions/tenant-audit-census.mdx`, `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` and `pnpm-lock.yaml` conflicted. All three were taken from `main` and regenerated: `pnpm install` for the lockfile, `node scripts/tenant-audit-census.mjs --write` for the census pair. On the merged head: - `node scripts/check-tenant-audit-census.mjs` exit 0 (`240 write call sites certified`), and its `--self-test` exit 0. - `node scripts/check-changeset-no-major.mjs --base origin/main` exit 0 (`This diff introduces no major bump`; the LEVEL axis reads NOT APPLICABLE locally because no `pull_request` payload exists). - `node scripts/check-adr-0087-registration.mjs --base origin/main` exit 0 (`this PR adds no declared-breaking changeset`). - Both scripts' `--self-test` exit 0. - After rebuilding the closure, `pnpm --filter @objectstack/plugin-pinyin-search test` passed (4 files, 39 tests) and `typecheck` exit 0. Re-run at `fbbc0bc41` with the same result; scoped eslint exit 0. - `eslint --no-inline-config packages/plugins/plugin-pinyin-search` (the `pnpm lint` command, scoped to this package): exit 0. - Every family CI runs (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core) is read from this PR's check-runs, not re-run here. - `Spec property liveness` is red on `main` since 0189524 (#25734), not from this PR: `translation/flows.screens` cites `engine.ts`, whose only `screens` mention moved to `run-resume.ts`; it reproduces on `origin/main` without this PR. ## Acceptance notes - Pre-existing, not changed here: the pass's companion write goes through `engine.update`, so the audit stamp moves a filled record's `updated_at` / `updated_by` to the system at the moment of the fill. That was already true of the old boot backfill and of `rebuildSearchCompanion`. The stamp is non-bypassable by design (`audit-stamp-skip-automations.test.ts`). - First boot after upgrading: each companion-bearing object is walked in full once more (no receipt yet), now after readiness. On the production deployment that is the 6.7 M-row walk, once, off the boot path. It rewrites only CJK rows that lack a blob, as before; non-CJK rows are never written. - `#23122` is not addressed here (it explains the inbox volume, not the scan). --- _Generated by [Claude Code](https://claude.ai/code/session_014yYprgEHbgKXeYT8h3XRJB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1e329e commit f9a1e93

11 files changed

Lines changed: 898 additions & 95 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/plugin-pinyin-search': patch
3+
---
4+
5+
fix(plugin-pinyin-search): the boot companion pass no longer holds readiness, and an unchanged restart reads no rows
6+
7+
Clause-②: yes
8+
Metadata-protocol: none
9+
10+
Before this change, `PinyinSearchPlugin` awaited a full walk of every row of every object that carries the `__search` companion inside `kernel:bootstrapped`. The kernel awaits that hook before `kernel:listening`, so the HTTP server opened only after the largest such table had been read. Every restart paid the same walk again, even when nothing had changed. In production, 6.7 M `sys_inbox_message` rows took about 4 min 50 s.
11+
12+
Now:
13+
14+
- The pass starts at `kernel:bootstrapped` and runs after readiness. Writes made through ObjectQL meanwhile are stamped by the companion hooks, as before. A failure is logged at `warn`. Plugin `destroy()` stops the pass between pages.
15+
- When a walk of an object completes, the pass records it in one `sys_migration` receipt row (id `pinyin-search-companion`, `blocking: 0`, `verified_at: null`). A later boot reads only the rows written since that walk that still have no companion. On an unchanged table that is one query that returns no rows. A changed display field, or a kernel without `sys_migration`, falls back to the full walk.
16+
- `rebuildSearchCompanion()` (`force: true`) still walks every row. Use it after an import that bypasses hooks and back-dates `updated_at`.
17+
- `CompanionBackfillResult` gains `pages`, `CompanionBackfillOptions` gains `signal`, and `PinyinSearchPlugin` gains `whenReconciled()`, which resolves when the boot pass ends.
18+
19+
What to do: nothing. The first boot after upgrading walks each companion-bearing object once more, after readiness. Later boots read only what changed.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **51 of the 239 sites are spelled that way**. A
135+
forwarding shim cannot, and **51 of the 240 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **239** |
200+
| 175 write call sites | quoted in the merged changeset | **240** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **163 of 239** decidable, **76** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 136 decidably elevated, 0 decidably not, 95 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **163 of 240** decidable, **77** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 137 decidably elevated, 0 decidably not, 95 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -217,11 +217,11 @@ would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
136 of 239 (57%) as decidably elevated, with 95 more whose elevation is a
220+
137 of 240 (57%) as decidably elevated, with 95 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 239`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 240`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,31 +233,31 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **239** |
236+
| write call sites on the application surface | **240** |
237237
| …whose object name is statically decidable | 163 |
238-
| …whose object name is chosen at run time | 76 |
238+
| …whose object name is chosen at run time | 77 |
239239
| …against an object with tenancy ENABLED | 162 |
240240
| …against an object that declares tenancy off | 1 |
241-
| threading a tenant context | 180 |
241+
| threading a tenant context | 181 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244244
| options argument UNREADABLE — may or may not carry one | 51 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246-
| threading a decidably ELEVATED (`isSystem`) context | 136 |
246+
| threading a decidably ELEVATED (`isSystem`) context | 137 |
247247
| threading a context that is decidably NOT elevated | 0 |
248248
| threading a context whose elevation is a run-time fact | 95 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 184 |
252+
| receiver carried a readable engine type | 185 |
253253
| receiver erased, placed by the object NAME | 45 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257257
| object name spelled inline | 112 |
258258
| object name spelled through a `const` | 51 |
259259
| object name is an `object: string` parameter | 7 |
260-
| object name is some other run-time expression | 69 |
260+
| object name is some other run-time expression | 70 |
261261

262262
### Subtractions the census could NOT defend — enforced
263263

@@ -307,11 +307,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-10-24 at `28b6d27e7`.
310+
Measured on 2026-10-25 at `ca3ca05b0`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 767 |
314+
| tracked non-test sources scanned | 768 |
315315
| engine-shaped types recognised | 87 |
316316
| declared objects in the registry | 117 |
317317
| same-named calls subtracted as non-engine | 176 |

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 239 |
36+
| Write call sites | 240 |
3737
| Object name statically decidable | 163 |
38-
| Object name chosen at run time | 76 |
38+
| Object name chosen at run time | 77 |
3939
| Against a tenancy-enabled object | 162 |
4040
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 180 |
41+
| Threading a tenant context | 181 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
4444
| Options argument unreadable | 51 |
4545
| …and decidably tenancy-enabled | 29 |
46-
| Threading a decidably elevated context | 136 |
46+
| Threading a decidably elevated context | 137 |
4747
| Threading a decidably non-elevated context | 0 |
4848
| Threading a context of undecidable elevation | 95 |
4949

@@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-10-24 at `28b6d27e7`.
93+
Measured on 2026-10-25 at `ca3ca05b0`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 767 |
97+
| tracked non-test sources scanned | 768 |
9898
| engine-shaped types recognised | 87 |
9999
| declared objects in the registry | 117 |
100100
| same-named calls subtracted as non-engine | 176 |
@@ -169,6 +169,7 @@ Measured on 2026-10-24 at `28b6d27e7`.
169169
| `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `object` | undecidable | elevated | 1 |
170170
| `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `object` | undecidable | elevated | 1 |
171171
| `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `object` | undecidable | elevated | 1 |
172+
| `packages/plugins/plugin-pinyin-search/src/boot-reconcile.ts` | `update` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 |
172173
| `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 |
173174
| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 |
174175
| `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 |

‎packages/plugins/plugin-pinyin-search/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@
2121
"dependencies": {
2222
"@objectstack/core": "workspace:*",
2323
"@objectstack/objectql": "workspace:*",
24+
"@objectstack/spec": "workspace:*",
2425
"@objectstack/types": "workspace:*",
2526
"pinyin-pro": "^3.29.4"
2627
},
2728
"devDependencies": {
29+
"@objectstack/driver-sql": "workspace:*",
30+
"@objectstack/platform-objects": "workspace:*",
2831
"@types/node": "^26.6.3",
2932
"tsx": "^4.23.15",
3033
"typescript": "^6.0.3",

0 commit comments

Comments
 (0)