Skip to content

Commit 2a40f98

Browse files
fix(plugin-security): the seed ownership hand-over yields to the event loop between 50-row batches, so a sign-in during it is answered (#26913)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X9mRXg8QNKdP2mmSLvmtm2L)_ Fixes #26825 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:种子落定后把种子数据移交给平台管理员的那一步(`claimSeedOwnership`,以及多组织版 `claimOrgSeedOwnership`)改为每批 50 行写入,每写一批让出一次事件循环;按行写的路径每 50 行让出一次。 - **为什么改**:实测在 `os serve --dev`(文件型 sqlite,1,500 行带种子文件的种子)上,这一步是一次覆盖 1,500 行的写入,耗时 2,641 ms,整个事件循环被占住约 2.7 秒,期间的登录请求要等 2.0–2.9 秒;改后最长卡顿 190 ms,同期登录 p50 317 ms、最慢 539 ms。 - **风险与回滚**:成功路径结果不变(同样的行、同样的所有者、每行一条审计行)。唯一变化在失败时:一个 5,000 行分块里只有部分批次被拒时,其余批次已归属、剩下的行由下一次认领补上(以前整块都不归属);整块全被拒时与原来完全相同。席位已接受此差异。回滚即撤销本 PR。 - **先看**:`packages/plugins/plugin-security/src/claim-seed-ownership.ts`(`claimPredicateInBatches`、`CLAIM_HANDOVER_ROWS`、`claimRows` 里的让出);`packages/plugins/organizations/src/claim-org-seed-ownership.ts`(`claimChunk`);两个新的 `*-yield.pin.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/plugin-security exec vitest run src/claim-seed-ownership-yield.pin.test.ts`,预期 5 passed。 - **需要你做的**:无,席位落地。 ## What changes **The reading first, on `main` at `e78d7057`.** The card's attribution came from a CPU profile whose async stacks are partial, so it was re-measured before any edit. A scratch app (one object with a file field, 1,500 seeded rows, each with a 20 KB `seedFile`) booted with `os serve --dev` over file-backed sqlite, `OS_INLINE_SEED_BUDGET_MS=1` so the seed settles in the background, a 20 ms event-loop lag sampler preloaded, and a sign-in (`POST /api/v1/auth/sign-in/email`) every 250 ms: - Three boots: the event loop stalled 2,763 / 2,762 / 2,742 ms in one stretch that starts at `Seed loading complete` and ends at `handed 1500 seeded record(s)`. Sign-ins issued inside it took 2.0–3.2 s. - A timing marker spliced into `plugin-security`'s `dist` around the claim's `ql.update` (removed afterwards; the dist was copied back) read three writes: `usr_system` arm 2 ms, the NULL arm's ONE write over 1,500 ids **2,641 ms**, then the warm re-pass 2 ms. So the whole stall is one engine write. - The CPU profile of that stretch is per-row hook work inside that write: better-sqlite3 `_query`/`prepare`, plugin-audit's `writeAudit` → `persistAuditTrailRow` (one audit row per row), engine `insert`, the storage file-reference hooks, plugin-auth and plugin-sharing middleware. Control on the same boot without a file field: the claim ran about 1.1 s with no single stall above 483 ms. **Why the cure is a smaller write, not a yield inside one.** The engine runs a predicate write and its after-hooks in one transaction (`inAfterAbortUnit`), and SQLite's pool holds one connection. A yield inside the write would leave the sign-in waiting on the pool instead of the event loop. So the atomic unit has to shrink. **The fix.** 1. `claim-seed-ownership.ts`: the NULL arm's chunk (still `CLAIM_PAGE_ROWS` = 5,000 ids, the unit of failure reporting) is written `CLAIM_HANDOVER_ROWS` = 50 ids at a time by the new `claimPredicateInBatches`, with one `setImmediate` turn after every write. 50 is measured: about 1.8 ms of hook work per claimed row, so about 90 ms per batch. The `owner_id = usr_system` arm is unchanged (one predicate write, paged only on the per-row hook refusal) and yields after it. The by-row path (`claimRows`, objects whose stored expression reads `owner_id`) yields after every 50 rows; its writes were already one per row. 2. `claim-org-seed-ownership.ts` (the multi-organization twin) had the same unbroken run: one write per 5,000-id chunk, inside the request that creates the organization. `claimChunk` now writes 50 ids at a time with a yield after each, and `claimChunkByRow` yields every 50 rows. 3. The yield helper is a module-private copy in each file, by the seat's ruling on this card (option B): the existing copies in `metadata-protocol/src/seed-files.ts` and `core/src/utils/import-runner.ts` are not exported, and exporting one would widen a published surface. Each docblock names the other copies. **Failure semantics, as accepted by the seat.** Success is unchanged: the same rows, the same owner, one audit row per claimed row. A chunk every batch of which is refused behaves exactly as on `main`: plugin-security rethrows the first refusal so the caller logs the same single warning and the chunk stays unowned; organizations logs the same `the N seeded row(s) of this write stay owner-less` warning. When only some batches of a chunk are refused, the accepted batches stay owned, one warning names the rows left unowned (count, the first 20 ids, the first error), and the next run claims them; on `main` one refused write left the whole chunk unowned. **Wall time.** The hand-over itself is not faster: on the live boot it spanned 3.4 s after the fix against 2.6 s before, because sign-ins now run between its batches. ## Why The first admin who signs in while a demo or package seed is still settling waited several seconds on a fresh boot. ## Tests - New `packages/plugins/plugin-security/src/claim-seed-ownership-yield.pin.test.ts`, on a real `ObjectQL` over `SqlDriver` (better-sqlite3 `:memory:`), 320 seeded rows, a code-registered `afterUpdate` hook writing one audit row per updated row (the shape of plugin-audit's writer) and a `beforeUpdate` hook that can refuse named rows: - `re-owns at most one batch of rows between two macrotask turns` - `serves a read issued during the hand-over before the hand-over ends` - `hands every seeded row to the admin with one audit row per claimed row` - `a refused batch leaves only its rows unowned, says which, and the next run claims them` - `every batch refused: nothing claimed, and the one warning a refused chunk always gave` - New `packages/plugins/organizations/src/claim-org-seed-ownership-yield.pin.test.ts`: the same five on `SqliteWasmDriver`, plus a row in another organization that stays untouched. - Updated shape pins that counted writes per 5,000-id chunk: plugin-security `issues ONE predicate write per unowned shape and hand-over batch`, the over-ceiling pin (the NULL arm's writes counted apart from the `usr_system` arm's), `writes the seeded ids in hand-over batches`; organizations' 10,500-row pin, the unreadable-count pin (now one warning per chunk) and the failed-chunk pin (now refuses every write of the second chunk). **Red first.** Production source restored to `main` (`git show e78d705:FILE`), the new pins run, restored from `HEAD` and proven by blob hash: - plugin-security: 3 failed, 2 passed. `no macrotask turn ran while the hand-over ran: expected 1 to be greater than 6.4`; `expected [ 'hand-over', 'read' ] to deeply equal [ 'read', 'hand-over' ]`; the partial-failure pin red (`main` leaves the whole chunk unowned). The result pin and the every-batch-refused pin are GREEN on `main`: that is the proof that ownership, audit rows and the total-failure warning are identical. - organizations: the same 3 failed, 2 passed. **Ablations** (`scripts/ablation-replace.mjs`, anchor counts and the HEAD-blob restore checked by the tool; the tests import source, so no `dist` is involved): - A, the yield resolves at once: plugin-security 2 failed (the two yield pins); organizations 2 failed (the same two). - B, `CLAIM_HANDOVER_ROWS = CLAIM_PAGE_ROWS`: plugin-security 3 failed (`expected 5 to be greater than 6.4`, `expected 320 to be less than 320`, partial-failure pin). - C, no rethrow when every batch is refused: plugin-security 1 failed (the every-batch-refused pin's warning text). **Live re-measure** with the fix built into `dist`, same app and boot: the stretch from `Seed loading complete` to `handed 1500` had no stall above 190 ms (before: 2,669–2,763 ms in one stretch), and the 14 sign-ins issued in it answered p50 317 ms, slowest 539 ms (before: p50 1,967 ms, slowest 2,698 ms). ## Acceptance notes - **Sharing rules on a large seeded object.** plugin-sharing's `afterUpdate` recomputes per row up to 1,000 rows per write and above that revokes the object's rule grants and re-grants them in the background (with a warning). A 50-row batch is always under that cap, so on an object with sharing rules and more than 1,000 seeded rows the claim now recomputes per row inline (no revoke-and-re-grant window) instead of one revoke plus a queued re-grant. The end state is the same rule grants; the `CLAIM_PAGE_ROWS` docblock reasoning about the 1,000 cap still applies to the `usr_system` arm's pages. - **The `usr_system` arm is unchanged**, by ruling. It is still one predicate write over every such row, so a seed whose records set `owner_id` to the seed identity would still be claimed in one run. No producer was found: HotCRM and the examples write no `owner_id` in seeds, and the measured seed landed every row NULL. Noted, not filed. - On `InMemoryDriver` the NULL arm's cost does not change: an `id IN` list of B ids over T rows costs T×B per write and there are N/B writes, the same T×N as one write. ## Verification <details><summary>Readings (final head d51a116)</summary> All at `d51a116d` (the final head); each exit code captured before any pipe. - `pnpm --filter @objectstack/plugin-security test && pnpm --filter @objectstack/plugin-security typecheck` under the verify lock: `Test Files 232 passed (232)`, `Tests 5037 passed | 45 skipped (5082)`, `check:test-typecheck: OK`, lock VERDICT command-exit 0. The new test files are in `tsconfig.test.json`'s program, which `check:test-typecheck` compiles. - `pnpm --filter @objectstack/organizations test && pnpm --filter @objectstack/organizations typecheck`: `Test Files 18 passed (18)`, `Tests 248 passed (248)`, lock VERDICT command-exit 0. - `pnpm exec eslint --no-inline-config packages/plugins/plugin-security packages/plugins/organizations`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 62 commands; each was run: 61 exit 0, `pnpm check:dual-build-cjs-loads` exit 3 (PREREQUISITE NOT MET, needs a full build: NOT MEASURED, CI runs it). `node scripts/check-plugin-teardown-shape.mjs --self-test` first exited 3 (a shallow checkout lacked its positive-control commit) and exit 0 after `git fetch --depth=1` of that commit. `--ran` reconciliation: `62 derived famil(ies) accounted for — 61 run, 1 NOT-MEASURED`, exit 0. - Dependency closure built before any reading: `pnpm turbo run build --filter=@objectstack/cli... --filter=@objectstack/organizations... --concurrency=2`, lock VERDICT command-exit 0; then both touched packages rebuilt for the live re-measure. - Overlap: none of the 41 open PRs touches `claim-seed-ownership*` or `claim-org-seed-ownership*` (each PR's `/files` read). - Live boots and the dist timing marker: scratch scripts, not committed; numbers in What changes and Tests. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No `packages/spec` change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (eslint on both touched packages, exit 0). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the maintainer summary is in Chinese by the lane's convention). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5mvv8ZvVXKGoTSX3eE)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c47e87 commit 2a40f98

7 files changed

Lines changed: 759 additions & 59 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/organizations': patch
4+
---
5+
6+
fix(plugin-security): the seed ownership hand-over no longer holds the event loop for seconds, so a sign-in during it is answered
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The seed ownership hand-over that runs when a seed settles (`app:seeded`, plugin-security's `claimSeedOwnership`) and its multi-organization twin (`claimOrgSeedOwnership` in `@objectstack/organizations`) no longer re-own a large seed in one unbroken run. On a synchronous driver (better-sqlite3, the wasm fallback) every step of a claim write resolves as a microtask, so one write over a 1,500-row seed ran its per-row hooks and audit rows for 2.6 s and a sign-in issued meanwhile waited for all of it. The claim now writes the seeded rows 50 at a time and yields the event loop after every write (the by-row path after every 50 rows); a sign-in during it answers in hundreds of milliseconds. The same rows go to the same owner with the same audit rows. If only some batches of a chunk are refused, the other batches stay claimed and one warning names the rows left unowned, which the next run claims; a chunk every batch of which is refused is reported and left unowned as before.
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26825] The organization seed ownership claim yields to the event loop
5+
* between bounded batches — measured on a REAL `ObjectQL` over a real
6+
* `SqliteWasmDriver`, a synchronous driver: every `await` of a claim write
7+
* resolves as a microtask there, so one chunk's write used to run its whole
8+
* per-row fan-out (code hooks, an audit row per row) as one unbroken chain.
9+
* Its single-tenant twin was measured stalling the event loop 2.7 s on a
10+
* 1 500-row seed (`claim-seed-ownership-yield.pin.test.ts`, plugin-security).
11+
*
12+
* 1. **the yield** — at most one batch of rows is re-owned between two
13+
* macrotask turns, and a read issued meanwhile completes before the claim;
14+
* 2. **the result** — every seeded row of the organization is owned by its
15+
* owner, with one audit row per claimed row, and no other row is touched;
16+
* 3. **a batch refused** — the other batches stay claimed, one warning names
17+
* the rows left owner-less and the first refusal, and the next run claims
18+
* them;
19+
* 4. **every batch refused** — nothing is claimed, and the one warning is the
20+
* one a refused chunk always gave.
21+
*/
22+
23+
import { describe, it, expect, afterEach, vi } from 'vitest';
24+
import { ObjectQL } from '@objectstack/objectql';
25+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
26+
27+
import { claimOrgSeedOwnership } from './claim-org-seed-ownership.js';
28+
29+
const SYS = { context: { isSystem: true } } as const;
30+
const ORG = 'org_alpha';
31+
const OTHER_ORG = 'org_north';
32+
const OWNER = 'usr_org_owner';
33+
const OBJECT = 'probe_deal';
34+
const AUDIT = 'probe_audit';
35+
/** The claim's hand-over batch (`CLAIM_HANDOVER_ROWS`, module-private). */
36+
const HANDOVER_ROWS = 50;
37+
/** Seeded rows: six batches, and one partial batch at the end. */
38+
const N = 6 * HANDOVER_ROWS + 20;
39+
40+
const OBJECTS = [
41+
{
42+
name: OBJECT,
43+
label: 'Probe Deal',
44+
fields: {
45+
id: { name: 'id', type: 'text', primaryKey: true },
46+
name: { name: 'name', type: 'text' },
47+
owner_id: { name: 'owner_id', type: 'text' },
48+
organization_id: { name: 'organization_id', type: 'text' },
49+
},
50+
},
51+
{
52+
name: AUDIT,
53+
label: 'Probe Audit',
54+
fields: {
55+
record_id: { name: 'record_id', type: 'text' },
56+
organization_id: { name: 'organization_id', type: 'text' },
57+
},
58+
},
59+
];
60+
61+
const engines: ObjectQL[] = [];
62+
afterEach(async () => {
63+
while (engines.length) {
64+
try { await engines.pop()?.destroy(); } catch { /* noop */ }
65+
}
66+
});
67+
68+
/** The organization's seed replay reported landing on these rows. */
69+
const seededAre = (ids: string[]) => (object: string): readonly string[] => (object === OBJECT ? ids : []);
70+
71+
/**
72+
* A booted engine with `N` owner-less seeded deals in `ORG`, one owner-less
73+
* deal in another organization, and a code-registered `afterUpdate` hook
74+
* writing one audit row per updated deal. `refuse` names the deals a
75+
* code-registered `beforeUpdate` hook refuses (`*` refuses every one).
76+
*/
77+
async function boot() {
78+
const engine = new ObjectQL();
79+
engine.registerDriver(new SqliteWasmDriver({ filename: ':memory:' } as never) as never, true);
80+
await engine.init();
81+
engine.registerApp({
82+
id: 'com.objectstack.org-claim-yield-26825',
83+
name: 'Organization claim yield',
84+
version: '1.0.0',
85+
type: 'plugin',
86+
scope: 'system',
87+
objects: OBJECTS,
88+
} as never);
89+
await engine.syncSchemas();
90+
engines.push(engine);
91+
92+
const ids = Array.from({ length: N }, (_, i) => `d${String(i).padStart(4, '0')}`);
93+
const driver = (engine as any).getDriverForObject(OBJECT);
94+
await driver.bulkCreate(OBJECT, [
95+
...ids.map((id) => ({ id, name: `Deal ${id}`, owner_id: null, organization_id: ORG })),
96+
{ id: 'foreign', name: 'Foreign', owner_id: null, organization_id: OTHER_ORG },
97+
]);
98+
99+
const refuse = new Set<string>();
100+
let claimedRows = 0;
101+
engine.registerHook('beforeUpdate', async (ctx: any) => {
102+
const id = String(ctx?.input?.id ?? '');
103+
if (refuse.has(id) || refuse.has('*')) throw new Error(`probe refused ${id}`);
104+
}, { object: OBJECT, packageId: 'probe.refuse' });
105+
engine.registerHook('afterUpdate', async (ctx: any) => {
106+
claimedRows += 1;
107+
await (engine as any).insert(AUDIT, { record_id: String(ctx?.input?.id ?? ''), organization_id: ORG }, SYS);
108+
}, { object: OBJECT, packageId: 'probe.audit' });
109+
110+
return { engine, ids, refuse, claimed: () => claimedRows };
111+
}
112+
113+
async function ownersOf(engine: ObjectQL): Promise<Map<string, unknown>> {
114+
const rows = await (engine as any).find(OBJECT, { fields: ['id', 'owner_id'] }, SYS);
115+
return new Map(rows.map((r: any) => [r.id, r.owner_id ?? null]));
116+
}
117+
118+
async function auditRows(engine: ObjectQL): Promise<string[]> {
119+
const rows = await (engine as any).find(AUDIT, { fields: ['record_id'] }, SYS);
120+
return rows.map((r: any) => String(r.record_id)).sort();
121+
}
122+
123+
const nextTurn = () => new Promise<void>((resolve) => setImmediate(resolve));
124+
125+
describe('[#26825] the organization seed ownership claim yields between bounded batches (real engine, sqlite-wasm)', () => {
126+
it('re-owns at most one batch of rows between two macrotask turns', async () => {
127+
const { engine, ids, claimed } = await boot();
128+
let done = false;
129+
let maxPerTurn = 0;
130+
let turns = 0;
131+
const sampler = (async () => {
132+
let seen = claimed();
133+
while (!done) {
134+
await nextTurn();
135+
turns += 1;
136+
maxPerTurn = Math.max(maxPerTurn, claimed() - seen);
137+
seen = claimed();
138+
}
139+
})();
140+
141+
const result = await claimOrgSeedOwnership(engine as never, ORG, OWNER, { seededRecordIds: seededAre(ids) });
142+
done = true;
143+
await sampler;
144+
145+
expect(result).toEqual([{ object: OBJECT, count: N }]);
146+
expect(turns, 'no macrotask turn ran while the claim ran').toBeGreaterThan(N / HANDOVER_ROWS);
147+
expect(maxPerTurn).toBeLessThanOrEqual(HANDOVER_ROWS);
148+
}, 60_000);
149+
150+
it('serves a read issued during the claim before the claim ends', async () => {
151+
const { engine, ids, claimed } = await boot();
152+
const order: string[] = [];
153+
let claimedAtRead: number | undefined;
154+
const claim = claimOrgSeedOwnership(engine as never, ORG, OWNER, { seededRecordIds: seededAre(ids) })
155+
.then(() => { order.push('claim'); });
156+
const read = nextTurn()
157+
.then(() => (engine as any).find(OBJECT, { where: { id: 'foreign' } }, SYS))
158+
.then((rows: any[]) => {
159+
expect(rows).toHaveLength(1);
160+
claimedAtRead = claimed();
161+
order.push('read');
162+
});
163+
await Promise.all([claim, read]);
164+
165+
expect(order).toEqual(['read', 'claim']);
166+
expect(claimedAtRead).toBeLessThan(N);
167+
}, 60_000);
168+
169+
it('hands every seeded row to the owner with one audit row per claimed row, and no other row', async () => {
170+
const { engine, ids } = await boot();
171+
172+
const result = await claimOrgSeedOwnership(engine as never, ORG, OWNER, { seededRecordIds: seededAre(ids) });
173+
174+
expect(result).toEqual([{ object: OBJECT, count: N }]);
175+
const owners = await ownersOf(engine);
176+
for (const id of ids) expect(owners.get(id)).toBe(OWNER);
177+
expect(owners.get('foreign')).toBeNull();
178+
expect(await auditRows(engine)).toEqual(ids);
179+
}, 60_000);
180+
181+
it('a refused batch leaves only its rows owner-less, says which, and the next run claims them', async () => {
182+
const { engine, ids, refuse } = await boot();
183+
const refusedBatch = ids.slice(2 * HANDOVER_ROWS, 3 * HANDOVER_ROWS);
184+
refuse.add(refusedBatch[7]!);
185+
const warn = vi.fn();
186+
187+
const first = await claimOrgSeedOwnership(engine as never, ORG, OWNER, {
188+
seededRecordIds: seededAre(ids),
189+
logger: { info: vi.fn(), warn },
190+
});
191+
192+
expect(first).toEqual([{ object: OBJECT, count: N - HANDOVER_ROWS }]);
193+
const owners = await ownersOf(engine);
194+
for (const id of ids) expect(owners.get(id)).toBe(refusedBatch.includes(id) ? null : OWNER);
195+
expect(await auditRows(engine)).toEqual(ids.filter((id) => !refusedBatch.includes(id)));
196+
expect(warn).toHaveBeenCalledTimes(1);
197+
const [message, meta] = warn.mock.calls[0]!;
198+
expect(message).toBe(
199+
`[org-scoping] claimOrgSeedOwnership failed for ${OBJECT} in org ${ORG}: ` +
200+
`${HANDOVER_ROWS} of the ${N} seeded row(s) of this chunk stay owner-less`,
201+
);
202+
expect(meta).toMatchObject({ object: OBJECT, organizationId: ORG, ids: N, refused: HANDOVER_ROWS, refusedIds: refusedBatch.slice(0, 20) });
203+
expect(meta.error).toContain(`probe refused ${refusedBatch[7]}`);
204+
205+
refuse.clear();
206+
const second = await claimOrgSeedOwnership(engine as never, ORG, OWNER, { seededRecordIds: seededAre(ids) });
207+
208+
expect(second).toEqual([{ object: OBJECT, count: HANDOVER_ROWS }]);
209+
for (const id of ids) expect((await ownersOf(engine)).get(id)).toBe(OWNER);
210+
expect(await auditRows(engine)).toEqual(ids);
211+
}, 60_000);
212+
213+
it('every batch refused: nothing claimed, and the one warning a refused chunk always gave', async () => {
214+
const { engine, ids, refuse } = await boot();
215+
refuse.add('*');
216+
const warn = vi.fn();
217+
218+
const result = await claimOrgSeedOwnership(engine as never, ORG, OWNER, {
219+
seededRecordIds: seededAre(ids),
220+
logger: { info: vi.fn(), warn },
221+
});
222+
223+
expect(result).toEqual([]);
224+
for (const owner of (await ownersOf(engine)).values()) expect(owner).toBeNull();
225+
expect(await auditRows(engine)).toEqual([]);
226+
expect(warn).toHaveBeenCalledTimes(1);
227+
const [message, meta] = warn.mock.calls[0]!;
228+
expect(message).toBe(
229+
`[org-scoping] claimOrgSeedOwnership failed for ${OBJECT} in org ${ORG}: ` +
230+
`the ${N} seeded row(s) of this write stay owner-less`,
231+
);
232+
expect(meta).toEqual({ object: OBJECT, organizationId: ORG, ids: N, error: expect.stringContaining('probe refused') });
233+
}, 60_000);
234+
});

‎packages/plugins/organizations/src/claim-org-seed-ownership.test.ts‎

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ function seededIds(byObject: Record<string, string[]>): (object: string) => read
120120
}
121121

