Skip to content

Commit e057f42

Browse files
authored
fix: harden the bulk-write path — retries, idempotency, contracts, summary visibility (#3161)
Fixes #3147, #3148, #3149, #3150, #3151, #3152. - #3151 bulkWrite + engine.insert(array) validate write-result contracts - #3150 wrap remaining bare writes in withTransientRetry; short-circuit logical errors - #3148 import resolveRef flushes pending creates; no negative caching - #3149 attempt-aware natural-key recheck makes batch retry idempotent - #3147 recomputeSummaries retries and surfaces SummaryRecomputeError - #3152 assign autonumbers after validation; document at-least-once hooks
1 parent a8aa34c commit e057f42

15 files changed

Lines changed: 1372 additions & 76 deletions

.changeset/bulk-write-hardening.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
"@objectstack/core": patch
3+
"@objectstack/objectql": patch
4+
"@objectstack/rest": patch
5+
"@objectstack/metadata-protocol": patch
6+
---
7+
8+
fix: harden the bulk-write path — retries, idempotency, contracts, and summary visibility (#3147#3152)
9+
10+
Six reliability fixes to the batched seed/import + `engine.insert(array)` path
11+
introduced by the #2678 bulk-write rework:
12+
13+
- **#3151** `bulkWrite` validates that `writeBatch` returns one record per input
14+
row (a short/long/non-array return is degraded per-row, not backfilled as
15+
phantom success); `engine.insert(array)` likewise rejects a short driver
16+
`bulkCreate` return instead of padding afterInsert with `undefined`.
17+
- **#3150** wraps the two remaining un-retried write points (seed
18+
`writeRecord`/`resolveDeferredUpdates`, import's no-`createManyData`
19+
fallback) in `withTransientRetry`; `defaultIsTransientError` short-circuits
20+
definitive logical errors to non-transient.
21+
- **#3148** import `resolveRef` flushes pending creates on a same-object miss so
22+
a later row can reference an earlier same-file CREATE, and no longer
23+
negatively caches a miss.
24+
- **#3149** threads an `attempt` counter through `bulkWrite`; seed rechecks by
25+
`externalId` and import by `matchFields` before re-writing, so a
26+
commit-then-lost-response retry cannot duplicate a batch.
27+
- **#3147** `recomputeSummaries` retries transient failures and, on exhaustion,
28+
surfaces `SummaryRecomputeError` (`ERR_SUMMARY_RECOMPUTE`) instead of a
29+
silent warn; seed/import recover it to a warning without re-writing.
30+
- **#3152** autonumbers are assigned after validation, so a batch that dies in
31+
validation consumes no sequence value (no number-range gaps).

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

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,81 @@ describe('bulkWrite', () => {
112112
expect(writeOne).not.toHaveBeenCalled();
113113
expect(results[0]).toMatchObject({ index: 0, ok: false });
114114
});
115+
116+
it('rejects a short writeBatch return as a failed batch and degrades to per-row (#3151)', async () => {
117+
// Driver dropped a row from its RETURNING set: 2-row batch, 1 record back.
118+
const writeBatch = vi.fn(async (batch: { n: number }[]) => batch.slice(1).map((r) => ({ id: `r${r.n}` })));
119+
const writeOne = vi.fn(async (row: { n: number }) => ({ id: `r${row.n}` }));
120+
121+
const results = await bulkWrite([{ n: 1 }, { n: 2 }], { batchSize: 10, writeBatch, writeOne, sleep: noopSleep });
122+
123+
expect(writeBatch).toHaveBeenCalledTimes(1); // mismatch is NOT transient — no batch retry
124+
expect(writeOne).toHaveBeenCalledTimes(2); // degraded to per-row instead of phantom success
125+
expect(results.every((r) => r.ok)).toBe(true);
126+
expect(results.map((r) => r.record)).toEqual([{ id: 'r1' }, { id: 'r2' }]);
127+
});
128+
129+
it('rejects a non-array writeBatch return and degrades to per-row (#3151)', async () => {
130+
const writeBatch = vi.fn(async () => undefined as unknown as { id: string }[]);
131+
const writeOne = vi.fn(async (row: { n: number }) => ({ id: `r${row.n}` }));
132+
133+
const results = await bulkWrite([{ n: 1 }, { n: 2 }], { batchSize: 10, writeBatch, writeOne, sleep: noopSleep });
134+
135+
expect(writeOne).toHaveBeenCalledTimes(2);
136+
expect(results.every((r) => r.ok)).toBe(true);
137+
});
138+
139+
it('rejects an over-long writeBatch return and degrades to per-row (#3151)', async () => {
140+
const writeBatch = vi.fn(async (batch: { n: number }[]) => [...batch, { n: 999 }].map((r) => ({ id: `r${r.n}` })));
141+
const writeOne = vi.fn(async (row: { n: number }) => ({ id: `r${row.n}` }));
142+
143+
const results = await bulkWrite([{ n: 1 }, { n: 2 }], { batchSize: 10, writeBatch, writeOne, sleep: noopSleep });
144+
145+
expect(writeOne).toHaveBeenCalledTimes(2);
146+
expect(results.every((r) => r.ok)).toBe(true);
147+
});
148+
149+
it('surfaces ERR_BULK_RESULT_MISMATCH on a single-row batch with the wrong return count (#3151)', async () => {
150+
const writeBatch = vi.fn(async () => [] as { id: string }[]); // empty for a 1-row batch
151+
const writeOne = vi.fn(async () => ({ id: 'unused' }));
152+
153+
const results = await bulkWrite([{ n: 1 }], { batchSize: 10, writeBatch, writeOne, sleep: noopSleep });
154+
155+
expect(writeOne).not.toHaveBeenCalled(); // single-row batch failure IS the row's final result
156+
expect(results[0].ok).toBe(false);
157+
expect((results[0].error as { code?: string })?.code).toBe('ERR_BULK_RESULT_MISMATCH');
158+
});
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+
});
115190
});
116191

