Skip to content

Commit 45c5df6

Browse files
committed
fix(objectql,seed,rest): retry roll-up recompute and surface failures (#3147)
recomputeSummaries wrapped each parent's aggregate+update in a try/catch that only warned — a transient turso blip on the parent summary write left the value silently stale, and a child batch insert still reported full success. Engine: retry each parent recompute with transient backoff (withTransientRetry, injectable via engine.summaryRetryOptions); collect per-parent failures so one bad parent doesn't abort the rest. When any remain after retries, insert/update/delete throw SummaryRecomputeError (code ERR_SUMMARY_RECOMPUTE) AFTER the realtime publish — the records ARE written, so the error carries them on `written`. Callers (identified by `code`, no objectql import — that would cycle): seed and import recover ERR_SUMMARY_RECOMPUTE to success using `written` rather than re-writing (which would duplicate), logging a warning; import marks the affected rows `SUMMARY_RECOMPUTE_FAILED` while keeping them created/updated. A direct API caller simply sees the thrown error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APnSDJneEDRh3Z51KGCLga
1 parent 4434f30 commit 45c5df6

8 files changed

Lines changed: 387 additions & 40 deletions

File tree

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,40 @@ describe('seed batched path — idempotent retry after commit-then-lost-response
179179
expect(result.summary.totalErrored).toBe(0);
180180
});
181181
});
182+
183+
describe('seed batched path — summary recompute failure is a warning, not an error (framework#3147)', () => {
184+
it('records the rows as inserted (not errored) and does not re-insert on ERR_SUMMARY_RECOMPUTE', async () => {
185+
const { engine, store } = createFaithfulEngine();
186+
const metadata = createMetadata();
187+
188+
// The array insert writes the rows, then reports a post-write summary
189+
// recompute failure — the records ARE written (carried on `written`).
190+
const realInsert = (engine.insert as any).getMockImplementation();
191+
let arrayCalls = 0;
192+
(engine.insert as any).mockImplementation(async (obj: string, data: any, opts: any) => {
193+
if (obj === 'my_app_widget' && Array.isArray(data)) {
194+
arrayCalls++;
195+
const written = await realInsert(obj, data, opts);
196+
throw Object.assign(new Error('summary recompute failed'), {
197+
code: 'ERR_SUMMARY_RECOMPUTE', written, failures: [{ parentObject: 'x' }],
198+
});
199+
}
200+
return realInsert(obj, data, opts);
201+
});
202+
203+
const result = await new SeedLoaderService(engine, metadata, createLogger()).load({
204+
seeds: [{
205+
object: 'my_app_widget',
206+
externalId: 'sku',
207+
mode: 'insert',
208+
env: ['prod', 'dev', 'test'],
209+
records: [{ name: 'A', sku: 'W-A' }, { name: 'B', sku: 'W-B' }],
210+
}] as any,
211+
config: CONFIG,
212+
});
213+
214+
expect(arrayCalls).toBe(1); // recovered, NOT re-inserted
215+
expect(store.my_app_widget).toHaveLength(2); // no duplicates
216+
expect(result.summary.totalErrored).toBe(0); // a stale summary is not a write error
217+
});
218+
});

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

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,8 @@ export class SeedLoaderService implements ISeedLoaderService {
305305
const freshlyInserted = toInsert.length === 0
306306
? []
307307
: toInsert.length === 1
308-
? [await this.engine.insert(objectName, toInsert[0], opts)]
309-
: await this.engine.insert(objectName, toInsert, opts);
308+
? [await this.writeRecoveringSummary(() => this.engine.insert(objectName, toInsert[0], opts))]
309+
: await this.writeRecoveringSummary(() => this.engine.insert(objectName, toInsert, opts));
310310
lastBatchUncertain = false;
311311
return assembleInOrder(rows, existing, freshlyInserted as any[]);
312312
} catch (e) {
@@ -323,7 +323,7 @@ export class SeedLoaderService implements ISeedLoaderService {
323323
if (hit) return hit; // already committed by a prior attempt
324324
}
325325
}
326-
return this.engine.insert(objectName, row, opts);
326+
return this.writeRecoveringSummary(() => this.engine.insert(objectName, row, opts));
327327
},
328328
},
329329
);
@@ -596,7 +596,7 @@ export class SeedLoaderService implements ISeedLoaderService {
596596
insertedRecords.get(objectName)!.set(externalIdValue, decision.id);
597597
}
598598
try {
599-
await withTransientRetry(() => this.engine.update(objectName, { ...record, id: decision.id }, opts));
599+
await this.writeRecoveringSummary(() => withTransientRetry(() => this.engine.update(objectName, { ...record, id: decision.id }, opts)));
600600
updated++;
601601
} catch (err: any) {
602602
errored++;
@@ -800,6 +800,29 @@ export class SeedLoaderService implements ISeedLoaderService {
800800
*/
801801
private static readonly SEED_OPTIONS = { context: { isSystem: true, skipTriggers: true } } as const;
802802

803+
/**
804+
* Run an engine write; if it fails ONLY because a post-write roll-up summary
805+
* recompute exhausted its retries (framework#3147, `code`
806+
* 'ERR_SUMMARY_RECOMPUTE'), the record WAS written — treat it as a warning
807+
* and return the written value rather than re-writing (which would
808+
* duplicate). Matched by `code` so we needn't import objectql (which depends
809+
* on this package — importing back would cycle). Any other error propagates.
810+
*/
811+
private async writeRecoveringSummary<T>(fn: () => Promise<T>): Promise<T> {
812+
try {
813+
return await fn();
814+
} catch (e: any) {
815+
if (e?.code === 'ERR_SUMMARY_RECOMPUTE') {
816+
this.logger.warn(
817+
'[SeedLoader] roll-up summary recompute failed after retries; records were written (summary values may be stale)',
818+
{ failures: Array.isArray(e.failures) ? e.failures.length : undefined },
819+
);
820+
return e.written as T;
821+
}
822+
throw e;
823+
}
824+
}
825+
803826
private async writeRecord(
804827
objectName: string,
805828
record: Record<string, unknown>,
@@ -813,7 +836,7 @@ export class SeedLoaderService implements ISeedLoaderService {
813836

814837
switch (mode) {
815838
case 'insert': {
816-
const result = await withTransientRetry(() => this.engine.insert(objectName, record, opts));
839+
const result = await this.writeRecoveringSummary(() => withTransientRetry(() => this.engine.insert(objectName, record, opts)));
817840
return { action: 'inserted', id: this.extractId(result) };
818841
}
819842

@@ -825,7 +848,7 @@ export class SeedLoaderService implements ISeedLoaderService {
825848
if (this.isNoOpReplay(record, existing)) {
826849
return { action: 'skipped', id };
827850
}
828-
await withTransientRetry(() => this.engine.update(objectName, { ...record, id }, opts));
851+
await this.writeRecoveringSummary(() => withTransientRetry(() => this.engine.update(objectName, { ...record, id }, opts)));
829852
return { action: 'updated', id };
830853
}
831854

@@ -835,10 +858,10 @@ export class SeedLoaderService implements ISeedLoaderService {
835858
if (this.isNoOpReplay(record, existing)) {
836859
return { action: 'skipped', id };
837860
}
838-
await withTransientRetry(() => this.engine.update(objectName, { ...record, id }, opts));
861+
await this.writeRecoveringSummary(() => withTransientRetry(() => this.engine.update(objectName, { ...record, id }, opts)));
839862
return { action: 'updated', id };
840863
} else {
841-
const result = await withTransientRetry(() => this.engine.insert(objectName, record, opts));
864+
const result = await this.writeRecoveringSummary(() => withTransientRetry(() => this.engine.insert(objectName, record, opts)));
842865
return { action: 'inserted', id: this.extractId(result) };
843866
}
844867
}
@@ -847,18 +870,18 @@ export class SeedLoaderService implements ISeedLoaderService {
847870
if (existing) {
848871
return { action: 'skipped', id: this.extractId(existing) };
849872
}
850-
const result = await withTransientRetry(() => this.engine.insert(objectName, record, opts));
873+
const result = await this.writeRecoveringSummary(() => withTransientRetry(() => this.engine.insert(objectName, record, opts)));
851874
return { action: 'inserted', id: this.extractId(result) };
852875
}
853876

854877
case 'replace': {
855878
// Replace mode: just insert (caller should have cleared the table)
856-
const result = await withTransientRetry(() => this.engine.insert(objectName, record, opts));
879+
const result = await this.writeRecoveringSummary(() => withTransientRetry(() => this.engine.insert(objectName, record, opts)));
857880
return { action: 'inserted', id: this.extractId(result) };
858881
}
859882

860883
default: {
861-
const result = await withTransientRetry(() => this.engine.insert(objectName, record, opts));
884+
const result = await this.writeRecoveringSummary(() => withTransientRetry(() => this.engine.insert(objectName, record, opts)));
862885
return { action: 'inserted', id: this.extractId(result) };
863886
}
864887
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// framework#3147: a roll-up summary recompute that fails on the parent
4+
// aggregate/update must be retried (transient), and — if it still fails — must
5+
// be surfaced to the caller (SummaryRecomputeError / ERR_SUMMARY_RECOMPUTE)
6+
// rather than swallowed with a warn. The triggering child records ARE written.
7+
8+
import { describe, it, expect } from 'vitest';
9+
import { ObjectQL } from './engine.js';
10+
11+
function makeDriver(opts: { onParentUpdate?: (parentId: string, callN: number) => void } = {}) {
12+
const stores = new Map<string, Map<string, any>>();
13+
const storeFor = (o: string) => {
14+
let s = stores.get(o);
15+
if (!s) { s = new Map(); stores.set(o, s); }
16+
return s;
17+
};
18+
const matches = (row: any, where: any): boolean => {
19+
if (!where || typeof where !== 'object') return true;
20+
return Object.entries(where).every(([k, v]) => row?.[k] === v);
21+
};
22+
const parentCalls = new Map<string, number>();
23+
let n = 0;
24+
const driver: any = {
25+
name: 'memory', version: '0.0.0', supports: {},
26+
async connect() {}, async disconnect() {}, async checkHealth() { return true; }, async execute() { return null; },
27+
async find(object: string, ast: any) {
28+
return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
29+
},
30+
findStream() { throw new Error('ni'); },
31+
async findOne(object: string, ast: any) {
32+
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
33+
return null;
34+
},
35+
async create(object: string, data: Record<string, unknown>) {
36+
n += 1;
37+
const id = (data.id as string) ?? `r_${n}`;
38+
const row = { ...data, id };
39+
storeFor(object).set(id, row);
40+
return row;
41+
},
42+
async update(object: string, id: string, data: Record<string, unknown>) {
43+
if (object === 'inv' && opts.onParentUpdate) {
44+
const callN = (parentCalls.get(id) ?? 0) + 1;
45+
parentCalls.set(id, callN);
46+
opts.onParentUpdate(id, callN); // may throw
47+
}
48+
const s = storeFor(object);
49+
const row = { ...s.get(id), ...data, id };
50+
s.set(id, row);
51+
return row;
52+
},
53+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
54+
async count() { return 0; },
55+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
56+
return Promise.all(rows.map((r) => this.create(object, r)));
57+
},
58+
async bulkUpdate() { return []; }, async bulkDelete() {},
59+
async beginTransaction() { return { __trx: true, commit: async () => {}, rollback: async () => {} }; },
60+
async commit() {}, async rollback() {},
61+
};
62+
return { driver, storeFor, parentCalls };
63+
}
64+
65+
async function makeEngine(driverOpts?: { onParentUpdate?: (parentId: string, callN: number) => void }) {
66+
const engine = new ObjectQL();
67+
const d = makeDriver(driverOpts);
68+
engine.registerDriver(d.driver, true);
69+
await engine.init();
70+
// Deterministic, fast backoff for the retry tests.
71+
engine.summaryRetryOptions = { sleep: async () => {}, backoffBaseMs: 0 };
72+
engine.registry.registerObject({
73+
name: 'inv',
74+
fields: {
75+
name: { type: 'text' },
76+
line_total: { type: 'summary', summaryOperations: { object: 'inv_line', field: 'amount', function: 'sum' } },
77+
},
78+
} as any);
79+
engine.registry.registerObject({
80+
name: 'inv_line',
81+
fields: { amount: { type: 'number' }, inv: { type: 'master_detail', reference: 'inv' } },
82+
} as any);
83+
return { engine, storeFor: d.storeFor, parentCalls: d.parentCalls };
84+
}
85+
86+
describe('roll-up summary recompute — retry + surface failures (framework#3147)', () => {
87+
it('retries a transient parent-update failure and lands the correct summary', async () => {
88+
const { engine, storeFor } = await makeEngine({
89+
onParentUpdate: (_id, callN) => { if (callN === 1) throw new Error('fetch failed'); },
90+
});
91+
const p = await engine.insert('inv', { name: 'INV-1' });
92+
// Child insert succeeds; the parent summary update throws once then retries.
93+
await engine.insert('inv_line', { inv: p.id, amount: 42 });
94+
95+
expect(storeFor('inv').get(p.id).line_total).toBe(42);
96+
});
97+
98+
it('surfaces ERR_SUMMARY_RECOMPUTE when retries are exhausted, and the child is still written', async () => {
99+
const { engine, storeFor } = await makeEngine({
100+
onParentUpdate: () => { throw new Error('ETIMEDOUT'); }, // persistent transient
101+
});
102+
const p = await engine.insert('inv', { name: 'INV-2' });
103+
104+
let caught: any;
105+
await engine.insert('inv_line', { inv: p.id, amount: 10 }).catch((e) => { caught = e; });
106+
107+
expect(caught?.code).toBe('ERR_SUMMARY_RECOMPUTE');
108+
expect(Array.isArray(caught?.failures)).toBe(true);
109+
expect(caught.failures[0]).toMatchObject({ parentObject: 'inv', field: 'line_total' });
110+
// The child record WAS written (only the summary recompute failed).
111+
expect(caught?.written).toBeTruthy();
112+
expect(Array.from(storeFor('inv_line').values())).toHaveLength(1);
113+
});
114+
115+
it('does not retry a non-transient parent-update failure (single attempt), still surfaces it', async () => {
116+
const { engine, parentCalls } = await makeEngine({
117+
onParentUpdate: () => { throw new Error('validation failed: bad summary'); },
118+
});
119+
const p = await engine.insert('inv', { name: 'INV-3' });
120+
121+
let caught: any;
122+
await engine.insert('inv_line', { inv: p.id, amount: 5 }).catch((e) => { caught = e; });
123+
124+
expect(caught?.code).toBe('ERR_SUMMARY_RECOMPUTE');
125+
expect(parentCalls.get(p.id)).toBe(1); // no retry on a logical error
126+
});
127+
128+
it('one failing parent does not block another parent recompute; failures list only the failed one', async () => {
129+
let failId: string | null = null;
130+
const { engine, storeFor } = await makeEngine({
131+
onParentUpdate: (id) => { if (id === failId) throw new Error('ETIMEDOUT'); },
132+
});
133+
const a = await engine.insert('inv', { name: 'A' });
134+
const b = await engine.insert('inv', { name: 'B' });
135+
failId = a.id; // only A's summary update fails
136+
137+
// Insert one child per parent as a single batch so both recompute in one call.
138+
let caught: any;
139+
await engine.insert('inv_line', [
140+
{ inv: a.id, amount: 7 },
141+
{ inv: b.id, amount: 3 },
142+
]).catch((e) => { caught = e; });
143+
144+
expect(caught?.code).toBe('ERR_SUMMARY_RECOMPUTE');
145+
expect(caught.failures).toHaveLength(1);
146+
expect(caught.failures[0].parentId).toBe(a.id);
147+
// B's summary recomputed correctly despite A failing.
148+
expect(storeFor('inv').get(b.id).line_total).toBe(3);
149+
});
150+
});

0 commit comments

Comments
 (0)