122122
const CHUNK = Math.floor(MAX_BULK_PER_ROW_HOOK_ROWS / 2);
123+
/** [#26825] Seeded ids one claim write carries: the claim's hand-over batch. */
124+
const HANDOVER_ROWS = 50;
123125

124126
describe('claimOrgSeedOwnership', () => {
125127
it('returns [] when registry is unavailable', async () => {
@@ -291,26 +293,27 @@ describe('claimOrgSeedOwnership', () => {
291293
expect(rows.find((r) => r.id === 'owned')!.owner_id).toBe(SOMEONE);
292294
expect(rows.find((r) => r.id === 'foreign')!.owner_id).toBeNull();
293295

294-
// Three chunks, each under the ceiling; every write keeps the claim's
295-
// predicate and names its chunk; nothing was read.
296-
expect(writes.map((w) => w.matched.length)).toEqual([CHUNK, CHUNK, N - 2 * CHUNK]);
296+
// Three chunks, each under the ceiling, [#26825] each written in hand-over
297+
// batches; every write keeps the claim's predicate and names its batch;
298+
// nothing was read.
299+
expect(writes.map((w) => w.matched.length)).toEqual(Array.from({ length: N / HANDOVER_ROWS }, () => HANDOVER_ROWS));
297300
for (const w of writes) {
298301
expect(Object.keys(w.where).sort()).toEqual(['id', 'organization_id', 'owner_id']);
299302
expect(w.where).toMatchObject({ organization_id: ORG, owner_id: null });
300-
expect(w.where.id.$in.length).toBeLessThanOrEqual(CHUNK);
303+
expect(w.where.id.$in.length).toBeLessThanOrEqual(HANDOVER_ROWS);
301304
expect(w.context).toEqual({ isSystem: true, skipAutomations: true });
302305
}
303306
expect(ql.find).not.toHaveBeenCalled();
304307
});
305308

306-
it('an unreadable count is warned as unknown, counts nothing, and the next chunk still lands', async () => {
309+
it('an unreadable count is warned as unknown, counts nothing, and the next write still lands', async () => {
307310
const schemas = [{ name: 'crm_lead', fields: BOTH_COLUMNS }];
308311
const seeded = Array.from({ length: CHUNK + 1 }, (_, i) => `l${String(i).padStart(5, '0')}`);
309312
const rows = seeded.map((id) => ({ id, organization_id: ORG, owner_id: null as string | null }));
310313
const logger = makeLogger();
311314
let call = 0;
312315
const { ql } = makeQL(schemas, { crm_lead: rows }, {
313-
// The first chunk's driver answers a record rather than a count.
316+
// The first write's driver answers a record rather than a count.
314317
writeAnswer: (_object, where) => {
315318
call += 1;
316319
if (call === 1) return { id: 'not-a-count' };
@@ -323,8 +326,11 @@ describe('claimOrgSeedOwnership', () => {
323326

324327
expect(
325328
await claimOrgSeedOwnership(ql, ORG, OWNER, { logger, seededRecordIds: seededIds({ crm_lead: seeded }) }),
326-
).toEqual([{ object: 'crm_lead', count: 1 }]);
327-
expect(ql.update).toHaveBeenCalledTimes(2);
329+
).toEqual([{ object: 'crm_lead', count: CHUNK + 1 - HANDOVER_ROWS }]);
330+
// [#26825] One write per hand-over batch: the first chunk's, then the
331+
// second chunk's one id.
332+
expect(ql.update).toHaveBeenCalledTimes(CHUNK / HANDOVER_ROWS + 1);
333+
expect(logger.warn).toHaveBeenCalledTimes(1);
328334
expect(logger.warn).toHaveBeenCalledWith(
329335
expect.stringContaining('could not read an affected-row count'),
330336
expect.objectContaining({ object: 'crm_lead', organizationId: ORG, ids: CHUNK }),
@@ -340,13 +346,12 @@ describe('claimOrgSeedOwnership', () => {
340346
const leads = seeded.map((id) => ({ id, organization_id: ORG, owner_id: null as string | null }));
341347
const deals = [{ id: 'd1', organization_id: ORG, owner_id: null }];
342348
const logger = makeLogger();
343-
let leadWrites = 0;
349+
const secondChunk = new Set(seeded.slice(CHUNK, 2 * CHUNK));
344350
const { ql } = makeQL(schemas, { crm_lead: leads, crm_deal: deals }, {
345-
// The second crm_lead chunk is refused outright by a code hook.
346-
failWrite: (object) => {
351+
// Every write of the second crm_lead chunk is refused outright by a code hook.
352+
failWrite: (object, where) => {
347353
if (object !== 'crm_lead') return undefined;
348-
leadWrites += 1;
349-
return leadWrites === 2 ? new Error('a code hook refused the write') : undefined;
354+
return secondChunk.has(where.id.$in[0]) ? new Error('a code hook refused the write') : undefined;
350355
},
351356
});
352357

@@ -361,9 +366,10 @@ describe('claimOrgSeedOwnership', () => {
361366
]);
362367
expect(leads.filter((r) => r.owner_id === OWNER)).toHaveLength(CHUNK + 1);
363368
expect(deals[0].owner_id).toBe(OWNER);
369+
expect(logger.warn).toHaveBeenCalledTimes(1);
364370
expect(logger.warn).toHaveBeenCalledWith(
365-
expect.stringContaining('failed for crm_lead'),
366-
expect.objectContaining({ object: 'crm_lead', ids: CHUNK, error: 'a code hook refused the write' }),
371+
expect.stringContaining(`failed for crm_lead in org ${ORG}: the ${CHUNK} seeded row(s) of this write stay owner-less`),
372+
{ object: 'crm_lead', organizationId: ORG, ids: CHUNK, error: 'a code hook refused the write' },
367373
);
368374
});
369375
});

0 commit comments

Comments
 (0)