Skip to content

Commit 4434f30

Browse files
committed
fix(core,seed,rest): idempotent batch retry via attempt-aware natural-key recheck (#3149)
bulkWrite's transient retry is at-least-once: when a driver commits a batch but its response is lost (turso's fetch-based transport fails after commit), the retry re-inserted the whole batch — 2 input rows became 4 stored rows, all reported success, each firing record-change and summary recompute twice. Rather than a generic dedup inside bulkWrite, thread a 1-based `attempt` counter into writeBatch/writeOne and let each caller enforce batch-level idempotency with its own natural key: - core: withRetry passes the attempt number; writeBatch/writeOne gain a `{ attempt }` context (backward compatible — callers may ignore it). - seed: on attempt > 1, recheck existing rows by externalId (reusing loadExistingRecords) and insert only the missing ones; reassemble the per-row result in order. - import: on attempt > 1 with matchFields, recheck by matchFields and create only the missing rows. A short createManyData return is now surfaced as a failed batch (so degradation, which also rechecks, runs) instead of being padded. A pure-insert import (no matchFields) has no natural key and stays at-least-once by documented contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APnSDJneEDRh3Z51KGCLga
1 parent d9e0f51 commit 4434f30

6 files changed

Lines changed: 322 additions & 25 deletions

File tree

packages/core/src/utils/bulk-write.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,37 @@ describe('bulkWrite', () => {
156156
expect(results[0].ok).toBe(false);
157157
expect((results[0].error as { code?: string })?.code).toBe('ERR_BULK_RESULT_MISMATCH');
158158
});
159+
160+
it('passes the 1-based attempt number to writeBatch across a transient retry (#3149)', async () => {
161+
const seen: number[] = [];
162+
let attempts = 0;
163+
const writeBatch = vi.fn(async (batch: { n: number }[], ctx: { attempt: number }) => {
164+
seen.push(ctx.attempt);
165+
attempts++;
166+
if (attempts === 1) throw new Error('fetch failed');
167+
return batch.map((r) => ({ id: `r${r.n}` }));
168+
});
169+
const writeOne = vi.fn(async () => ({ id: 'x' }));
170+
171+
await bulkWrite([{ n: 1 }, { n: 2 }], { batchSize: 10, writeBatch, writeOne, sleep: noopSleep });
172+
173+
expect(seen).toEqual([1, 2]); // attempt 1 threw, attempt 2 succeeded
174+
});
175+
176+
it('passes an attempt counter to writeOne during degradation (#3149)', async () => {
177+
const seen: number[] = [];
178+
const writeBatch = vi.fn(async () => { throw new Error('logical'); }); // force degradation
179+
const writeOne = vi.fn(async (row: { n: number }, ctx: { attempt: number }) => {
180+
seen.push(ctx.attempt);
181+
if (ctx.attempt < 2 && row.n === 1) throw new Error('fetch failed'); // retry row 1 once
182+
return { id: `r${row.n}` };
183+
});
184+
185+
await bulkWrite([{ n: 1 }, { n: 2 }], { batchSize: 10, writeBatch, writeOne, sleep: noopSleep });
186+
187+
// row 1: attempt 1 (transient throw) → attempt 2 (ok); row 2: attempt 1 (ok)
188+
expect(seen).toEqual([1, 2, 1]);
189+
});
159190
});
160191

