diff --git a/.changeset/25634-pinyin-reconcile-off-readiness.md b/.changeset/25634-pinyin-reconcile-off-readiness.md new file mode 100644 index 00000000000..565da793fb3 --- /dev/null +++ b/.changeset/25634-pinyin-reconcile-off-readiness.md @@ -0,0 +1,19 @@ +--- +'@objectstack/plugin-pinyin-search': patch +--- + +fix(plugin-pinyin-search): the boot companion pass no longer holds readiness, and an unchanged restart reads no rows + +Clause-②: yes +Metadata-protocol: none + +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. + +Now: + +- 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. +- 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. +- `rebuildSearchCompanion()` (`force: true`) still walks every row. Use it after an import that bypasses hooks and back-dates `updated_at`. +- `CompanionBackfillResult` gains `pages`, `CompanionBackfillOptions` gains `signal`, and `PinyinSearchPlugin` gains `whenReconciled()`, which resolves when the boot pass ends. + +What to do: nothing. The first boot after upgrading walks each companion-bearing object once more, after readiness. Later boots read only what changed. diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index a278e7a67ee..b2d1f338a4f 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **51 of the 239 sites are spelled that way**. A +forwarding shim cannot, and **51 of the 240 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **239** | +| 175 write call sites | quoted in the merged changeset | **240** | | 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **163 of 239** decidable, **76** undecidable | -| 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 | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **163 of 240** decidable, **77** undecidable | +| 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 | | 141 and 132, two independent re-derivations | the card that filed this work | — | **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. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -136 of 239 (57%) as decidably elevated, with 95 more whose elevation is a +137 of 240 (57%) as decidably elevated, with 95 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 239`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 240`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites without tenant context" — **29 further sites** have an options argument this @@ -233,23 +233,23 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **239** | +| write call sites on the application surface | **240** | | …whose object name is statically decidable | 163 | -| …whose object name is chosen at run time | 76 | +| …whose object name is chosen at run time | 77 | | …against an object with tenancy ENABLED | 162 | | …against an object that declares tenancy off | 1 | -| threading a tenant context | 180 | +| threading a tenant context | 181 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | | options argument UNREADABLE — may or may not carry one | 51 | | …of those, against a decidably tenancy-enabled object | 29 | -| threading a decidably ELEVATED (`isSystem`) context | 136 | +| threading a decidably ELEVATED (`isSystem`) context | 137 | | threading a context that is decidably NOT elevated | 0 | | threading a context whose elevation is a run-time fact | 95 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 184 | +| receiver carried a readable engine type | 185 | | receiver erased, placed by the object NAME | 45 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | @@ -257,7 +257,7 @@ cannot read, and they are neither in nor out. | object name spelled inline | 112 | | object name spelled through a `const` | 51 | | object name is an `object: string` parameter | 7 | -| object name is some other run-time expression | 69 | +| object name is some other run-time expression | 70 | ### Subtractions the census could NOT defend — enforced @@ -307,11 +307,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-24 at `28b6d27e7`. +Measured on 2026-10-25 at `ca3ca05b0`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 767 | +| tracked non-test sources scanned | 768 | | engine-shaped types recognised | 87 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 176 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index b91fc9f0dc1..05094082d14 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 239 | +| Write call sites | 240 | | Object name statically decidable | 163 | -| Object name chosen at run time | 76 | +| Object name chosen at run time | 77 | | Against a tenancy-enabled object | 162 | | Against an object declaring tenancy off | 1 | -| Threading a tenant context | 180 | +| Threading a tenant context | 181 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | | Options argument unreadable | 51 | | …and decidably tenancy-enabled | 29 | -| Threading a decidably elevated context | 136 | +| Threading a decidably elevated context | 137 | | Threading a decidably non-elevated context | 0 | | Threading a context of undecidable elevation | 95 | @@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-24 at `28b6d27e7`. +Measured on 2026-10-25 at `ca3ca05b0`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 767 | +| tracked non-test sources scanned | 768 | | engine-shaped types recognised | 87 | | declared objects in the registry | 117 | | same-named calls subtracted as non-engine | 176 | @@ -169,6 +169,7 @@ Measured on 2026-10-24 at `28b6d27e7`. | `packages/plugins/plugin-email/src/attachment-reclaim.ts` | `update` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `insert` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-email/src/email-plugin.ts` | `update` | `object` | undecidable | elevated | 1 | +| `packages/plugins/plugin-pinyin-search/src/boot-reconcile.ts` | `update` | `DATA_MIGRATION_FLAG_OBJECT` | undecidable | elevated | 1 | | `packages/plugins/plugin-pinyin-search/src/companion-projection.ts` | `update` | `schema.name` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `delete` | `object` | undecidable | elevated | 1 | | `packages/plugins/plugin-security/src/auto-org-admin-grant.ts` | `insert` | `object` | undecidable | elevated | 1 | diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index 6304595597c..0ec62ad4a81 100644 --- a/packages/plugins/plugin-pinyin-search/package.json +++ b/packages/plugins/plugin-pinyin-search/package.json @@ -21,10 +21,13 @@ "dependencies": { "@objectstack/core": "workspace:*", "@objectstack/objectql": "workspace:*", + "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "pinyin-pro": "^3.29.4" }, "devDependencies": { + "@objectstack/driver-sql": "workspace:*", + "@objectstack/platform-objects": "workspace:*", "@types/node": "^26.6.3", "tsx": "^4.23.15", "typescript": "^6.0.3", diff --git a/packages/plugins/plugin-pinyin-search/src/boot-reconcile.integration.test.ts b/packages/plugins/plugin-pinyin-search/src/boot-reconcile.integration.test.ts new file mode 100644 index 00000000000..79bac40971d --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/src/boot-reconcile.integration.test.ts @@ -0,0 +1,302 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #25634 — the boot reconcile reads what changed, not the whole table, and + * readiness does not wait for it. + * + * Measured on `main` before this change: `kernel:bootstrapped` awaited + * `backfillSearchCompanion`, which keyset-walked every row of every + * companion-bearing object on every boot, and the kernel awaits every + * `kernel:bootstrapped` handler before `kernel:listening` (the HTTP listen). + * 6.7 M `sys_inbox_message` rows held a production boot for about 4 min 50 s, + * and the next, unchanged boot paid the same again. + * + * Drives the REAL kernel, the REAL ObjectQL engine and the REAL plugin over a + * SQLite FILE (so two boots share one database), with the REAL + * `sys_migration` ledger object. Pages and rows are counted at the engine's + * `find` for the probe object — the cost a boot actually pays. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import type { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysMigration } from '@objectstack/platform-objects/system'; +import { PinyinSearchPlugin } from './pinyin-search-plugin.js'; +import { rebuildSearchCompanion } from './companion-projection.js'; +import { SEARCH_COMPANION_RECONCILE_MIGRATION_ID } from './boot-reconcile.js'; + +const PROBE = 'pinyin_probe'; +const SYSTEM = { isSystem: true } as const; +/** A history older than any watermark this test can write. */ +const HISTORIC = '2026-01-01T00:00:00.000Z'; +const ROWS = 2_500; +const PAGE = 1_000; + +const PROBE_OBJECT = { + name: PROBE, + label: 'Pinyin probe', + nameField: 'title', + systemFields: { tenant: false }, + fields: { + title: { name: 'title', type: 'text' }, + }, +}; + +/** `queries`: every `find` the walk issued for the probe, an empty one included. */ +interface Reads { queries: number; rows: number } + +/** + * The deployment under test: ObjectQL over one SQLite file, the probe object + * and the ledger, and a read counter on the probe's `find`. `gate`, when + * given, holds the probe reads from the `holdFrom`-th one (0-based, default + * 0) until it resolves. + */ +class FixturePlugin implements Plugin { + name = 'test.pinyin-reconcile-fixture'; + version = '1.0.0'; + type = 'standard' as const; + dependencies = ['com.objectstack.engine.objectql']; + driver: any; + engine!: ObjectQL; + readonly reads: Reads = { queries: 0, rows: 0 }; + + constructor( + private readonly file: string, + private readonly opts: { ledger?: boolean; gate?: Promise; holdFrom?: number } = {}, + ) {} + + async init(): Promise {} + + async start(ctx: PluginContext): Promise { + const engine = ctx.getService('objectql'); + this.engine = engine; + this.driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: this.file }, useNullAsDefault: true }); + await this.driver.connect(); + (engine as any).registerDriver(this.driver, true); + const objects: any[] = this.opts.ledger === false ? [PROBE_OBJECT] : [PROBE_OBJECT, SysMigration]; + for (const def of objects) (engine as any).registry.registerObject(def, 'pinyin-test', 'pinyin-test'); + await (engine as any).syncSchemas(); + + const find = engine.find.bind(engine); + const reads = this.reads; + const gate = this.opts.gate; + const holdFrom = this.opts.holdFrom ?? 0; + let issued = 0; + (engine as any).find = async (object: string, ...rest: any[]) => { + if (object !== PROBE) return find(object, ...rest); + if (gate && issued++ >= holdFrom) await gate; + const rows = await find(object, ...rest); + reads.queries++; + reads.rows += rows.length; + return rows; + }; + } + + /** A write that bypasses every hook and the audit stamp: an import, a migration. */ + async directInsert(rows: Array>): Promise { + for (const row of rows) await this.driver.create(PROBE, row); + } + + async row(id: string): Promise | null> { + return this.engine.findOne(PROBE, { where: { id }, fields: ['id', 'title', '__search'], context: SYSTEM }); + } +} + +const kernels: ObjectKernel[] = []; +let dir: string; +let file: string; + +beforeEach(() => { + // The registry decides at construction whether to provision `__search`. + vi.stubEnv('OS_SEARCH_PINYIN_ENABLED', 'true'); + dir = mkdtempSync(join(tmpdir(), 'pinyin-reconcile-')); + file = join(dir, 'db.sqlite'); +}); + +afterEach(async () => { + while (kernels.length) { + try { await kernels.pop()!.shutdown(); } catch { /* already stopped */ } + } + vi.unstubAllEnvs(); + rmSync(dir, { recursive: true, force: true }); +}); + +async function boot(opts: { + ledger?: boolean; + gate?: Promise; + holdFrom?: number; + before?: (f: FixturePlugin) => Promise; +} = {}) { + const kernel = new ObjectKernel({ logger: { level: 'silent' } }); + kernels.push(kernel); + const fixture = new FixturePlugin(file, opts); + const plugin = new PinyinSearchPlugin({ enabled: true }); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(fixture); + if (opts.before) { + await kernel.use({ + name: 'test.pinyin-reconcile-before', version: '1.0.0', type: 'standard', + dependencies: [fixture.name], + init: async () => {}, + start: async () => opts.before!(fixture), + } as Plugin); + } + await kernel.use(plugin); + await kernel.bootstrap(); + return { kernel, fixture, plugin }; +} + +async function shutdown(kernel: ObjectKernel): Promise { + await kernel.shutdown(); + kernels.splice(kernels.indexOf(kernel), 1); +} + +/** 2,500 historic rows a hook never saw: every 10th one is CJK. */ +function history(): Array> { + return Array.from({ length: ROWS }, (_, i) => ({ + id: `p${String(i).padStart(5, '0')}`, + title: i % 10 === 0 ? `张伟${i}` : `Message ${i}`, + created_at: HISTORIC, + updated_at: HISTORIC, + })); +} + +describe('#25634 the boot reconcile reads what changed since its last completed walk', () => { + it('a first boot walks every row; an unchanged second boot reads none — and a receipt sits in sys_migration', async () => { + const first = await boot({ before: (f) => f.directInsert(history()) }); + await first.plugin.whenReconciled(); + const firstReads = { ...first.fixture.reads }; + expect(firstReads.rows).toBe(ROWS); + expect(firstReads.queries).toBe(Math.ceil(ROWS / PAGE)); + // The walk did its job: a CJK history row now carries its companion. + expect((await first.fixture.row('p00010'))?.__search).toBe('zhangwei10 zw10'); + expect((await first.fixture.row('p00011'))?.__search ?? null).toBeNull(); + const receipt: any = await first.fixture.engine.findOne('sys_migration', { + where: { id: SEARCH_COMPANION_RECONCILE_MIGRATION_ID }, context: SYSTEM, + }); + expect(receipt?.blocking).toBe(0); + expect(receipt?.verified_at ?? null).toBeNull(); + expect(JSON.parse(receipt.details).objects[PROBE].sources).toEqual(['title']); + await shutdown(first.kernel); + + const second = await boot(); + await second.plugin.whenReconciled(); + // THE PIN: the unchanged table is not read again — one filtered query + // that answers no row. + expect(second.fixture.reads).toEqual({ queries: 1, rows: 0 }); + console.info(`[#25634] rows/queries read — first boot ${firstReads.rows}/${firstReads.queries}, ` + + `unchanged second boot ${second.fixture.reads.rows}/${second.fixture.reads.queries}`); + }); + + it('a second boot still fills a row a hook-bypassing writer added since the last walk, reading only that row', async () => { + const first = await boot({ before: (f) => f.directInsert(history()) }); + await first.plugin.whenReconciled(); + await shutdown(first.kernel); + + const second = await boot({ + before: (f) => f.directInsert([{ id: 'p_new', title: '李娜', created_at: new Date().toISOString(), updated_at: new Date().toISOString() }]), + }); + await second.plugin.whenReconciled(); + expect(second.fixture.reads.rows).toBe(1); + expect((await second.fixture.row('p_new'))?.__search).toBe('lina ln'); + }); + + it('control: a normal ObjectQL write still stamps __search through the hooks, with the reconcile off the boot path', async () => { + let open!: () => void; + const gate = new Promise((resolve) => { open = resolve; }); + const { fixture, plugin } = await boot({ gate, before: (f) => f.directInsert(history()) }); + // The reconcile is still parked on its first page; the write does not wait for it. + await fixture.engine.insert(PROBE, { id: 'p_live', title: '王芳' }, { context: SYSTEM }); + expect((await fixture.row('p_live'))?.__search).toBe('wangfang wf'); + open(); + await plugin.whenReconciled(); + }); + + it('control: the explicit rebuild (force) still walks every row, and fills a row the watermark cannot see', async () => { + const first = await boot({ before: (f) => f.directInsert(history()) }); + await first.plugin.whenReconciled(); + // A hook- AND stamp-bypassing write that back-dates itself: no watermark + // can see it. That is what the explicit rebuild is for. + await first.fixture.directInsert([{ id: 'p_backdated', title: '赵磊', created_at: HISTORIC, updated_at: HISTORIC }]); + const before = first.fixture.reads.rows; + const result = await rebuildSearchCompanion(first.fixture.engine as any, undefined, { object: PROBE }); + expect(result.scanned).toBe(ROWS + 1); + expect(first.fixture.reads.rows - before).toBe(ROWS + 1); + expect((await first.fixture.row('p_backdated'))?.__search).toBe('zhaolei zl'); + }); + + it('readiness does not wait for the reconcile: bootstrap() resolves while its first page is still held', async () => { + let open!: () => void; + const gate = new Promise((resolve) => { open = resolve; }); + let settled = false; + const { fixture, plugin } = await boot({ gate, before: (f) => f.directInsert(history()) }); + void plugin.whenReconciled().then(() => { settled = true; }); + // bootstrap() — every boot-phase hook, `kernel:listening` included — has + // resolved, and the reconcile has not read a single page. + await new Promise((r) => setTimeout(r, 20)); + expect(fixture.reads.queries).toBe(0); + expect(settled).toBe(false); + open(); + await plugin.whenReconciled(); + expect(fixture.reads.rows).toBe(ROWS); + }); + + it('shutdown stops the reconcile between pages and records no receipt for the unfinished walk', async () => { + let open!: () => void; + const gate = new Promise((resolve) => { open = resolve; }); + // Page 1 is served, page 2 is held: shutdown arrives mid-walk. + const first = await boot({ gate, holdFrom: 1, before: (f) => f.directInsert(history()) }); + while (first.fixture.reads.queries < 1) await new Promise((r) => setTimeout(r, 5)); + const stopping = shutdown(first.kernel); + open(); + await stopping; + expect(first.fixture.reads.queries).toBe(2); + expect(first.fixture.reads.rows).toBe(2 * PAGE); + + const second = await boot(); + await second.plugin.whenReconciled(); + expect(second.fixture.reads.rows).toBe(ROWS); + }); + + it('without the sys_migration ledger every boot walks every row (the old cost, never a missed row)', async () => { + const first = await boot({ ledger: false, before: (f) => f.directInsert(history()) }); + await first.plugin.whenReconciled(); + await shutdown(first.kernel); + const second = await boot({ ledger: false }); + await second.plugin.whenReconciled(); + expect(second.fixture.reads.rows).toBe(ROWS); + }); +}); + +describe('#25634 a failing reconcile is logged, not swallowed', () => { + it('logs at warn when the reconcile throws, and bootstrapped resolves first', async () => { + const hooks: Record Promise> = {}; + const warn = vi.fn(); + const engine = { + registerHook: () => {}, + unregisterHooksByPackage: () => 0, + registry: { getObject: () => undefined, getAllObjects: () => { throw new Error('registry exploded'); } }, + // No `update`: the pass throws before it could write anything. + find: async () => [], + }; + const ctx: any = { + logger: { warn, debug: () => {}, info: () => {}, error: () => {} }, + hook: (name: string, fn: () => Promise) => { hooks[name] = fn; }, + getService: () => engine, + }; + const plugin = new PinyinSearchPlugin({ enabled: true }); + await plugin.start(ctx); + await hooks['kernel:bootstrapped'](); + await plugin.whenReconciled(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('companion reconcile failed'), + expect.objectContaining({ error: 'registry exploded' }), + ); + }); +}); diff --git a/packages/plugins/plugin-pinyin-search/src/boot-reconcile.ts b/packages/plugins/plugin-pinyin-search/src/boot-reconcile.ts new file mode 100644 index 00000000000..08208bf4cab --- /dev/null +++ b/packages/plugins/plugin-pinyin-search/src/boot-reconcile.ts @@ -0,0 +1,320 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Boot reconcile for the `__search` companion (#25634). + * + * The boot pass used to be `backfillSearchCompanion`: every row of every + * companion-bearing object, on every boot, awaited inside + * `kernel:bootstrapped` — which the kernel awaits before `kernel:listening`, + * so readiness grew with table size (6.7 M `sys_inbox_message` rows, about + * 4 min 50 s in production). An unchanged table paid the same walk again on + * every restart, because a row's `__search` is `null` both when it was never + * looked at and when it was looked at and has no CJK to project, so the data + * alone cannot say a row is done. + * + * This module records instead, per object, that a walk COMPLETED: one + * receipt row in the `sys_migration` deployment ledger. A later boot then + * walks only the rows written since that walk started (`updated_at`) that + * still carry no companion, which is what can still need one: rows written while the switch was off, + * seed rows written before the hooks were bound, and rows a hook-bypassing + * writer stamped. An unchanged object reads zero rows. Writes that bypass the + * audit stamp too are the explicit rebuild's job (`rebuildSearchCompanion`). + * + * ## Why `sys_migration`, and why the id is spelled here + * + * It is the ledger the runtime already keeps for "has THIS deployment run + * this data pass" (`@objectstack/platform-objects`' `sys-migration.object.ts`), + * its API is read-only, and its row contract lives in `@objectstack/spec/system` + * (`DataMigrationFlagSchema`). It is the destination the seed-tenancy repair's + * receipt was ruled onto, written the same way: through the engine, against + * the contract, with no dependency on the package that registers the object. + * The id is a constant of this package, not of the spec, for the reason that + * receipt states: one writer and one reader, both here, so publishing it into + * the shared contract would declare a coordination point that does not exist. + * Nothing about it is authorable — no metadata key, no setting. + * + * ## The field reading (the ledger's fields are gate-bearing for other ids) + * + * - `verified_at: null` — there is no self-check; a timestamp would be a + * certificate nothing earned. `blocking: 0` — no consumer is gated on this + * id. Every ledger reader reads one row by its own id, so neither reaches + * another id's gate. + * - `advisory` — rows whose update failed in the last run. Their object's + * watermark is NOT advanced, so the next boot retries them. + * - `details` — JSON: `{ version, objects: { NAME: { sources, since } } }`. + * `since` is the start of the last completed walk, less a margin; + * `sources` is the display fields it projected. A different source set or + * a different `version` invalidates the entry and the object is walked in + * full again. + * + * ## No ledger, no receipt + * + * A kernel without `PlatformObjectsPlugin` has no `sys_migration`. The pass + * then walks every row, as it always did, and says so once at `warn` — the + * cost is visible, nothing is lost. + */ + +import { DATA_MIGRATION_FLAG_OBJECT, type DataMigrationFlag } from '@objectstack/spec/system'; +import { SEARCH_COMPANION_FIELD } from '@objectstack/objectql'; +import { + companionObjects, + walkCompanionObject, + type MinimalEngine, + type MinimalLogger, +} from './companion-projection.js'; + +/** This pass's ledger row id. See the module comment for why it is spelled here. */ +export const SEARCH_COMPANION_RECONCILE_MIGRATION_ID = 'pinyin-search-companion'; + +/** + * Bump when the projection itself changes (what `computeSearchCompanionValue` + * writes, or which rows the walk would touch): every receipt written under an + * older version is ignored and every object is walked in full once. + */ +export const SEARCH_COMPANION_RECONCILE_VERSION = 1; + +/** + * How far before a walk's start its watermark is set. A row is stamped with + * the writing node's clock, and that node may run behind this one; the + * margin re-reads a few minutes of rows rather than missing any. + */ +export const RECONCILE_WATERMARK_MARGIN_MS = 10 * 60 * 1000; + +/** The column the incremental walk filters on — the platform's audit stamp. */ +const WATERMARK_FIELD = 'updated_at'; + +const LEDGER_CTX = { isSystem: true } as const; + +interface ReceiptEntry { + sources: string[]; + since: string; +} + +interface ReceiptDetails { + version: number; + objects: Record; +} + +export interface SearchCompanionReconcileOptions { + /** Rows fetched per page. Default 1000. */ + batchSize?: number; + /** Stop between pages (plugin shutdown). */ + signal?: AbortSignal; + /** Clock, for tests. */ + now?: () => Date; +} + +export interface SearchCompanionReconcileResult { + /** Companion-bearing objects considered. */ + objects: number; + /** Objects walked in full (no usable receipt entry). */ + full: number; + /** Objects walked from their watermark. */ + incremental: number; + scanned: number; + pages: number; + updated: number; + /** Whether the receipt was written (false: no ledger, aborted, or the write failed). */ + recorded: boolean; + aborted: boolean; +} + +/** + * Reconcile every companion-bearing object, reading only what changed since + * its last completed walk. Never throws for a single object's failure; the + * caller (the plugin's post-readiness task) logs anything it does throw. + */ +export async function reconcileSearchCompanion( + engine: MinimalEngine, + logger?: MinimalLogger, + options?: SearchCompanionReconcileOptions, +): Promise { + const batchSize = Math.max(1, options?.batchSize ?? 1000); + const now = options?.now ?? (() => new Date()); + const result: SearchCompanionReconcileResult = { + objects: 0, full: 0, incremental: 0, scanned: 0, pages: 0, updated: 0, recorded: false, aborted: false, + }; + + const targets = companionObjects(engine); + if (targets.length === 0) return result; + + const ledger = hasLedger(engine); + if (!ledger) { + logger?.warn?.( + `[pinyin-search] ${DATA_MIGRATION_FLAG_OBJECT} is not registered on this kernel, so the companion ` + + `reconcile cannot record a completed walk and reads EVERY row of every searchable object on every ` + + `boot. Compose PlatformObjectsPlugin (it carries the ledger every served kernel gets) to make an ` + + `unchanged restart read nothing.`, + ); + } + const previous = ledger ? await readReceipt(engine, logger) : undefined; + const next: ReceiptDetails = { + version: SEARCH_COMPANION_RECONCILE_VERSION, + objects: { ...(previous?.objects ?? {}) }, + }; + let skipped = 0; + + for (const { schema, sources } of targets) { + if (options?.signal?.aborted) { + result.aborted = true; + break; + } + result.objects++; + const entry = previous?.objects[schema.name]; + const usable = + entry !== undefined && + sameSources(entry.sources, sources) && + schema.fields?.[WATERMARK_FIELD] !== undefined; + const startedAt = now().getTime(); + const walk = await walkCompanionObject( + engine, + schema, + sources, + { + batchSize, + signal: options?.signal, + where: usable ? sinceLastWalk(entry!.since) : undefined, + }, + logger, + ); + if (usable) result.incremental++; + else result.full++; + result.scanned += walk.scanned; + result.pages += walk.pages; + result.updated += walk.updated; + skipped += walk.skipped; + + if (walk.aborted) { + result.aborted = true; + break; + } + // Advance only past a walk that saw every row it was asked for and wrote + // every row it meant to: a failed scan or a skipped row keeps the old + // watermark, so the next boot reads those rows again. + if (!walk.failed && walk.skipped === 0) { + next.objects[schema.name] = { + sources: [...sources], + since: new Date(startedAt - RECONCILE_WATERMARK_MARGIN_MS).toISOString(), + }; + } + if (!usable || walk.updated > 0) { + logger?.info?.('[pinyin-search] companion reconcile walked an object', { + object: schema.name, + mode: usable ? 'since-last-walk' : 'full', + scanned: walk.scanned, + pages: walk.pages, + updated: walk.updated, + }); + } + } + + if (ledger && !result.aborted) { + result.recorded = await writeReceipt(engine, next, skipped, now().toISOString(), logger); + } + return result; +} + +/** + * The rows that can still need a companion after a completed walk: written + * since it started AND still without one. A row that has a blob never needs + * a backfill. The `null` test alone is not enough (a non-CJK row is `null` + * forever, so it would match on every boot), and the watermark alone is not + * either: the walk's own writes go through the audit stamp, so every row it + * filled carries a fresh `updated_at` and the next boot would read them all + * again. Together they match only rows nothing has looked at. + */ +function sinceLastWalk(since: string): Record { + return { $and: [{ [WATERMARK_FIELD]: { $gte: since } }, { [SEARCH_COMPANION_FIELD]: null }] }; +} + +function hasLedger(engine: MinimalEngine): boolean { + return ( + typeof engine.findOne === 'function' && + typeof engine.insert === 'function' && + engine.registry?.getObject?.(DATA_MIGRATION_FLAG_OBJECT) != null + ); +} + +function sameSources(a: unknown, b: string[]): boolean { + return Array.isArray(a) && a.length === b.length && a.every((s, i) => s === b[i]); +} + +/** + * The previous receipt's details, or `undefined` when there is none or it + * cannot be used — every object is then walked in full, which is the safe + * direction (the cost of a first boot, never a missed row). + */ +async function readReceipt(engine: MinimalEngine, logger?: MinimalLogger): Promise { + let row: Record | null; + try { + row = await engine.findOne!(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: SEARCH_COMPANION_RECONCILE_MIGRATION_ID }, + context: LEDGER_CTX, + }); + } catch (err: any) { + logger?.warn?.( + `[pinyin-search] reading the companion reconcile receipt from ${DATA_MIGRATION_FLAG_OBJECT} failed — ` + + `every searchable object is walked in full this boot`, + { error: err?.message }, + ); + return undefined; + } + if (!row || typeof row.details !== 'string') return undefined; + try { + const parsed = JSON.parse(row.details) as ReceiptDetails; + if (parsed?.version !== SEARCH_COMPANION_RECONCILE_VERSION || typeof parsed.objects !== 'object') { + return undefined; + } + return parsed; + } catch { + logger?.warn?.( + `[pinyin-search] the companion reconcile receipt in ${DATA_MIGRATION_FLAG_OBJECT} ` + + `(id '${SEARCH_COMPANION_RECONCILE_MIGRATION_ID}') is not valid JSON — every searchable object is ` + + `walked in full this boot and the receipt is rewritten`, + ); + return undefined; + } +} + +/** + * Upsert the receipt. A failure is reported, never raised: nothing is lost + * (the companion values are already written), the next boot only pays a + * full walk again — a cost, so `warn`, and said once. + */ +async function writeReceipt( + engine: MinimalEngine, + details: ReceiptDetails, + skipped: number, + now: string, + logger?: MinimalLogger, +): Promise { + const flag: DataMigrationFlag = { + id: SEARCH_COMPANION_RECONCILE_MIGRATION_ID, + last_run_at: now, + applied_at: now, + verified_at: null, + blocking: 0, + advisory: skipped, + details: JSON.stringify(details), + }; + const row: Record = { ...flag, updated_at: now }; + try { + const existing = await engine.findOne!(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: flag.id }, + context: LEDGER_CTX, + }); + if (existing?.id === flag.id) { + await engine.update(DATA_MIGRATION_FLAG_OBJECT, row, { context: LEDGER_CTX }); + } else { + await engine.insert!(DATA_MIGRATION_FLAG_OBJECT, { ...row, created_at: now }, { context: LEDGER_CTX }); + } + return true; + } catch (err: any) { + logger?.warn?.( + `[pinyin-search] the companion reconcile finished but its receipt could not be written to ` + + `${DATA_MIGRATION_FLAG_OBJECT} — the next boot walks every searchable object in full again`, + { error: err?.message }, + ); + return false; + } +} diff --git a/packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts b/packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts index 4438b93165d..573030a445c 100644 --- a/packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts +++ b/packages/plugins/plugin-pinyin-search/src/companion-projection.test.ts @@ -159,7 +159,7 @@ describe('backfillSearchCompanion / rebuildSearchCompanion', () => { { id: 'd', name: '李雷', __search: null }, // fill (second page) ]; const result = await backfillSearchCompanion(engine as any, undefined, { batchSize: 2 }); - expect(result).toEqual({ objects: 1, scanned: 4, updated: 2 }); + expect(result).toEqual({ objects: 1, scanned: 4, updated: 2, pages: 2 }); const rows = engine._tables.crm_contact; expect(rows.find((r) => r.id === 'a')!.__search).toBe('zhangwei zw'); expect(rows.find((r) => r.id === 'b')!.__search).toBe(null); @@ -243,7 +243,7 @@ describe('[#10290] negative control — a primary-key-only object is never walke { id: 'jwk_2', created_at: '2026-01-02' }, ]; const result = await backfillSearchCompanion(engine as any); - expect(result).toEqual({ objects: 0, scanned: 0, updated: 0 }); + expect(result).toEqual({ objects: 0, scanned: 0, updated: 0, pages: 0 }); expect(engine._finds).toEqual([]); }); @@ -251,7 +251,7 @@ describe('[#10290] negative control — a primary-key-only object is never walke const engine = makeEngine([jwksLegacySchema]); engine._tables.sys_jwks = [{ id: 'jwk_1', __search: null }]; const result = await backfillSearchCompanion(engine as any); - expect(result).toEqual({ objects: 0, scanned: 0, updated: 0 }); + expect(result).toEqual({ objects: 0, scanned: 0, updated: 0, pages: 0 }); expect(engine._finds).toEqual([]); }); @@ -276,7 +276,7 @@ describe('[#10290] positive control — titled objects are still backfilled', () ]; engine._tables.sys_jwks = [{ id: 'jwk_1' }]; const result = await backfillSearchCompanion(engine as any); - expect(result).toEqual({ objects: 1, scanned: 2, updated: 1 }); + expect(result).toEqual({ objects: 1, scanned: 2, updated: 1, pages: 1 }); // Only the titled object was queried — the doomed one was never touched. expect(new Set(engine._finds)).toEqual(new Set(['crm_contact'])); expect(engine._tables.crm_contact.find((r) => r.id === 'a')!.__search).toBe('zhangwei zw'); diff --git a/packages/plugins/plugin-pinyin-search/src/companion-projection.ts b/packages/plugins/plugin-pinyin-search/src/companion-projection.ts index 2f9679f06ae..465dbb74ca1 100644 --- a/packages/plugins/plugin-pinyin-search/src/companion-projection.ts +++ b/packages/plugins/plugin-pinyin-search/src/companion-projection.ts @@ -13,7 +13,8 @@ * display/name field) is present in the write — i.e. only when the source * actually changed, avoiding write amplification. Writes that bypass hooks * (bulk import, direct migration) leave the companion empty; the boot - * backfill and the `rebuildSearchCompanion` reconcile entry cover that. + * reconcile (`boot-reconcile.ts`) and the `rebuildSearchCompanion` entry + * cover that. */ import { @@ -28,7 +29,7 @@ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; export const PINYIN_SEARCH_HOOK_PACKAGE = 'plugin-pinyin-search:companion'; -interface MinimalEngine { +export interface MinimalEngine { registerHook( event: string, handler: (ctx: any) => any | Promise, @@ -40,6 +41,8 @@ interface MinimalEngine { fn: (event: { object: string; row: Record; fields: string[] }) => void | Promise, ): void; find(object: string, query?: any, options?: any): Promise; + findOne?(object: string, query?: any, options?: any): Promise; + insert?(object: string, data: any, options?: any): Promise; update(object: string, data: any, options?: any): Promise; registry?: { getObject(name: string): any; @@ -47,7 +50,7 @@ interface MinimalEngine { }; } -interface MinimalLogger { +export interface MinimalLogger { info?: (msg: any, ...rest: any[]) => void; warn?: (msg: any, ...rest: any[]) => void; debug?: (msg: any, ...rest: any[]) => void; @@ -131,78 +134,109 @@ export interface CompanionBackfillOptions { * companion is empty but whose source has CJK content). */ force?: boolean; + /** + * Stop between pages once aborted (#25634): the boot reconcile runs after + * readiness, so a shutdown must be able to end it. An aborted object is + * reported in `aborted`, never as a clean pass. + */ + signal?: AbortSignal; } export interface CompanionBackfillResult { objects: number; scanned: number; updated: number; + /** Pages read across every walked object (#25634: the cost a reboot pays). */ + pages: number; + /** True when `signal` stopped the pass before every object was walked. */ + aborted?: boolean; } -/** - * Backfill / reconcile the companion column. - * - * Denormalized-on-write columns go stale when writes bypass hooks (bulk - * import, direct migration) and are empty for rows that predate the switch - * being enabled. This scans every object that carries the companion column - * (paged, system context) and fills the gaps; with `force: true` it - * recomputes unconditionally (the periodic reconcile / rebuild entry). - * Idempotent; per-row failures are skipped so one bad row never aborts the - * pass. - */ -export async function backfillSearchCompanion( +/** One object's walk, as {@link walkCompanionObject} reports it. */ +export interface CompanionObjectWalk { + scanned: number; + pages: number; + updated: number; + /** Rows whose update failed and were skipped. */ + skipped: number; + /** The scan itself failed (a page read threw); the walk is incomplete. */ + failed: boolean; + /** `signal` stopped the walk before the object was exhausted. */ + aborted: boolean; +} + +/** The companion-bearing schemas a pass walks, each with its source fields. */ +export function companionObjects( engine: MinimalEngine, - logger?: MinimalLogger, - options?: CompanionBackfillOptions, -): Promise { - const batchSize = Math.max(1, options?.batchSize ?? 1000); - const all = options?.object - ? [engine.registry?.getObject?.(options.object)].filter(Boolean) + object?: string, +): Array<{ schema: any; sources: string[] }> { + const all = object + ? [engine.registry?.getObject?.(object)].filter(Boolean) : engine.registry?.getAllObjects?.() ?? []; - - const result: CompanionBackfillResult = { objects: 0, scanned: 0, updated: 0 }; - + const out: Array<{ schema: any; sources: string[] }> = []; for (const schema of all) { if (!schema?.name || !schema?.fields?.[SEARCH_COMPANION_FIELD]) continue; const sources = resolveSearchCompanionSources(schema); if (sources.length === 0) continue; - result.objects++; + out.push({ schema, sources }); + } + return out; +} - // Seek by `id` (#4363). This walk UPDATES the rows it reads, and an offset - // counts into a set those writes are changing, so rows slide past the - // cursor and never get their companion blob — a backfill that reports a - // clean pass while leaving records unsearchable. - const walk = keysetWalk( - (q) => engine.find(schema.name, { - ...q, - fields: ['id', ...sources, SEARCH_COMPANION_FIELD], - context: SYSTEM_CTX, - }), - { pageSize: batchSize }, - ); +/** + * Walk ONE companion-bearing object and fill (or, with `force`, recompute) + * its companion. `where` narrows the walk — the boot reconcile passes the + * rows written since its last completed pass (#25634); omitted, every row is + * read. + */ +export async function walkCompanionObject( + engine: MinimalEngine, + schema: any, + sources: string[], + options: { batchSize: number; force?: boolean; where?: unknown; signal?: AbortSignal }, + logger?: MinimalLogger, +): Promise { + const out: CompanionObjectWalk = { scanned: 0, pages: 0, updated: 0, skipped: 0, failed: false, aborted: false }; + const fields = ['id', ...sources, SEARCH_COMPANION_FIELD]; - try { - for await (const rows of walk.pages()) { - result.scanned += rows.length; + // Seek by `id` (#4363). This walk UPDATES the rows it reads, and an offset + // counts into a set those writes are changing, so rows slide past the + // cursor and never get their companion blob — a backfill that reports a + // clean pass while leaving records unsearchable. + const walk = keysetWalk( + (q) => engine.find(schema.name, { ...q, fields, context: SYSTEM_CTX }), + { pageSize: options.batchSize, where: options.where }, + ); + + try { + for await (const rows of walk.pages()) { + out.pages++; + out.scanned += rows.length; for (const row of rows) { if (row?.id == null) continue; - const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === 'string' && row[SEARCH_COMPANION_FIELD] !== ''; - const hasCjkSource = sources.some((s) => containsCJK(row[s])); - // Backfill mode: touch only rows missing a blob they should have. - // Force mode: recompute everything (also clears stale blobs). - if (!options?.force && (hasBlob || !hasCjkSource)) continue; + if (!needsWrite(row, sources, options.force)) continue; try { - const value = await computeSearchCompanionValue(sources.map((s) => row[s])); - if (!options?.force && value == null) continue; - if (value === row[SEARCH_COMPANION_FIELD]) continue; + // [#25634] The walk now runs while the server takes writes, so a + // row renamed after its page was read must not get the OLD name's + // blob written over the one its own hook just stamped: read the row + // again right before writing it. Only rows about to be written pay + // this read, and in backfill mode those are rare by construction. + const current = engine.findOne + ? await engine.findOne(schema.name, { where: { id: row.id }, fields, context: SYSTEM_CTX }) + : row; + if (!current || !needsWrite(current, sources, options.force)) continue; + const value = await computeSearchCompanionValue(sources.map((s) => current[s])); + if (!options.force && value == null) continue; + if (value === current[SEARCH_COMPANION_FIELD]) continue; await engine.update( schema.name, { id: row.id, [SEARCH_COMPANION_FIELD]: value }, { context: SYSTEM_CTX }, ); - result.updated++; + out.updated++; } catch (err: any) { + out.skipped++; logger?.warn?.('[pinyin-search] backfill row skipped', { object: schema.name, id: row.id, @@ -210,10 +244,72 @@ export async function backfillSearchCompanion( }); } } + + if (options.signal?.aborted) { + out.aborted = true; + break; } - } catch (err: any) { - logger?.warn?.('[pinyin-search] backfill scan failed', { object: schema.name, error: err?.message }); - continue; + } + } catch (err: any) { + out.failed = true; + logger?.warn?.('[pinyin-search] backfill scan failed', { object: schema.name, error: err?.message }); + } + return out; +} + +/** + * Backfill mode touches only rows missing a blob they should have; force + * mode recomputes everything (also clears stale blobs). + */ +function needsWrite(row: any, sources: string[], force?: boolean): boolean { + if (force) return true; + const hasBlob = typeof row[SEARCH_COMPANION_FIELD] === 'string' && row[SEARCH_COMPANION_FIELD] !== ''; + return !hasBlob && sources.some((s) => containsCJK(row[s])); +} + +/** + * Backfill / reconcile the companion column. + * + * Denormalized-on-write columns go stale when writes bypass hooks (bulk + * import, direct migration) and are empty for rows that predate the switch + * being enabled. This scans every object that carries the companion column + * (paged, system context) and fills the gaps; with `force: true` it + * recomputes unconditionally (the periodic reconcile / rebuild entry). + * Idempotent; per-row failures are skipped so one bad row never aborts the + * pass. + * + * This reads EVERY row of every companion-bearing object. The plugin's boot + * pass does not call it: it runs `reconcileSearchCompanion` + * (`boot-reconcile.ts`), which reads only what changed since its last + * completed pass (#25634). + */ +export async function backfillSearchCompanion( + engine: MinimalEngine, + logger?: MinimalLogger, + options?: CompanionBackfillOptions, +): Promise { + const batchSize = Math.max(1, options?.batchSize ?? 1000); + const result: CompanionBackfillResult = { objects: 0, scanned: 0, updated: 0, pages: 0 }; + + for (const { schema, sources } of companionObjects(engine, options?.object)) { + if (options?.signal?.aborted) { + result.aborted = true; + break; + } + result.objects++; + const walk = await walkCompanionObject( + engine, + schema, + sources, + { batchSize, force: options?.force, signal: options?.signal }, + logger, + ); + result.scanned += walk.scanned; + result.pages += walk.pages; + result.updated += walk.updated; + if (walk.aborted) { + result.aborted = true; + break; } } diff --git a/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts b/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts index 3d4bc3ab8d2..28739311a52 100644 --- a/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts +++ b/packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts @@ -10,8 +10,10 @@ * * - before-save hooks: recompute full pinyin + initials of the * display/name field when it changes; - * - boot backfill (`kernel:bootstrapped`): fill rows that predate the - * switch or arrived via hook-bypassing writes; + * - boot reconcile (started at `kernel:bootstrapped`, NOT awaited there — + * #25634): fill rows that predate the switch or arrived via + * hook-bypassing writes, reading only what changed since the last + * completed pass (`boot-reconcile.ts`); * - `rebuildSearchCompanion` (exported): explicit reconcile/rebuild entry. * * When the flag is off the plugin is inert: no hooks, no backfill, and @@ -19,14 +21,12 @@ */ // `IObjectQLEngine` via the core barrel, which re-exports it from -// `@objectstack/spec/contracts`: this package does not depend on spec directly, -// and the slot's contract is not a reason to add a dependency. +// `@objectstack/spec/contracts`: the slot's contract is not a reason to import +// spec here (the reconcile receipt's row contract is, in `boot-reconcile.ts`). import type { IObjectQLEngine, Plugin, PluginContext } from '@objectstack/core'; import { resolveSearchPinyinEnabled } from '@objectstack/types'; -import { - bindSearchCompanionHooks, - backfillSearchCompanion, -} from './companion-projection.js'; +import { bindSearchCompanionHooks } from './companion-projection.js'; +import { reconcileSearchCompanion } from './boot-reconcile.js'; export interface PinyinSearchPluginOptions { /** @@ -34,7 +34,7 @@ export interface PinyinSearchPluginOptions { * embedders). Default: `resolveSearchPinyinEnabled()`. */ enabled?: boolean; - /** Skip the boot backfill (default: run it once per boot). */ + /** Skip the boot reconcile (default: run it once per boot, after readiness). */ backfill?: boolean; } @@ -45,6 +45,8 @@ export class PinyinSearchPlugin implements Plugin { dependencies = ['com.objectstack.engine.objectql']; private readonly options: PinyinSearchPluginOptions; + /** The in-flight boot reconcile, so `destroy()` can stop and await it. */ + private reconcile: { abort: AbortController; done: Promise } | null = null; constructor(options: PinyinSearchPluginOptions = {}) { this.options = options; @@ -78,22 +80,55 @@ export class PinyinSearchPlugin implements Plugin { } }); - // Backfill AFTER boot settles (`kernel:bootstrapped` fires once every + // Reconcile AFTER boot settles (`kernel:bootstrapped` fires once every // `kernel:ready` hook — including seed loading — has completed), so // seeded rows written before/around hook binding are reconciled too. + // + // [#25634] Started here, NOT awaited here. The kernel awaits every + // `kernel:bootstrapped` handler before `kernel:listening`, the cue the + // HTTP server opens its socket on, so an awaited walk held readiness for + // as long as the largest companion-bearing table took to read (6.7 M + // `sys_inbox_message` rows: about 4 min 50 s). Writes made meanwhile are + // stamped by the hooks bound at `kernel:ready`; the reconcile only fills + // rows those hooks never saw. Its failure is logged, never swallowed, and + // `destroy()` stops it between pages. if (this.options.backfill !== false) { ctx.hook('kernel:bootstrapped', async () => { const engine = this.resolveEngine(ctx); if (!engine) return; - try { - await backfillSearchCompanion(engine, ctx.logger as any); - } catch (err: any) { - ctx.logger.warn('PinyinSearchPlugin: companion backfill failed', { error: err?.message }); - } + const abort = new AbortController(); + const done = reconcileSearchCompanion(engine as any, ctx.logger as any, { signal: abort.signal }) + .then( + (result) => { + ctx.logger.debug?.('PinyinSearchPlugin: companion reconcile finished', result as any); + }, + (err: any) => { + ctx.logger.warn('PinyinSearchPlugin: companion reconcile failed — rows written by hook-bypassing ' + + 'writers may stay unsearchable by pinyin until the next boot or rebuildSearchCompanion()', { + error: err?.message, + }); + }, + ) + .finally(() => { + if (this.reconcile?.done === done) this.reconcile = null; + }); + this.reconcile = { abort, done }; }); } } + /** The in-flight boot reconcile's completion, for hosts and tests; resolved when none runs. */ + whenReconciled(): Promise { + return this.reconcile?.done ?? Promise.resolve(); + } + + async destroy(): Promise { + const running = this.reconcile; + if (!running) return; + running.abort.abort(); + await running.done; + } + /** * [#4251 B5] The engine SEEN WHOLE — `bindSearchCompanionHooks` binds * `registerHook`, which lives on `IObjectQLEngine`, not on the data plane. diff --git a/packages/plugins/plugin-pinyin-search/vitest.config.ts b/packages/plugins/plugin-pinyin-search/vitest.config.ts index 5e0591efc44..e549c88c558 100644 --- a/packages/plugins/plugin-pinyin-search/vitest.config.ts +++ b/packages/plugins/plugin-pinyin-search/vitest.config.ts @@ -1,12 +1,30 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// This config exists for exactly one setting; everything else stays on -// vitest's defaults, deliberately — a key added here re-specifies behaviour -// for every test file in the package (packages/cli/vitest.config.ts's header -// records the incident that taught that). +// Everything not set here stays on vitest's defaults, deliberately — a key +// added here re-specifies behaviour for every test file in the package +// (packages/cli/vitest.config.ts's header records the incident that taught +// that). import { defineConfig } from 'vitest/config'; +import path from 'node:path'; export default defineConfig({ + resolve: { + // [#25634] `boot-reconcile.integration.test.ts` boots the REAL kernel over + // the REAL SQLite driver with the REAL `sys_migration` object, so those + // specifiers resolve to source, not to a `dist/` build + // (`pnpm check:test-source-alias`, #7668/#7778). Anchored regexes: a bare + // string `find` matches by prefix and would swallow subpaths. + alias: [ + { find: /^@objectstack\/core$/, replacement: path.resolve(__dirname, '../../core/src/index.ts') }, + { find: /^@objectstack\/driver-sql$/, replacement: path.resolve(__dirname, '../../drivers/driver-sql/src/index.ts') }, + // `boot-reconcile.ts` reads the ledger's row contract from spec. + { find: /^@objectstack\/spec\/system$/, replacement: path.resolve(__dirname, '../../spec/src/system/index.ts') }, + { + find: /^@objectstack\/platform-objects\/system$/, + replacement: path.resolve(__dirname, '../../platform-objects/src/system/index.ts'), + }, + ], + }, test: { // A late console.* must not redden a green suite (#10374): vitest's worker // forwards console output over RPC and discards the promise, and a write diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 375eb1a7aac..bb1366d1ae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1919,6 +1919,9 @@ importers: '@objectstack/objectql': specifier: workspace:* version: link:../../objectql + '@objectstack/spec': + specifier: workspace:* + version: link:../../spec '@objectstack/types': specifier: workspace:* version: link:../../types @@ -1926,6 +1929,12 @@ importers: specifier: ^3.29.4 version: 3.29.4 devDependencies: + '@objectstack/driver-sql': + specifier: workspace:* + version: link:../../drivers/driver-sql + '@objectstack/platform-objects': + specifier: workspace:* + version: link:../../platform-objects '@types/node': specifier: ^26.6.3 version: 26.6.3