@auto-engineer/pipeline 1.135.0 → 1.136.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +5 -5
- package/.turbo/turbo-type-check.log +1 -1
- package/CHANGELOG.md +27 -0
- package/dist/src/projections/item-status-projection.d.ts +2 -0
- package/dist/src/projections/item-status-projection.d.ts.map +1 -1
- package/dist/src/projections/item-status-projection.js +1 -0
- package/dist/src/projections/item-status-projection.js.map +1 -1
- package/dist/src/server/pipeline-server.d.ts +4 -0
- package/dist/src/server/pipeline-server.d.ts.map +1 -1
- package/dist/src/server/pipeline-server.js +40 -6
- package/dist/src/server/pipeline-server.js.map +1 -1
- package/dist/src/store/pipeline-read-model.d.ts.map +1 -1
- package/dist/src/store/pipeline-read-model.js +11 -1
- package/dist/src/store/pipeline-read-model.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/ketchup-plan.md +5 -0
- package/package.json +3 -3
- package/src/projections/item-status-projection.specs.ts +62 -0
- package/src/projections/item-status-projection.ts +3 -0
- package/src/server/pipeline-server.specs.ts +123 -37
- package/src/server/pipeline-server.ts +43 -4
- package/src/store/pipeline-read-model.specs.ts +82 -0
- package/src/store/pipeline-read-model.ts +10 -1
package/ketchup-plan.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Ketchup Plan: Fix Pipeline Diagram Wrong Status for Retried Commands
|
|
2
2
|
|
|
3
|
+
### Bottle: RetryStatusFix
|
|
4
|
+
|
|
3
5
|
## TODO
|
|
4
6
|
|
|
5
7
|
## DONE
|
|
@@ -8,3 +10,6 @@
|
|
|
8
10
|
- [x] Burst 2: Add `autoRegisterItemKeyExtractor` and call from `registerCommandHandlers` [depends: Burst 1] (0bd4e2a7)
|
|
9
11
|
- [x] Burst 3: Covered by Bursts 1+2 tests [depends: Burst 2]
|
|
10
12
|
- [x] Burst 4: Test manual extractor takes precedence over auto-derived [depends: Burst 2]
|
|
13
|
+
- [x] Burst 5: Add `batchId` field to `ItemStatusDocument` and `ItemStatusChangedEvent`, preserve via `evolve` fallback [depends: none] (752e1329)
|
|
14
|
+
- [x] Burst 6: Scope `computeCommandStats` to latest batch — filter items by highest `batchId` with backward compat fallback [depends: Burst 5] (a34a7e6a)
|
|
15
|
+
- [x] Burst 7+8: Wire sync `resolveBatchId` with pending counter, update test expectations for batch-scoped counts [depends: Burst 6] (7e33cf98)
|
package/package.json
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"jose": "^5.9.6",
|
|
16
16
|
"nanoid": "^5.0.0",
|
|
17
17
|
"uuid": "^11.0.0",
|
|
18
|
-
"@auto-engineer/file-store": "1.
|
|
19
|
-
"@auto-engineer/message-bus": "1.
|
|
18
|
+
"@auto-engineer/file-store": "1.136.0",
|
|
19
|
+
"@auto-engineer/message-bus": "1.136.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/cors": "^2.8.17",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"publishConfig": {
|
|
26
26
|
"access": "public"
|
|
27
27
|
},
|
|
28
|
-
"version": "1.
|
|
28
|
+
"version": "1.136.0",
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "tsc && tsx ../../scripts/fix-esm-imports.ts",
|
|
31
31
|
"test": "vitest run --reporter=dot",
|
|
@@ -179,6 +179,68 @@ describe('ItemStatusProjection', () => {
|
|
|
179
179
|
expect(result.endedAt).toEqual(endTime.toISOString());
|
|
180
180
|
});
|
|
181
181
|
|
|
182
|
+
it('preserves batchId from creation event on subsequent updates', () => {
|
|
183
|
+
const existing: ItemStatusDocument = {
|
|
184
|
+
correlationId: 'c1',
|
|
185
|
+
commandType: 'ProcessItem',
|
|
186
|
+
itemKey: 'item-1',
|
|
187
|
+
currentRequestId: 'req-1',
|
|
188
|
+
status: 'running',
|
|
189
|
+
attemptCount: 1,
|
|
190
|
+
batchId: '2025-01-01T00:00:00.000Z',
|
|
191
|
+
};
|
|
192
|
+
const event: ItemStatusChangedEvent = {
|
|
193
|
+
type: 'ItemStatusChanged',
|
|
194
|
+
data: {
|
|
195
|
+
correlationId: 'c1',
|
|
196
|
+
commandType: 'ProcessItem',
|
|
197
|
+
itemKey: 'item-1',
|
|
198
|
+
requestId: 'req-1',
|
|
199
|
+
status: 'success',
|
|
200
|
+
attemptCount: 1,
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const result = evolve(existing, event);
|
|
205
|
+
|
|
206
|
+
expect(result).toEqual({
|
|
207
|
+
correlationId: 'c1',
|
|
208
|
+
commandType: 'ProcessItem',
|
|
209
|
+
itemKey: 'item-1',
|
|
210
|
+
currentRequestId: 'req-1',
|
|
211
|
+
status: 'success',
|
|
212
|
+
attemptCount: 1,
|
|
213
|
+
batchId: '2025-01-01T00:00:00.000Z',
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('sets batchId from event data on creation', () => {
|
|
218
|
+
const event: ItemStatusChangedEvent = {
|
|
219
|
+
type: 'ItemStatusChanged',
|
|
220
|
+
data: {
|
|
221
|
+
correlationId: 'c1',
|
|
222
|
+
commandType: 'ProcessItem',
|
|
223
|
+
itemKey: 'item-1',
|
|
224
|
+
requestId: 'req-1',
|
|
225
|
+
status: 'running',
|
|
226
|
+
attemptCount: 1,
|
|
227
|
+
batchId: '2025-01-01T00:00:00.000Z',
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const result = evolve(null, event);
|
|
232
|
+
|
|
233
|
+
expect(result).toEqual({
|
|
234
|
+
correlationId: 'c1',
|
|
235
|
+
commandType: 'ProcessItem',
|
|
236
|
+
itemKey: 'item-1',
|
|
237
|
+
currentRequestId: 'req-1',
|
|
238
|
+
status: 'running',
|
|
239
|
+
attemptCount: 1,
|
|
240
|
+
batchId: '2025-01-01T00:00:00.000Z',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
182
244
|
it('sets endedAt when status becomes error', () => {
|
|
183
245
|
const startTime = new Date('2025-01-01T00:00:00Z');
|
|
184
246
|
const endTime = new Date('2025-01-01T00:00:03Z');
|
|
@@ -8,6 +8,7 @@ export interface ItemStatusDocument {
|
|
|
8
8
|
attemptCount: number;
|
|
9
9
|
startedAt?: string;
|
|
10
10
|
endedAt?: string;
|
|
11
|
+
batchId?: string;
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface ItemStatusChangedEvent {
|
|
@@ -20,6 +21,7 @@ export interface ItemStatusChangedEvent {
|
|
|
20
21
|
status: 'running' | 'success' | 'error';
|
|
21
22
|
attemptCount: number;
|
|
22
23
|
timestamp?: string;
|
|
24
|
+
batchId?: string;
|
|
23
25
|
};
|
|
24
26
|
}
|
|
25
27
|
|
|
@@ -31,6 +33,7 @@ export function evolve(document: ItemStatusDocument | null, event: ItemStatusCha
|
|
|
31
33
|
currentRequestId: event.data.requestId,
|
|
32
34
|
status: event.data.status,
|
|
33
35
|
attemptCount: event.data.attemptCount,
|
|
36
|
+
batchId: event.data.batchId ?? document?.batchId,
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
if (event.data.status === 'running') {
|
|
@@ -832,7 +832,7 @@ describe('PipelineServer', () => {
|
|
|
832
832
|
const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
|
|
833
833
|
const cmdNode = data.nodes.find((n) => n.id === 'cmd:IndependentCmd');
|
|
834
834
|
expect(cmdNode?.status).toBe('success');
|
|
835
|
-
expect(cmdNode?.endedCount).toBe(
|
|
835
|
+
expect(cmdNode?.endedCount).toBe(1);
|
|
836
836
|
|
|
837
837
|
await server.stop();
|
|
838
838
|
});
|
|
@@ -1707,10 +1707,16 @@ describe('PipelineServer', () => {
|
|
|
1707
1707
|
});
|
|
1708
1708
|
|
|
1709
1709
|
it('should count multiple parallel items correctly', async () => {
|
|
1710
|
+
const resolvers: Array<() => void> = [];
|
|
1710
1711
|
const handler = {
|
|
1711
1712
|
name: 'ImplementSlice',
|
|
1712
1713
|
events: ['SliceImplemented'],
|
|
1713
|
-
handle: async () =>
|
|
1714
|
+
handle: async () => {
|
|
1715
|
+
await new Promise<void>((resolve) => {
|
|
1716
|
+
resolvers.push(resolve);
|
|
1717
|
+
});
|
|
1718
|
+
return { type: 'SliceImplemented', data: {} };
|
|
1719
|
+
},
|
|
1714
1720
|
};
|
|
1715
1721
|
const pipeline = define('test').on('Trigger').emit('ImplementSlice', {}).build();
|
|
1716
1722
|
const server = new PipelineServer({ port: 0 });
|
|
@@ -1719,24 +1725,26 @@ describe('PipelineServer', () => {
|
|
|
1719
1725
|
server.registerItemKeyExtractor('ImplementSlice', (d) => (d as { slicePath?: string }).slicePath);
|
|
1720
1726
|
await server.start();
|
|
1721
1727
|
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
}),
|
|
1738
|
-
]);
|
|
1728
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
1729
|
+
method: 'POST',
|
|
1730
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1731
|
+
body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-1' } }),
|
|
1732
|
+
});
|
|
1733
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
1734
|
+
method: 'POST',
|
|
1735
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1736
|
+
body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-2' } }),
|
|
1737
|
+
});
|
|
1738
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
1739
|
+
method: 'POST',
|
|
1740
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1741
|
+
body: JSON.stringify({ type: 'ImplementSlice', data: { slicePath: '/server/slice-3' } }),
|
|
1742
|
+
});
|
|
1739
1743
|
|
|
1744
|
+
while (resolvers.length < 3) {
|
|
1745
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1746
|
+
}
|
|
1747
|
+
resolvers.forEach((r) => r());
|
|
1740
1748
|
await new Promise((r) => setTimeout(r, 100));
|
|
1741
1749
|
|
|
1742
1750
|
const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
|
|
@@ -1805,10 +1813,14 @@ describe('PipelineServer', () => {
|
|
|
1805
1813
|
});
|
|
1806
1814
|
|
|
1807
1815
|
it('should show error status when any item fails', async () => {
|
|
1816
|
+
const resolvers: Array<() => void> = [];
|
|
1808
1817
|
const handler = {
|
|
1809
1818
|
name: 'MixedSlice',
|
|
1810
1819
|
events: ['MixedSliceDone', 'MixedSliceFailed'],
|
|
1811
1820
|
handle: async (cmd: { data: { shouldFail?: boolean } }) => {
|
|
1821
|
+
await new Promise<void>((resolve) => {
|
|
1822
|
+
resolvers.push(resolve);
|
|
1823
|
+
});
|
|
1812
1824
|
if (cmd.data.shouldFail === true) {
|
|
1813
1825
|
return { type: 'MixedSliceFailed', data: {} };
|
|
1814
1826
|
}
|
|
@@ -1822,24 +1834,26 @@ describe('PipelineServer', () => {
|
|
|
1822
1834
|
server.registerItemKeyExtractor('MixedSlice', (d) => (d as { id?: string }).id);
|
|
1823
1835
|
await server.start();
|
|
1824
1836
|
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
}),
|
|
1841
|
-
]);
|
|
1837
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
1838
|
+
method: 'POST',
|
|
1839
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1840
|
+
body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-1' } }),
|
|
1841
|
+
});
|
|
1842
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
1843
|
+
method: 'POST',
|
|
1844
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1845
|
+
body: JSON.stringify({ type: 'MixedSlice', data: { id: 'fail-1', shouldFail: true } }),
|
|
1846
|
+
});
|
|
1847
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
1848
|
+
method: 'POST',
|
|
1849
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1850
|
+
body: JSON.stringify({ type: 'MixedSlice', data: { id: 'pass-2' } }),
|
|
1851
|
+
});
|
|
1842
1852
|
|
|
1853
|
+
while (resolvers.length < 3) {
|
|
1854
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1855
|
+
}
|
|
1856
|
+
resolvers.forEach((r) => r());
|
|
1843
1857
|
await new Promise((r) => setTimeout(r, 100));
|
|
1844
1858
|
|
|
1845
1859
|
const data = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
|
|
@@ -2120,7 +2134,7 @@ describe('PipelineServer', () => {
|
|
|
2120
2134
|
label: 'RetryNoExtractor',
|
|
2121
2135
|
status: 'success',
|
|
2122
2136
|
pendingCount: 0,
|
|
2123
|
-
endedCount:
|
|
2137
|
+
endedCount: 1,
|
|
2124
2138
|
lastDurationMs: expect.any(Number),
|
|
2125
2139
|
});
|
|
2126
2140
|
|
|
@@ -2168,6 +2182,78 @@ describe('PipelineServer', () => {
|
|
|
2168
2182
|
resolveHandler();
|
|
2169
2183
|
await server.stop();
|
|
2170
2184
|
});
|
|
2185
|
+
|
|
2186
|
+
it('should not let stale error items from a previous batch contaminate status', async () => {
|
|
2187
|
+
let callCount = 0;
|
|
2188
|
+
const resolvers: Array<() => void> = [];
|
|
2189
|
+
const handler = {
|
|
2190
|
+
name: 'BatchCmd',
|
|
2191
|
+
events: ['BatchDone', 'BatchFailed'],
|
|
2192
|
+
handle: async () => {
|
|
2193
|
+
callCount++;
|
|
2194
|
+
const thisCall = callCount;
|
|
2195
|
+
await new Promise<void>((resolve) => {
|
|
2196
|
+
resolvers.push(resolve);
|
|
2197
|
+
});
|
|
2198
|
+
if (thisCall <= 2) {
|
|
2199
|
+
return { type: 'BatchFailed', data: {} };
|
|
2200
|
+
}
|
|
2201
|
+
return { type: 'BatchDone', data: {} };
|
|
2202
|
+
},
|
|
2203
|
+
};
|
|
2204
|
+
const pipeline = define('test').on('Trigger').emit('BatchCmd', {}).build();
|
|
2205
|
+
const server = new PipelineServer({ port: 0 });
|
|
2206
|
+
server.registerCommandHandlers([handler]);
|
|
2207
|
+
server.registerPipeline(pipeline);
|
|
2208
|
+
server.registerItemKeyExtractor('BatchCmd', (d) => (d as { id?: string }).id);
|
|
2209
|
+
await server.start();
|
|
2210
|
+
|
|
2211
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
2212
|
+
method: 'POST',
|
|
2213
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2214
|
+
body: JSON.stringify({ type: 'BatchCmd', data: { id: 'item-a' } }),
|
|
2215
|
+
});
|
|
2216
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
2217
|
+
method: 'POST',
|
|
2218
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2219
|
+
body: JSON.stringify({ type: 'BatchCmd', data: { id: 'item-b' } }),
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
while (resolvers.length < 2) {
|
|
2223
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2224
|
+
}
|
|
2225
|
+
resolvers.forEach((r) => r());
|
|
2226
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2227
|
+
|
|
2228
|
+
const firstBatch = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
|
|
2229
|
+
const firstNode = firstBatch.nodes.find((n) => n.id === 'cmd:BatchCmd');
|
|
2230
|
+
expect(firstNode?.status).toBe('error');
|
|
2231
|
+
expect(firstNode?.endedCount).toBe(2);
|
|
2232
|
+
|
|
2233
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
2234
|
+
method: 'POST',
|
|
2235
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2236
|
+
body: JSON.stringify({ type: 'BatchCmd', data: { id: 'item-c' } }),
|
|
2237
|
+
});
|
|
2238
|
+
void fetch(`http://localhost:${server.port}/command`, {
|
|
2239
|
+
method: 'POST',
|
|
2240
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2241
|
+
body: JSON.stringify({ type: 'BatchCmd', data: { id: 'item-d' } }),
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
while (resolvers.length < 4) {
|
|
2245
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2246
|
+
}
|
|
2247
|
+
resolvers.slice(2).forEach((r) => r());
|
|
2248
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
2249
|
+
|
|
2250
|
+
const secondBatch = await fetchAs<PipelineResponse>(`http://localhost:${server.port}/pipeline`);
|
|
2251
|
+
const secondNode = secondBatch.nodes.find((n) => n.id === 'cmd:BatchCmd');
|
|
2252
|
+
expect(secondNode?.status).toBe('success');
|
|
2253
|
+
expect(secondNode?.endedCount).toBe(2);
|
|
2254
|
+
|
|
2255
|
+
await server.stop();
|
|
2256
|
+
});
|
|
2171
2257
|
});
|
|
2172
2258
|
|
|
2173
2259
|
describe('auto-derived item key extractors', () => {
|
|
@@ -76,6 +76,9 @@ export class PipelineServer {
|
|
|
76
76
|
private currentSessionId = '';
|
|
77
77
|
private readonly quiescenceTracker: QuiescenceTracker;
|
|
78
78
|
private readonly requestIdToSourceEvent = new Map<string, string>();
|
|
79
|
+
private readonly settledRequestIds = new Set<string>();
|
|
80
|
+
private readonly currentBatchIds = new Map<string, string>();
|
|
81
|
+
private readonly batchPendingCounts = new Map<string, number>();
|
|
79
82
|
|
|
80
83
|
constructor(config: PipelineServerConfig) {
|
|
81
84
|
this.storeFileName = config.storeFileName;
|
|
@@ -666,6 +669,7 @@ export class PipelineServer {
|
|
|
666
669
|
requestId: string,
|
|
667
670
|
status: 'running' | 'success' | 'error',
|
|
668
671
|
attemptCount: number,
|
|
672
|
+
batchId?: string,
|
|
669
673
|
): Promise<void> {
|
|
670
674
|
await this.eventStoreContext.eventStore.appendToStream(`pipeline-${correlationId}`, [
|
|
671
675
|
{
|
|
@@ -678,6 +682,7 @@ export class PipelineServer {
|
|
|
678
682
|
status,
|
|
679
683
|
attemptCount,
|
|
680
684
|
timestamp: new Date().toISOString(),
|
|
685
|
+
...(batchId !== undefined && { batchId }),
|
|
681
686
|
},
|
|
682
687
|
},
|
|
683
688
|
]);
|
|
@@ -828,6 +833,30 @@ export class PipelineServer {
|
|
|
828
833
|
this.sseManager.broadcast(event);
|
|
829
834
|
}
|
|
830
835
|
|
|
836
|
+
private resolveBatchId(correlationId: string, commandType: string, requestId: string): string {
|
|
837
|
+
const key = `${correlationId}:${commandType}`;
|
|
838
|
+
const isFromSettled = this.settledRequestIds.delete(requestId);
|
|
839
|
+
|
|
840
|
+
if (isFromSettled) {
|
|
841
|
+
const existing = this.currentBatchIds.get(key);
|
|
842
|
+
if (existing) return existing;
|
|
843
|
+
} else {
|
|
844
|
+
const pending = this.batchPendingCounts.get(key) ?? 0;
|
|
845
|
+
if (pending === 0) {
|
|
846
|
+
const batchId = new Date().toISOString();
|
|
847
|
+
this.currentBatchIds.set(key, batchId);
|
|
848
|
+
return batchId;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const existing = this.currentBatchIds.get(key);
|
|
853
|
+
if (existing) return existing;
|
|
854
|
+
|
|
855
|
+
const batchId = new Date().toISOString();
|
|
856
|
+
this.currentBatchIds.set(key, batchId);
|
|
857
|
+
return batchId;
|
|
858
|
+
}
|
|
859
|
+
|
|
831
860
|
private extractItemKey(commandType: string, data: unknown, requestId: string): string {
|
|
832
861
|
const extractor = this.itemKeyExtractors.get(commandType);
|
|
833
862
|
if (extractor !== undefined) {
|
|
@@ -842,11 +871,12 @@ export class PipelineServer {
|
|
|
842
871
|
commandType: string,
|
|
843
872
|
itemKey: string,
|
|
844
873
|
requestId: string,
|
|
874
|
+
batchId?: string,
|
|
845
875
|
): Promise<{ attemptCount: number }> {
|
|
846
876
|
const existing = await this.eventStoreContext.readModel.getItemStatus(correlationId, commandType, itemKey);
|
|
847
877
|
const attemptCount = (existing?.attemptCount ?? 0) + 1;
|
|
848
878
|
|
|
849
|
-
await this.emitItemStatusChanged(correlationId, commandType, itemKey, requestId, 'running', attemptCount);
|
|
879
|
+
await this.emitItemStatusChanged(correlationId, commandType, itemKey, requestId, 'running', attemptCount, batchId);
|
|
850
880
|
|
|
851
881
|
return { attemptCount };
|
|
852
882
|
}
|
|
@@ -1171,8 +1201,12 @@ export class PipelineServer {
|
|
|
1171
1201
|
handler: CommandHandlerWithMetadata,
|
|
1172
1202
|
signal: AbortSignal,
|
|
1173
1203
|
): Promise<void> {
|
|
1204
|
+
const batchKey = `${this.currentSessionId}:${command.type}`;
|
|
1205
|
+
const batchId = this.resolveBatchId(this.currentSessionId, command.type, command.requestId);
|
|
1206
|
+
this.batchPendingCounts.set(batchKey, (this.batchPendingCounts.get(batchKey) ?? 0) + 1);
|
|
1207
|
+
|
|
1174
1208
|
const itemKey = this.extractItemKey(command.type, command.data, command.requestId);
|
|
1175
|
-
await this.getOrCreateItemStatus(this.currentSessionId, command.type, itemKey, command.requestId);
|
|
1209
|
+
await this.getOrCreateItemStatus(this.currentSessionId, command.type, itemKey, command.requestId, batchId);
|
|
1176
1210
|
|
|
1177
1211
|
await this.updateNodeStatus(this.currentSessionId, command.type, 'running');
|
|
1178
1212
|
const sourceEventType = this.requestIdToSourceEvent.get(command.requestId);
|
|
@@ -1202,10 +1236,14 @@ export class PipelineServer {
|
|
|
1202
1236
|
];
|
|
1203
1237
|
}
|
|
1204
1238
|
|
|
1205
|
-
if (signal.aborted)
|
|
1239
|
+
if (signal.aborted) {
|
|
1240
|
+
this.batchPendingCounts.set(batchKey, (this.batchPendingCounts.get(batchKey) ?? 1) - 1);
|
|
1241
|
+
return;
|
|
1242
|
+
}
|
|
1206
1243
|
|
|
1207
1244
|
const finalStatus = this.getStatusFromEvents(events);
|
|
1208
1245
|
await this.updateItemStatus(this.currentSessionId, command.type, itemKey, finalStatus);
|
|
1246
|
+
this.batchPendingCounts.set(batchKey, (this.batchPendingCounts.get(batchKey) ?? 1) - 1);
|
|
1209
1247
|
const completedItem = await this.eventStoreContext.readModel.getItemStatus(
|
|
1210
1248
|
this.currentSessionId,
|
|
1211
1249
|
command.type,
|
|
@@ -1255,9 +1293,10 @@ export class PipelineServer {
|
|
|
1255
1293
|
return 'success';
|
|
1256
1294
|
}
|
|
1257
1295
|
|
|
1258
|
-
/* v8 ignore next
|
|
1296
|
+
/* v8 ignore next 12 - integration callback tested via v2-runtime-bridge.specs.ts */
|
|
1259
1297
|
private async dispatchFromSettled(commandType: string, data: unknown, correlationId: string): Promise<void> {
|
|
1260
1298
|
const requestId = `req-${nanoid()}`;
|
|
1299
|
+
this.settledRequestIds.add(requestId);
|
|
1261
1300
|
const command: Command & { correlationId: string; requestId: string } = {
|
|
1262
1301
|
type: commandType,
|
|
1263
1302
|
data: data as Record<string, unknown>,
|
|
@@ -277,6 +277,88 @@ describe('PipelineReadModel', () => {
|
|
|
277
277
|
});
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
+
it('should scope to latest batch and ignore stale errors from previous batches', async () => {
|
|
281
|
+
const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
|
|
282
|
+
await collection.insertOne({
|
|
283
|
+
_id: 'c1-Cmd-old-a',
|
|
284
|
+
correlationId: 'c1',
|
|
285
|
+
commandType: 'Cmd',
|
|
286
|
+
itemKey: 'old-a',
|
|
287
|
+
currentRequestId: 'r1',
|
|
288
|
+
status: 'error',
|
|
289
|
+
attemptCount: 1,
|
|
290
|
+
batchId: '2025-01-01T00:00:00.000Z',
|
|
291
|
+
});
|
|
292
|
+
await collection.insertOne({
|
|
293
|
+
_id: 'c1-Cmd-old-b',
|
|
294
|
+
correlationId: 'c1',
|
|
295
|
+
commandType: 'Cmd',
|
|
296
|
+
itemKey: 'old-b',
|
|
297
|
+
currentRequestId: 'r2',
|
|
298
|
+
status: 'error',
|
|
299
|
+
attemptCount: 1,
|
|
300
|
+
batchId: '2025-01-01T00:00:00.000Z',
|
|
301
|
+
});
|
|
302
|
+
await collection.insertOne({
|
|
303
|
+
_id: 'c1-Cmd-new-a',
|
|
304
|
+
correlationId: 'c1',
|
|
305
|
+
commandType: 'Cmd',
|
|
306
|
+
itemKey: 'new-a',
|
|
307
|
+
currentRequestId: 'r3',
|
|
308
|
+
status: 'success',
|
|
309
|
+
attemptCount: 1,
|
|
310
|
+
batchId: '2025-01-01T00:01:00.000Z',
|
|
311
|
+
});
|
|
312
|
+
await collection.insertOne({
|
|
313
|
+
_id: 'c1-Cmd-new-b',
|
|
314
|
+
correlationId: 'c1',
|
|
315
|
+
commandType: 'Cmd',
|
|
316
|
+
itemKey: 'new-b',
|
|
317
|
+
currentRequestId: 'r4',
|
|
318
|
+
status: 'success',
|
|
319
|
+
attemptCount: 1,
|
|
320
|
+
batchId: '2025-01-01T00:01:00.000Z',
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const result = await readModel.computeCommandStats('c1', 'Cmd');
|
|
324
|
+
|
|
325
|
+
expect(result).toEqual({
|
|
326
|
+
pendingCount: 0,
|
|
327
|
+
endedCount: 2,
|
|
328
|
+
aggregateStatus: 'success',
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should use all items when no batchId exists for backward compatibility', async () => {
|
|
333
|
+
const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
|
|
334
|
+
await collection.insertOne({
|
|
335
|
+
_id: 'c1-Cmd-a',
|
|
336
|
+
correlationId: 'c1',
|
|
337
|
+
commandType: 'Cmd',
|
|
338
|
+
itemKey: 'a',
|
|
339
|
+
currentRequestId: 'r1',
|
|
340
|
+
status: 'success',
|
|
341
|
+
attemptCount: 1,
|
|
342
|
+
});
|
|
343
|
+
await collection.insertOne({
|
|
344
|
+
_id: 'c1-Cmd-b',
|
|
345
|
+
correlationId: 'c1',
|
|
346
|
+
commandType: 'Cmd',
|
|
347
|
+
itemKey: 'b',
|
|
348
|
+
currentRequestId: 'r2',
|
|
349
|
+
status: 'error',
|
|
350
|
+
attemptCount: 1,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const result = await readModel.computeCommandStats('c1', 'Cmd');
|
|
354
|
+
|
|
355
|
+
expect(result).toEqual({
|
|
356
|
+
pendingCount: 0,
|
|
357
|
+
endedCount: 2,
|
|
358
|
+
aggregateStatus: 'error',
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
280
362
|
it('documents behavior: returns error when stale error items exist (BUG: requestId-based itemKey needs proper extractor)', async () => {
|
|
281
363
|
const collection = database.collection<WithId<ItemStatusDocument>>('ItemStatus');
|
|
282
364
|
await collection.insertOne({
|
|
@@ -50,11 +50,18 @@ export class PipelineReadModel {
|
|
|
50
50
|
return { pendingCount: 0, endedCount: 0, aggregateStatus: 'idle' };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
const latestBatchId = items.reduce<string | undefined>((latest, item) => {
|
|
54
|
+
if (item.batchId === undefined) return latest;
|
|
55
|
+
if (latest === undefined || item.batchId > latest) return item.batchId;
|
|
56
|
+
return latest;
|
|
57
|
+
}, undefined);
|
|
58
|
+
const currentItems = latestBatchId !== undefined ? items.filter((item) => item.batchId === latestBatchId) : items;
|
|
59
|
+
|
|
53
60
|
let pendingCount = 0;
|
|
54
61
|
let endedCount = 0;
|
|
55
62
|
let hasError = false;
|
|
56
63
|
|
|
57
|
-
for (const item of
|
|
64
|
+
for (const item of currentItems) {
|
|
58
65
|
if (item.status === 'running') {
|
|
59
66
|
pendingCount++;
|
|
60
67
|
} else {
|
|
@@ -117,6 +124,7 @@ export class PipelineReadModel {
|
|
|
117
124
|
attemptCount: item.attemptCount,
|
|
118
125
|
startedAt: item.startedAt,
|
|
119
126
|
endedAt: item.endedAt,
|
|
127
|
+
batchId: item.batchId,
|
|
120
128
|
};
|
|
121
129
|
}
|
|
122
130
|
|
|
@@ -131,6 +139,7 @@ export class PipelineReadModel {
|
|
|
131
139
|
attemptCount: item.attemptCount,
|
|
132
140
|
startedAt: item.startedAt,
|
|
133
141
|
endedAt: item.endedAt,
|
|
142
|
+
batchId: item.batchId,
|
|
134
143
|
}));
|
|
135
144
|
}
|
|
136
145
|
|