Repository navigation
Commit f9a1e93
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
File tree
- .changeset
- content/docs/permissions
- docs/audits
- packages/plugins/plugin-pinyin-search
- 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 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
132 | 132 | | |
133 | 133 | | |
134 | 134 | | |
135 | | - | |
| 135 | + | |
136 | 136 | | |
137 | 137 | | |
138 | 138 | | |
| |||
197 | 197 | | |
198 | 198 | | |
199 | 199 | | |
200 | | - | |
| 200 | + | |
201 | 201 | | |
202 | | - | |
203 | | - | |
| 202 | + | |
| 203 | + | |
204 | 204 | | |
205 | 205 | | |
206 | 206 | | |
| |||
217 | 217 | | |
218 | 218 | | |
219 | 219 | | |
220 | | - | |
| 220 | + | |
221 | 221 | | |
222 | 222 | | |
223 | 223 | | |
224 | | - | |
| 224 | + | |
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
| |||
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
| 236 | + | |
237 | 237 | | |
238 | | - | |
| 238 | + | |
239 | 239 | | |
240 | 240 | | |
241 | | - | |
| 241 | + | |
242 | 242 | | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | | - | |
| 246 | + | |
247 | 247 | | |
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
251 | 251 | | |
252 | | - | |
| 252 | + | |
253 | 253 | | |
254 | 254 | | |
255 | 255 | | |
256 | 256 | | |
257 | 257 | | |
258 | 258 | | |
259 | 259 | | |
260 | | - | |
| 260 | + | |
261 | 261 | | |
262 | 262 | | |
263 | 263 | | |
| |||
307 | 307 | | |
308 | 308 | | |
309 | 309 | | |
310 | | - | |
| 310 | + | |
311 | 311 | | |
312 | 312 | | |
313 | 313 | | |
314 | | - | |
| 314 | + | |
315 | 315 | | |
316 | 316 | | |
317 | 317 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
| 36 | + | |
37 | 37 | | |
38 | | - | |
| 38 | + | |
39 | 39 | | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
| 97 | + | |
98 | 98 | | |
99 | 99 | | |
100 | 100 | | |
| |||
169 | 169 | | |
170 | 170 | | |
171 | 171 | | |
| 172 | + | |
172 | 173 | | |
173 | 174 | | |
174 | 175 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
| 24 | + | |
24 | 25 | | |
25 | 26 | | |
26 | 27 | | |
27 | 28 | | |
| 29 | + | |
| 30 | + | |
28 | 31 | | |
29 | 32 | | |
30 | 33 | | |
| |||
0 commit comments