161192
describe('withTransientRetry', () => {

packages/core/src/utils/bulk-write.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,18 @@ export interface BulkWriteOptions<TRow, TRecord = any> extends RetryOptions {
6767
* `batch[i]` positionally (this is how every `bulkCreate` implementation in
6868
* this repo already behaves: sql's single `INSERT ... VALUES (...), (...)
6969
* RETURNING *`, memory's `Promise.all`, mongodb's ordered `insertMany`).
70+
*
71+
* `ctx.attempt` is the 1-based attempt number. `attempt > 1` means a prior
72+
* attempt's outcome is UNKNOWN (a transient blip that may have landed after
73+
* commit) — an exactly-once caller should recheck by natural key and skip
74+
* rows already present before re-writing (framework#3149).
7075
*/
71-
writeBatch: (batch: TRow[]) => Promise<TRecord[]>;
72-
/** Write a single row — used only to degrade a failed batch. */
73-
writeOne: (row: TRow) => Promise<TRecord>;
76+
writeBatch: (batch: TRow[], ctx: { attempt: number }) => Promise<TRecord[]>;
77+
/**
78+
* Write a single row — used only to degrade a failed batch. `ctx.attempt`
79+
* carries the same recheck signal as {@link writeBatch}.
80+
*/
81+
writeOne: (row: TRow, ctx: { attempt: number }) => Promise<TRecord>;
7482
}
7583

7684
const DEFAULT_BATCH_SIZE = 200;
@@ -136,11 +144,11 @@ interface ResolvedRetryOptions {
136144
sleep: (ms: number) => Promise<void>;
137145
}
138146

139-
async function withRetry<T>(fn: () => Promise<T>, opts: ResolvedRetryOptions): Promise<T> {
147+
async function withRetry<T>(fn: (attempt: number) => Promise<T>, opts: ResolvedRetryOptions): Promise<T> {
140148
let lastError: unknown;
141149
for (let attempt = 1; attempt <= opts.maxRetries; attempt++) {
142150
try {
143-
return await fn();
151+
return await fn(attempt);
144152
} catch (err) {
145153
lastError = err;
146154
if (attempt >= opts.maxRetries || !opts.isTransientError(err)) throw err;
@@ -159,7 +167,7 @@ async function withRetry<T>(fn: () => Promise<T>, opts: ResolvedRetryOptions): P
159167
* transient-error backoff {@link bulkWrite} applies to batches — so a
160168
* network blip doesn't drop an update the way it used to drop an insert.
161169
*/
162-
export async function withTransientRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
170+
export async function withTransientRetry<T>(fn: (attempt: number) => Promise<T>, opts: RetryOptions = {}): Promise<T> {
163171
return withRetry(fn, {
164172
maxRetries: Math.max(1, opts.maxRetries ?? DEFAULT_MAX_RETRIES),
165173
backoffBaseMs: opts.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS,
@@ -194,7 +202,7 @@ export async function bulkWrite<TRow, TRecord = any>(
194202
for (let start = 0; start < rows.length; start += batchSize) {
195203
const batch = rows.slice(start, start + batchSize);
196204
try {
197-
const records = await withRetry(() => opts.writeBatch(batch), retryOpts);
205+
const records = await withRetry((attempt) => opts.writeBatch(batch, { attempt }), retryOpts);
198206
// Contract guard (framework#3151): `writeBatch` must resolve one record
199207
// per input row. A short / long / non-array return breaks the positional
200208
// correlation below, so backfilling it would report phantom successes
@@ -233,7 +241,7 @@ export async function bulkWrite<TRow, TRecord = any>(
233241
for (let i = 0; i < batch.length; i++) {
234242
const idx = start + i;
235243
try {
236-
const record = await withRetry(() => opts.writeOne(batch[i]), retryOpts);
244+
const record = await withRetry((attempt) => opts.writeOne(batch[i], { attempt }), retryOpts);
237245
results[idx] = { index: idx, ok: true, record };
238246
} catch (err) {
239247
results[idx] = { index: idx, ok: false, error: err };

packages/metadata-protocol/src/seed-loader-retry.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ function createMetadata(): IMetadataService {
7171
manager: { type: 'lookup', reference: 'my_app_employee' },
7272
},
7373
},
74+
// A plain object (no self-ref) → the batched flushPendingInserts path.
75+
my_app_widget: {
76+
name: 'my_app_widget',
77+
fields: {
78+
name: { type: 'text' },
79+
sku: { type: 'text' },
80+
},
81+
},
7482
};
7583
return {
7684
getObject: vi.fn(async (name: string) => objects[name]),
@@ -129,3 +137,45 @@ describe('seed self-ref sequential path — transient retry (framework#3150)', (
129137
expect((store.my_app_employee ?? []).map((r) => r.name).sort()).toEqual(['Alice', 'Bob']);
130138
});
131139
});
140+
141+
describe('seed batched path — idempotent retry after commit-then-lost-response (framework#3149)', () => {
142+
it('does not duplicate rows when the batch insert commits but its response is lost', async () => {
143+
const { engine, store } = createFaithfulEngine();
144+
const metadata = createMetadata();
145+
146+
// First array insert writes both rows to the store, then throws a transient
147+
// error (turso's commit-then-lost-response shape). The retry must recheck
148+
// by externalId and NOT insert them again.
149+
const realInsert = (engine.insert as any).getMockImplementation();
150+
let arrayInsertCalls = 0;
151+
(engine.insert as any).mockImplementation(async (obj: string, data: any, opts: any) => {
152+
if (obj === 'my_app_widget' && Array.isArray(data)) {
153+
arrayInsertCalls++;
154+
if (arrayInsertCalls === 1) {
155+
await realInsert(obj, data, opts); // commit lands
156+
throw new Error('fetch failed'); // ...but the response is lost
157+
}
158+
}
159+
return realInsert(obj, data, opts);
160+
});
161+
162+
const result = await new SeedLoaderService(engine, metadata, createLogger()).load({
163+
seeds: [{
164+
object: 'my_app_widget',
165+
externalId: 'sku',
166+
mode: 'insert',
167+
env: ['prod', 'dev', 'test'],
168+
records: [{ name: 'Widget A', sku: 'W-A' }, { name: 'Widget B', sku: 'W-B' }],
169+
}] as any,
170+
config: CONFIG,
171+
});
172+
173+
// The array insert ran once (attempt 1, which committed); the retry's
174+
// recheck found both rows already present and did NOT re-insert.
175+
expect(arrayInsertCalls).toBe(1);
176+
expect(store.my_app_widget.filter((r) => r.sku === 'W-A')).toHaveLength(1);
177+
expect(store.my_app_widget.filter((r) => r.sku === 'W-B')).toHaveLength(1);
178+
expect(store.my_app_widget).toHaveLength(2); // no duplicates
179+
expect(result.summary.totalErrored).toBe(0);
180+
});
181+
});

packages/metadata-protocol/src/seed-loader.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
} from '@objectstack/spec/data';
1616
import { SeedLoaderConfigSchema } from '@objectstack/spec/data';
1717
import { resolveSeedRecord } from '@objectstack/formula';
18-
import { bulkWrite, withTransientRetry, type BulkWriteRowResult } from '@objectstack/core';
18+
import { bulkWrite, withTransientRetry, defaultIsTransientError, type BulkWriteRowResult } from '@objectstack/core';
1919

2020
interface Logger {
2121
info(message: string, meta?: Record<string, any>): void;
@@ -262,20 +262,69 @@ export class SeedLoaderService implements ISeedLoaderService {
262262
// logical/validation failure. See framework#2678.
263263
const pendingInserts: Array<{ recordIndex: number; externalIdValue: string; record: Record<string, unknown> }> = [];
264264
const opts = SeedLoaderService.SEED_OPTIONS as any;
265+
const extIdOf = (rec: Record<string, unknown>) => String(rec[externalId] ?? '');
266+
// bulkWrite is at-least-once: a retry (or a mismatch-driven degradation)
267+
// may re-run a write whose prior attempt already committed. Guard against
268+
// duplicate seed rows by rechecking natural keys before re-inserting
269+
// (framework#3149). `lastBatchUncertain` carries the "prior batch outcome
270+
// unknown" signal into the per-row degradation writeOne calls.
271+
let lastBatchUncertain = false;
272+
const isUncertainOutcome = (e: unknown) =>
273+
defaultIsTransientError(e) || (e as { code?: unknown } | null)?.code === 'ERR_BULK_RESULT_MISMATCH';
274+
// Reassemble one record per input row in order: rows already present are
275+
// represented by the existing record; the rest are consumed in order from
276+
// the freshly-inserted list.
277+
const assembleInOrder = (rows: Record<string, unknown>[], existing: Map<string, any>, freshlyInserted: any[]): any[] => {
278+
let k = 0;
279+
return rows.map((r) => {
280+
const key = extIdOf(r);
281+
if (key && existing.has(key)) return existing.get(key);
282+
return freshlyInserted[k++];
283+
});
284+
};
265285
const flushPendingInserts = async (): Promise<void> => {
266286
if (pendingInserts.length === 0) return;
267287
const batch = pendingInserts.splice(0, pendingInserts.length);
268288
const writeResults: BulkWriteRowResult[] = await bulkWrite(
269289
batch.map(b => b.record),
270290
{
271291
batchSize: SeedLoaderService.BULK_BATCH_SIZE,
272-
// A lone row keeps the historical bare-record insert() call shape
273-
// (no array wrapping) so single-record datasets are byte-for-byte
274-
// unchanged; only a real batch (>1) uses the array/bulk form.
275-
writeBatch: (rows) => rows.length === 1
276-
? this.engine.insert(objectName, rows[0], opts).then((r: any) => [r])
277-
: this.engine.insert(objectName, rows, opts),
278-
writeOne: (row) => this.engine.insert(objectName, row, opts),
292+
writeBatch: async (rows, { attempt }) => {
293+
let toInsert = rows;
294+
let existing = new Map<string, any>();
295+
if (attempt > 1) {
296+
// Prior attempt may have committed before its response was lost:
297+
// insert only rows not already present so a retry can't duplicate.
298+
existing = await this.loadExistingRecords(objectName, externalId, config.organizationId);
299+
toInsert = rows.filter((r) => { const k = extIdOf(r); return !(k && existing.has(k)); });
300+
}
301+
try {
302+
// A lone row keeps the historical bare-record insert() call shape
303+
// (no array wrapping) so single-record datasets are byte-for-byte
304+
// unchanged; only a real batch (>1) uses the array/bulk form.
305+
const freshlyInserted = toInsert.length === 0
306+
? []
307+
: toInsert.length === 1
308+
? [await this.engine.insert(objectName, toInsert[0], opts)]
309+
: await this.engine.insert(objectName, toInsert, opts);
310+
lastBatchUncertain = false;
311+
return assembleInOrder(rows, existing, freshlyInserted as any[]);
312+
} catch (e) {
313+
lastBatchUncertain = isUncertainOutcome(e);
314+
throw e;
315+
}
316+
},
317+
writeOne: async (row, { attempt }) => {
318+
if (attempt > 1 || lastBatchUncertain) {
319+
const key = extIdOf(row);
320+
if (key) {
321+
const existing = await this.loadExistingRecords(objectName, externalId, config.organizationId);
322+
const hit = existing.get(key);
323+
if (hit) return hit; // already committed by a prior attempt
324+
}
325+
}
326+
return this.engine.insert(objectName, row, opts);
327+
},
279328
},
280329
);
281330
for (const res of writeResults) {
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* framework#3149: bulkWrite is at-least-once — a retry (or a mismatch-driven
5+
* degradation) may re-run a create whose prior attempt already committed. When
6+
* the import has natural keys (matchFields), runImport rechecks before
7+
* re-creating so a retry can't duplicate the row. A pure-insert import has no
8+
* natural key and stays at-least-once by contract.
9+
*/
10+
11+
import { describe, it, expect, vi } from 'vitest';
12+
import { runImport, type ImportProtocolLike } from './import-runner';
13+
import type { ExportFieldMeta } from './export-format.js';
14+
15+
const metaMap = new Map<string, ExportFieldMeta>([['name', { name: 'name', type: 'text' }]]);
16+
17+
const baseOpts = {
18+
objectName: 'task',
19+
metaMap,
20+
dryRun: false,
21+
runAutomations: false,
22+
trimWhitespace: true,
23+
createMissingOptions: false,
24+
skipBlankMatchKey: false,
25+
};
26+
27+
/**
28+
* Mock protocol backed by an in-memory store. `createManyData` optionally
29+
* commits-then-throws (or short-returns) on its first call to model a lost
30+
* response / mismatch; findData filters the store for the recheck.
31+
*/
32+
function makeProtocol(opts: { firstCall?: 'throw' | 'shortReturn' } = {}) {
33+
const store: Array<Record<string, any>> = [];
34+
let idc = 0;
35+
let calls = 0;
36+
const createManyData = vi.fn(async (args: { records: any[] }) => {
37+
calls++;
38+
const recs = args.records.map((r) => {
39+
const rec = { id: `id-${++idc}`, ...r };
40+
store.push(rec);
41+
return rec;
42+
});
43+
if (calls === 1 && opts.firstCall === 'throw') throw new Error('fetch failed'); // committed, response lost
44+
if (calls === 1 && opts.firstCall === 'shortReturn') return { records: [] }; // committed, bad count
45+
return { records: recs };
46+
});
47+
const createData = vi.fn(async (args: { data: { name: string } }) => {
48+
const rec = { id: `id-${++idc}`, ...args.data };
49+
store.push(rec);
50+
return rec;
51+
});
52+
const findData = vi.fn(async (args: { query?: { $filter?: Record<string, any> } }) => {
53+
const filter = args.query?.$filter ?? {};
54+
return store.filter((row) => Object.entries(filter).every(([k, v]) => row[k] === v));
55+
});
56+
const p: ImportProtocolLike = { findData, createData, updateData: vi.fn(), createManyData };
57+
return { p, store, createManyData, createData };
58+
}
59+
60+
describe('runImport — idempotent retry with natural keys (framework#3149)', () => {
61+
it('upsert+matchFields: a transient retry after commit does not duplicate rows', async () => {
62+
const { p, store, createManyData } = makeProtocol({ firstCall: 'throw' });
63+
64+
const summary = await runImport({
65+
...baseOpts, p, writeMode: 'upsert', matchFields: ['name'],
66+
rows: [{ name: 'x' }, { name: 'y' }],
67+
});
68+
69+
// createManyData ran once (attempt 1, which committed); the retry's recheck
70+
// found both rows already present and did NOT re-create them.
71+
expect(createManyData).toHaveBeenCalledTimes(1);
72+
expect(store.filter((r) => r.name === 'x')).toHaveLength(1);
73+
expect(store.filter((r) => r.name === 'y')).toHaveLength(1);
74+
expect(store).toHaveLength(2); // no duplicates
75+
expect(summary.created).toBe(2);
76+
expect(summary.errors).toBe(0);
77+
});
78+
79+
it('upsert+matchFields: a short createManyData return degrades and still does not duplicate', async () => {
80+
const { p, store } = makeProtocol({ firstCall: 'shortReturn' });
81+
82+
const summary = await runImport({
83+
...baseOpts, p, writeMode: 'upsert', matchFields: ['name'],
84+
rows: [{ name: 'x' }, { name: 'y' }],
85+
});
86+
87+
// The empty return voids the batch → per-row degradation, which rechecks
88+
// and finds both rows already committed rather than re-creating them.
89+
expect(store).toHaveLength(2);
90+
expect(summary.created).toBe(2);
91+
expect(summary.errors).toBe(0);
92+
});
93+
94+
it('pure insert (no matchFields): retry is at-least-once — duplicates are the documented contract', async () => {
95+
const { p, store } = makeProtocol({ firstCall: 'throw' });
96+
97+
await runImport({
98+
...baseOpts, p, writeMode: 'insert', matchFields: [],
99+
rows: [{ name: 'x' }, { name: 'y' }],
100+
});
101+
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);
105+
});
106+
});

0 commit comments

Comments
 (0)