Skip to content

Commit 13a0b6e

Browse files
committed
feat(rest): exact import-retry idempotency via pre-assigned row ids; adopt partial-success create (#3173, #3172)
Pure-insert imports (no matchFields) had no natural key to recheck on a commit-then-lost-response retry, so they stayed at-least-once — a retry duplicated the whole batch (#3149 left this documented, #3173 tracked closing it). Every buffered CREATE row now gets a client-generated id at flush time (stable across attempts; an explicit user-supplied id is respected — both the SQL driver and bulkCreate honor caller ids). The retry path rechecks by id ($in) and re-inserts only rows that truly did not land. This is exact for every write mode: legitimate duplicate rows in insert mode resolve correctly because each copy has its own id — the case a natural-key or fingerprint recheck cannot distinguish. The old matchFields-based recheck is replaced by the id recheck. The import runner also prefers the protocol's new insertManyData (framework#3172) via bulkWrite's writeBatchPartial: a row that fails validation is a per-row verdict from one call, so a bad row no longer forces the whole-batch degradation that re-ran beforeInsert hooks on its siblings. Protocols without insertManyData keep the createManyData path. Flips the previously pinned at-least-once test: pure-insert retry after commit now expects zero duplicates. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APnSDJneEDRh3Z51KGCLga
1 parent 9992e02 commit 13a0b6e

3 files changed

Lines changed: 169 additions & 27 deletions

File tree

packages/rest/src/import-runner-bulk.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,11 @@ describe('runImport — bulk create batching (framework#2678)', () => {
5353
expect(summary.created).toBe(250);
5454
expect(summary.results).toHaveLength(250);
5555
expect(summary.results.map((r) => r.row)).toEqual(Array.from({ length: 250 }, (_, i) => i + 1));
56-
expect(summary.results[0]).toMatchObject({ ok: true, action: 'created', id: 'id_r0' });
57-
expect(summary.results[249]).toMatchObject({ ok: true, action: 'created', id: 'id_r249' });
56+
// Rows carry a pre-assigned id (framework#3173), echoed back by the mock.
57+
expect(summary.results[0]).toMatchObject({ ok: true, action: 'created' });
58+
expect(summary.results[0].id).toBeTruthy();
59+
expect(summary.results[249]).toMatchObject({ ok: true, action: 'created' });
60+
expect(summary.results[249].id).toBeTruthy();
5861
});
5962

6063
it('retries a transient createManyData failure instead of dropping the batch', async () => {

packages/rest/src/import-runner-idempotency.test.ts

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,12 @@ function makeProtocol(opts: { firstCall?: 'throw' | 'shortReturn' } = {}) {
5151
});
5252
const findData = vi.fn(async (args: { query?: { $filter?: Record<string, any> } }) => {
5353
const filter = args.query?.$filter ?? {};
54-
return store.filter((row) => Object.entries(filter).every(([k, v]) => row[k] === v));
54+
// Supports equality and { $in: [...] } — the id recheck (framework#3173)
55+
// queries by pre-assigned id $in, like the real SQL driver does.
56+
return store.filter((row) => Object.entries(filter).every(([k, v]) => {
57+
if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]);
58+
return row[k] === v;
59+
}));
5560
});
5661
const p: ImportProtocolLike = { findData, createData, updateData: vi.fn(), createManyData };
5762
return { p, store, createManyData, createData };
@@ -91,17 +96,67 @@ describe('runImport — idempotent retry with natural keys (framework#3149)', ()
9196
expect(summary.errors).toBe(0);
9297
});
9398