117192
describe('withTransientRetry', () => {
@@ -151,4 +226,14 @@ describe('defaultIsTransientError', () => {
151226
expect(defaultIsTransientError(new Error('Validation failed: email is required'))).toBe(false);
152227
expect(defaultIsTransientError(new Error('UNIQUE constraint failed: task.id'))).toBe(false);
153228
});
229+
230+
it('classifies a mixed constraint+network message as logical, not transient (#3150)', () => {
231+
// A logical signature must win even when a transient keyword also appears,
232+
// so we do not burn retries on a row that will fail identically each time.
233+
expect(defaultIsTransientError(new Error('CHECK constraint failed: network_zone'))).toBe(false);
234+
expect(defaultIsTransientError(new Error('column network_id is not allowed'))).toBe(false);
235+
expect(defaultIsTransientError(new Error('value out of range at row 503'))).toBe(false);
236+
// Pure transient signatures are unaffected.
237+
expect(defaultIsTransientError(new Error('fetch failed'))).toBe(true);
238+
});
154239
});

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

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,17 @@
2626
* can reassemble output in input order even though rows are processed in
2727
* batches (and a batch's flush may be interleaved with other, immediate,
2828
* per-row work such as updates).
29+
*
30+
* Delivery semantics: **at-least-once**. Transient retry and per-row
31+
* degradation both RE-RUN a write whose outcome was unknown — e.g. a turso
32+
* `fetch failed` that arrived *after* the row was already committed
33+
* (framework#3149), or a result-count mismatch that voids the batch
34+
* (framework#3151). A caller that needs exactly-once must make its
35+
* `writeBatch`/`writeOne` idempotent; both receive an `attempt` counter for
36+
* exactly this — see the natural-key recheck the seed loader and import
37+
* runner perform on `attempt > 1`. `writeBatch` MUST also resolve exactly one
38+
* record per input row, in input order: a short / long / non-array return is
39+
* rejected as a failed batch (framework#3151), never silently backfilled.
2940
*/
3041

3142
export interface BulkWriteRowResult<TRecord = any> {
@@ -56,10 +67,18 @@ export interface BulkWriteOptions<TRow, TRecord = any> extends RetryOptions {
5667
* `batch[i]` positionally (this is how every `bulkCreate` implementation in
5768
* this repo already behaves: sql's single `INSERT ... VALUES (...), (...)
5869
* 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).
75+
*/
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}.
5980
*/
60-
writeBatch: (batch: TRow[]) => Promise<TRecord[]>;
61-
/** Write a single row — used only to degrade a failed batch. */
62-
writeOne: (row: TRow) => Promise<TRecord>;
81+
writeOne: (row: TRow, ctx: { attempt: number }) => Promise<TRecord>;
6382
}
6483

6584
const DEFAULT_BATCH_SIZE = 200;
@@ -86,11 +105,33 @@ const TRANSIENT_PATTERNS: RegExp[] = [
86105

87106
const TRANSIENT_CODES = /^(ECONNRESET|ECONNREFUSED|ECONNABORTED|EPIPE|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENOTFOUND)$/i;
88107

108+
/**
109+
* Validation / constraint / schema signatures that are DEFINITIVELY logical,
110+
* never worth retrying. Checked before {@link TRANSIENT_PATTERNS} so a message
111+
* that happens to mention both (e.g. `CHECK constraint failed: network_zone`,
112+
* `column network_id is not allowed`) is classified as logical rather than
113+
* burning retries on a row that will fail identically every time (framework
114+
* #3150).
115+
*/
116+
const NON_TRANSIENT_PATTERNS: RegExp[] = [
117+
/validation/i,
118+
/constraint/i,
119+
/\brequired\b/i,
120+
/\bunique\b/i,
121+
/duplicate/i,
122+
/not[\s_-]*null/i,
123+
/invalid/i,
124+
/not allowed/i,
125+
/out of range/i,
126+
];
127+
89128
export function defaultIsTransientError(err: unknown): boolean {
90-
const code = (err as { code?: unknown } | null)?.code;
91-
if (typeof code === 'string' && TRANSIENT_CODES.test(code)) return true;
92129
const message = (err as { message?: unknown } | null)?.message;
93130
const text = typeof message === 'string' ? message : String(err ?? '');
131+
// A definitive logical signature wins even if a transient word also appears.
132+
if (NON_TRANSIENT_PATTERNS.some((re) => re.test(text))) return false;
133+
const code = (err as { code?: unknown } | null)?.code;
134+
if (typeof code === 'string' && TRANSIENT_CODES.test(code)) return true;
94135
return TRANSIENT_PATTERNS.some((re) => re.test(text));
95136
}
96137

@@ -103,11 +144,11 @@ interface ResolvedRetryOptions {
103144
sleep: (ms: number) => Promise<void>;
104145
}
105146

106-
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> {
107148
let lastError: unknown;
108149
for (let attempt = 1; attempt <= opts.maxRetries; attempt++) {
109150
try {
110-
return await fn();
151+
return await fn(attempt);
111152
} catch (err) {
112153
lastError = err;
113154
if (attempt >= opts.maxRetries || !opts.isTransientError(err)) throw err;
@@ -126,7 +167,7 @@ async function withRetry<T>(fn: () => Promise<T>, opts: ResolvedRetryOptions): P
126167
* transient-error backoff {@link bulkWrite} applies to batches — so a
127168
* network blip doesn't drop an update the way it used to drop an insert.
128169
*/
129-
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> {
130171
return withRetry(fn, {
131172
maxRetries: Math.max(1, opts.maxRetries ?? DEFAULT_MAX_RETRIES),
132173
backoffBaseMs: opts.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS,
@@ -161,7 +202,26 @@ export async function bulkWrite<TRow, TRecord = any>(
161202
for (let start = 0; start < rows.length; start += batchSize) {
162203
const batch = rows.slice(start, start + batchSize);
163204
try {
164-
const records = await withRetry(() => opts.writeBatch(batch), retryOpts);
205+
const records = await withRetry((attempt) => opts.writeBatch(batch, { attempt }), retryOpts);
206+
// Contract guard (framework#3151): `writeBatch` must resolve one record
207+
// per input row. A short / long / non-array return breaks the positional
208+
// correlation below, so backfilling it would report phantom successes
209+
// (`record: undefined`) or drop records. Treat the whole batch as failed
210+
// and fall through to per-row degradation (each row re-attempted via
211+
// `writeOne`, which under an idempotent caller rechecks before writing).
212+
// The message deliberately avoids any transient signature so this never
213+
// reads as a retryable blip — and it is thrown *outside* `withRetry`, so
214+
// the batch is not retried on it.
215+
if (!Array.isArray(records) || records.length !== batch.length) {
216+
throw Object.assign(
217+
new Error(
218+
`bulkWrite: writeBatch returned ${
219+
Array.isArray(records) ? `${records.length} record(s)` : String(typeof records)
220+
} for a ${batch.length}-row batch — treating batch as failed`,
221+
),
222+
{ code: 'ERR_BULK_RESULT_MISMATCH' },
223+
);
224+
}
165225
for (let i = 0; i < batch.length; i++) {
166226
results[start + i] = { index: start + i, ok: true, record: records[i] };
167227
}
@@ -181,7 +241,7 @@ export async function bulkWrite<TRow, TRecord = any>(
181241
for (let i = 0; i < batch.length; i++) {
182242
const idx = start + i;
183243
try {
184-
const record = await withRetry(() => opts.writeOne(batch[i]), retryOpts);
244+
const record = await withRetry((attempt) => opts.writeOne(batch[i], { attempt }), retryOpts);
185245
results[idx] = { index: idx, ok: true, record };
186246
} catch (err) {
187247
results[idx] = { index: idx, ok: false, error: err };

0 commit comments

Comments
 (0)