94-
it('pure insert (no matchFields): retry is at-least-once — duplicates are the documented contract', async () => {
95-
const { p, store } = makeProtocol({ firstCall: 'throw' });
99+
it('pure insert (no matchFields): pre-assigned ids make the retry exactly-once too (#3173)', async () => {
100+
const { p, store, createManyData } = makeProtocol({ firstCall: 'throw' });
96101

97-
await runImport({
102+
const summary = await runImport({
98103
...baseOpts, p, writeMode: 'insert', matchFields: [],
99104
rows: [{ name: 'x' }, { name: 'y' }],
100105
});
101106

102-
// No natural key to recheck against: the committed-then-retried batch is
103-
// written twice. This pins the contract (exactly-once needs matchFields).
104-
expect(store).toHaveLength(4);
107+
// Previously pinned as at-least-once (4 rows). With pre-assigned row ids
108+
// the retry rechecks by id and re-inserts nothing — no natural key needed.
109+
expect(createManyData).toHaveBeenCalledTimes(1); // committed once; retry only rechecked
110+
expect(store).toHaveLength(2);
111+
expect(summary.created).toBe(2);
112+
expect(summary.errors).toBe(0);
113+
});
114+
115+
it('pure insert: legitimate duplicate rows survive the retry intact (each copy has its own id) (#3173)', async () => {
116+
const { p, store } = makeProtocol({ firstCall: 'throw' });
117+
118+
const summary = await runImport({
119+
...baseOpts, p, writeMode: 'insert', matchFields: [],
120+
rows: [{ name: 'same' }, { name: 'same' }], // two intentional copies
121+
});
122+
123+
// A natural-key recheck could not tell the copies apart; the per-row id
124+
// recheck keeps exactly the two intended rows — no loss, no duplication.
125+
expect(store).toHaveLength(2);
126+
expect(summary.created).toBe(2);
127+
expect(summary.errors).toBe(0);
128+
});
129+
130+
it('insertManyData (partial success): a bad row is a per-row verdict — good rows never re-run (framework#3172)', async () => {
131+
const store: Array<Record<string, any>> = [];
132+
const insertManyData = vi.fn(async (args: { records: any[] }) => ({
133+
outcomes: args.records.map((r) => {
134+
if (r.name === 'bad') return { ok: false, error: new Error('validation failed: bad name') };
135+
const rec = { ...r };
136+
store.push(rec);
137+
return { ok: true, record: rec };
138+
}),
139+
}));
140+
const createManyData = vi.fn();
141+
const createData = vi.fn();
142+
const p: ImportProtocolLike = {
143+
findData: vi.fn(async () => []), createData, updateData: vi.fn(), createManyData, insertManyData,
144+
};
145+
146+
const summary = await runImport({
147+
...baseOpts, p, writeMode: 'insert', matchFields: [],
148+
rows: [{ name: 'good1' }, { name: 'bad' }, { name: 'good2' }],
149+
});
150+
151+
expect(insertManyData).toHaveBeenCalledTimes(1); // one call, per-row verdicts
152+
expect(createManyData).not.toHaveBeenCalled(); // partial path preferred
153+
expect(createData).not.toHaveBeenCalled(); // NO degradation re-run for good rows
154+
expect(summary.created).toBe(2);
155+
expect(summary.errors).toBe(1);
156+
expect(summary.results[0]).toMatchObject({ ok: true, action: 'created' });
157+
expect(summary.results[1]).toMatchObject({ ok: false, action: 'failed' });
158+
expect(summary.results[2]).toMatchObject({ ok: true, action: 'created' });
159+
expect(store).toHaveLength(2);
105160
});
106161

107162
it('marks rows created-with-warning on a summary recompute failure, without failing or duplicating (framework#3147)', async () => {

packages/rest/src/import-runner.ts

Lines changed: 102 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3+
import { randomUUID } from 'node:crypto';
34
import { coerceRow, firstMissingRequiredField, type RefResolver, type RefMatch } from './import-coerce.js';
45
import type { ExportFieldMeta } from './export-format.js';
56
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from '@objectstack/core';
@@ -76,6 +77,14 @@ export interface ImportProtocolLike {
7677
* record per input row, in the same order.
7778
*/
7879
createManyData?(args: { object: string; records: any[]; context?: any; environmentId?: string }): Promise<{ records: any[] }>;
80+
/**
81+
* Optional partial-success bulk create (framework#3172). When present it is
82+
* preferred over `createManyData`: one outcome per input row, in order — a
83+
* row that fails validation is a per-row verdict, so a bad row never forces
84+
* the whole-batch degradation that re-runs beforeInsert hooks on the good
85+
* rows.
86+
*/
87+
insertManyData?(args: { object: string; records: any[]; context?: any; environmentId?: string }): Promise<{ outcomes: Array<{ ok: boolean; record?: any; error?: unknown }> }>;
7988
}
8089

8190
export interface RunImportOptions {
@@ -272,12 +281,19 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
272281
// without `createManyData` never buffers — `canBulkCreate` is false and
273282
// creates fall back to the original inline per-row `createData` call.
274283
const canBulkCreate = typeof p.createManyData === 'function';
284+
// Partial-success flush (framework#3172): preferred when the protocol
285+
// offers it — a row that fails validation is a per-row verdict from one
286+
// batch call, so a bad row never forces the whole-batch degradation that
287+
// re-runs beforeInsert hooks on its siblings.
288+
const canPartialCreate = typeof p.insertManyData === 'function';
275289
const pendingCreates: Array<{ index: number; rowNo: number; data: Record<string, any> }> = [];
276290
// bulkWrite is at-least-once: a retry (or a mismatch-driven degradation) may
277-
// re-run a create whose prior attempt already committed. When the import has
278-
// natural keys (matchFields), recheck before re-creating so a retry can't
279-
// duplicate a row (framework#3149). A pure-insert import (no matchFields) has
280-
// no natural key to recheck against and stays at-least-once by contract.
291+
// re-run a create whose prior attempt already committed. Every buffered
292+
// CREATE row is therefore pre-assigned a client-generated id at flush time
293+
// (framework#3173) — stable across attempts — so a retry can recheck by id
294+
// ($in) and re-insert only the rows that truly did not land. This is exact
295+
// for EVERY write mode (including pure insert with legitimate duplicate
296+
// rows, where a natural-key recheck could not distinguish copies).
281297
let lastBatchUncertain = false;
282298
// Set when a flush's write succeeded but its post-write roll-up summary
283299
// recompute exhausted retries (framework#3147). The rows ARE written; we mark
@@ -296,15 +312,42 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
296312
}
297313
return null;
298314
};
299-
const existingByMatch = async (data: Record<string, any>): Promise<Record<string, any> | null> => {
300-
if (matchFields.length === 0) return null;
301-
const found = await findExisting(data);
302-
return found && typeof found === 'object' ? found : null; // ignore 'blank'/'none'/'ambiguous'
315+
// Exact idempotency recheck (framework#3173): buffered CREATE rows carry a
316+
// pre-assigned id, so "did the lost-response attempt actually commit?" is
317+
// answered precisely by an id $in query — no natural key, no clocks, and
318+
// legitimate duplicate rows (pure insert mode) resolve correctly because
319+
// each copy has its own id.
320+
const recheckByIds = async (chunk: Array<Record<string, any>>): Promise<Map<string, any>> => {
321+
const ids = chunk.map((r) => r.id).filter((v) => v != null && v !== '');
322+
if (ids.length === 0) return new Map();
323+
const r = await p.findData({
324+
...findArgsBase({ $filter: { id: { $in: ids } }, $top: ids.length }),
325+
object: objectName,
326+
});
327+
return new Map(findRows(r).map((rec: any) => [String(rec.id), rec]));
303328
};
304329
const flushPendingCreates = async (): Promise<void> => {
305330
if (pendingCreates.length === 0) return;
306331
flushSummaryStale = false;
307332
const batch = pendingCreates.splice(0, pendingCreates.length);
333+
// Pre-assign ids once per row (framework#3173) — the closures below see
334+
// the same row objects on every retry attempt, so the ids are stable. An
335+
// id the user supplied explicitly is respected.
336+
for (const b of batch) {
337+
if (b.data.id == null || b.data.id === '') b.data.id = randomUUID();
338+
}
339+
// Recheck helper shared by both write paths: on attempt > 1 split the
340+
// chunk into rows that already landed (by id) and rows still to create.
341+
const splitByExisting = async (chunk: Array<Record<string, any>>) => {
342+
const existingByIdx = new Map<number, Record<string, any>>();
343+
const toCreate: Array<Record<string, any>> = [];
344+
const found = await recheckByIds(chunk);
345+
chunk.forEach((row, i) => {
346+
const hit = row.id != null ? found.get(String(row.id)) : undefined;
347+
if (hit) existingByIdx.set(i, hit); else toCreate.push(row);
348+
});
349+
return { existingByIdx, toCreate };
350+
};
308351
const writeResults: BulkWriteRowResult[] = await bulkWrite(
309352
batch.map(b => b.data),
310353
{
@@ -313,17 +356,57 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
313356
// the issue's suggested 100-500 rows/batch must not translate into
314357
// one oversized multi-row INSERT statement.
315358
batchSize: Math.min(progressEvery, MAX_CREATE_BATCH_SIZE),
359+
// Partial-success path (framework#3172): per-row verdicts from one
360+
// call; a bad row never degrades the batch.
361+
...(canPartialCreate ? {
362+
writeBatchPartial: async (chunk: Array<Record<string, any>>, { attempt }: { attempt: number }) => {
363+
let toCreate = chunk;
364+
let existingByIdx = new Map<number, Record<string, any>>();
365+
if (attempt > 1) {
366+
({ existingByIdx, toCreate } = await splitByExisting(chunk));
367+
}
368+
try {
369+
let freshOutcomes: Array<{ ok: boolean; record?: any; error?: unknown }>;
370+
if (toCreate.length === 0) {
371+
freshOutcomes = [];
372+
} else {
373+
try {
374+
freshOutcomes = (await p.insertManyData!({
375+
object: objectName, records: toCreate, context: writeCtx,
376+
...(environmentId ? { environmentId } : {}),
377+
})).outcomes;
378+
} catch (e) {
379+
// Rows written but summary recompute failed: recover the
380+
// outcome array carried on the error (framework#3147).
381+
const recovered = recoverSummaryStale(e);
382+
if (!recovered) throw e;
383+
freshOutcomes = recovered as Array<{ ok: boolean; record?: any; error?: unknown }>;
384+
}
385+
}
386+
if (!Array.isArray(freshOutcomes) || freshOutcomes.length !== toCreate.length) {
387+
throw Object.assign(
388+
new Error(`insertManyData returned ${Array.isArray(freshOutcomes) ? `${freshOutcomes.length} outcome(s)` : String(typeof freshOutcomes)} for ${toCreate.length} row(s)`),
389+
{ code: 'ERR_BULK_RESULT_MISMATCH' },
390+
);
391+
}
392+
lastBatchUncertain = false;
393+
let k = 0;
394+
return chunk.map((_row, i) => existingByIdx.has(i)
395+
? { ok: true, record: existingByIdx.get(i)! }
396+
: freshOutcomes[k++]);
397+
} catch (e) {
398+
lastBatchUncertain = isUncertainOutcome(e);
399+
throw e;
400+
}
401+
},
402+
} : {}),
316403
writeBatch: async (chunk, { attempt }) => {
317404
let toCreate = chunk;
318-
const existingByIdx = new Map<number, Record<string, any>>();
319-
if (attempt > 1 && matchFields.length > 0) {
405+
let existingByIdx = new Map<number, Record<string, any>>();
406+
if (attempt > 1) {
320407
// A prior attempt may have committed before its response was lost:
321-
// recheck each row by matchFields and only create the ones missing.
322-
toCreate = [];
323-
for (let i = 0; i < chunk.length; i++) {
324-
const hit = await existingByMatch(chunk[i]);
325-
if (hit) existingByIdx.set(i, hit); else toCreate.push(chunk[i]);
326-
}
408+
// recheck by pre-assigned id and only create the missing rows.
409+
({ existingByIdx, toCreate } = await splitByExisting(chunk));
327410
}
328411
try {
329412
let createdRecords: any[];
@@ -362,8 +445,9 @@ export function runImport(opts: RunImportOptions): Promise<ImportRunSummary> {
362445
}
363446
},
364447
writeOne: async (row, { attempt }) => {
365-
if ((attempt > 1 || lastBatchUncertain) && matchFields.length > 0) {
366-
const hit = await existingByMatch(row);
448+
if (attempt > 1 || lastBatchUncertain) {
449+
const found = await recheckByIds([row]);
450+
const hit = row.id != null ? found.get(String(row.id)) : undefined;
367451
if (hit) return hit; // already committed by a prior attempt
368452
}
369453
try {

0 commit comments

Comments
 (0)