@workflow/web-shared 5.0.0-beta.40 → 5.0.0-beta.42
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/dist/components/event-list-view.d.ts +10 -2
- package/dist/components/event-list-view.d.ts.map +1 -1
- package/dist/components/event-list-view.js +52 -26
- package/dist/components/sidebar/attributes-block.d.ts.map +1 -1
- package/dist/components/sidebar/attributes-block.js +3 -6
- package/dist/components/sidebar/conversation-view.d.ts.map +1 -1
- package/dist/components/sidebar/conversation-view.js +19 -36
- package/dist/components/sidebar/entity-detail-panel.d.ts +5 -0
- package/dist/components/sidebar/entity-detail-panel.d.ts.map +1 -1
- package/dist/components/sidebar/entity-detail-panel.js +2 -2
- package/dist/components/sidebar/events-list.d.ts +7 -1
- package/dist/components/sidebar/events-list.d.ts.map +1 -1
- package/dist/components/sidebar/events-list.js +6 -5
- package/dist/components/sidebar/resolve-hook-modal.d.ts.map +1 -1
- package/dist/components/sidebar/resolve-hook-modal.js +4 -145
- package/dist/components/sidebar/sidebar-data-context.d.ts +6 -0
- package/dist/components/sidebar/sidebar-data-context.d.ts.map +1 -1
- package/dist/components/sidebar/sidebar-data-context.js +1 -1
- package/dist/components/stream-viewer-skeleton.js +6 -6
- package/dist/components/stream-viewer.js +2 -2
- package/dist/components/trace-viewer/components/detail-panel.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/detail-panel.js +2 -1
- package/dist/components/trace-viewer/components/timeline.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/timeline.js +13 -22
- package/dist/components/trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -1
- package/dist/components/trace-viewer/components/trace-viewer-skeleton.js +58 -18
- package/dist/components/trace-viewer/icons.js +5 -5
- package/dist/components/trace-viewer/utils.d.ts +6 -10
- package/dist/components/trace-viewer/utils.d.ts.map +1 -1
- package/dist/components/trace-viewer/utils.js +14 -24
- package/dist/components/trace-viewer.d.ts.map +1 -1
- package/dist/components/trace-viewer.js +12 -4
- package/dist/components/ui/context-card.d.ts.map +1 -1
- package/dist/components/ui/context-card.js +9 -21
- package/dist/components/ui/data-inspector.d.ts.map +1 -1
- package/dist/components/ui/data-inspector.js +8 -35
- package/dist/components/ui/duplicate-event-tooltip.d.ts +15 -0
- package/dist/components/ui/duplicate-event-tooltip.d.ts.map +1 -0
- package/dist/components/ui/duplicate-event-tooltip.js +19 -0
- package/dist/components/ui/menu-dropdown.d.ts.map +1 -1
- package/dist/components/ui/menu-dropdown.js +3 -19
- package/dist/components/ui/skeleton.js +2 -2
- package/dist/components/ui/spinner.d.ts.map +1 -1
- package/dist/components/ui/spinner.js +5 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/duplicate-events.d.ts +27 -0
- package/dist/lib/duplicate-events.d.ts.map +1 -0
- package/dist/lib/duplicate-events.js +166 -0
- package/dist/lib/event-materialization.d.ts +15 -2
- package/dist/lib/event-materialization.d.ts.map +1 -1
- package/dist/lib/event-materialization.js +19 -4
- package/dist/lib/trace-builder.d.ts +16 -1
- package/dist/lib/trace-builder.d.ts.map +1 -1
- package/dist/lib/trace-builder.js +23 -4
- package/package.json +4 -4
- package/src/components/event-list-view.tsx +70 -32
- package/src/components/sidebar/attributes-block.tsx +4 -11
- package/src/components/sidebar/conversation-view.tsx +25 -79
- package/src/components/sidebar/entity-detail-panel.tsx +6 -0
- package/src/components/sidebar/events-list.tsx +21 -3
- package/src/components/sidebar/resolve-hook-modal.tsx +23 -186
- package/src/components/sidebar/sidebar-data-context.tsx +6 -0
- package/src/components/stream-viewer-skeleton.tsx +5 -5
- package/src/components/stream-viewer.tsx +1 -1
- package/src/components/trace-viewer/components/detail-panel.tsx +1 -0
- package/src/components/trace-viewer/components/timeline.tsx +21 -39
- package/src/components/trace-viewer/components/trace-viewer-skeleton.tsx +63 -35
- package/src/components/trace-viewer/icons.tsx +4 -4
- package/src/components/trace-viewer/utils.test.ts +5 -7
- package/src/components/trace-viewer/utils.ts +17 -31
- package/src/components/trace-viewer.tsx +15 -3
- package/src/components/ui/context-card.tsx +16 -26
- package/src/components/ui/data-inspector.tsx +20 -65
- package/src/components/ui/duplicate-event-tooltip.tsx +44 -0
- package/src/components/ui/menu-dropdown.tsx +8 -22
- package/src/components/ui/skeleton.tsx +2 -2
- package/src/components/ui/spinner.tsx +4 -8
- package/src/index.ts +4 -0
- package/src/lib/duplicate-events.test.ts +339 -0
- package/src/lib/duplicate-events.ts +188 -0
- package/src/lib/event-materialization.test.ts +117 -0
- package/src/lib/event-materialization.ts +23 -3
- package/src/lib/trace-builder.test.ts +74 -0
- package/src/lib/trace-builder.ts +30 -3
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import type { Event, EventType } from '@workflow/world';
|
|
2
|
+
import { DUPLICATE_EVENT_FIXTURES } from '@workflow/world/test-support/duplicate-event-fixtures.js';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { findDuplicateEventIds } from './duplicate-events';
|
|
5
|
+
|
|
6
|
+
const BASE_TIME = Date.parse('2026-01-01T00:00:00.000Z');
|
|
7
|
+
|
|
8
|
+
const COMPLETE = { isCompleteHistory: true };
|
|
9
|
+
|
|
10
|
+
let nextSlot = 0;
|
|
11
|
+
|
|
12
|
+
function event(
|
|
13
|
+
eventType: EventType,
|
|
14
|
+
options: {
|
|
15
|
+
correlationId?: string;
|
|
16
|
+
/** Log position. Defaults to the order the fixture created the event in. */
|
|
17
|
+
slot?: number;
|
|
18
|
+
/** `createdAt`/`occurredAt`, in seconds after the fixture epoch. */
|
|
19
|
+
at?: number;
|
|
20
|
+
occurredAt?: number;
|
|
21
|
+
} = {}
|
|
22
|
+
): Event {
|
|
23
|
+
nextSlot += 1;
|
|
24
|
+
const slot = options.slot ?? nextSlot;
|
|
25
|
+
const createdAt = new Date(BASE_TIME + (options.at ?? slot) * 1000);
|
|
26
|
+
return {
|
|
27
|
+
eventId: `evnt_${String(slot).padStart(26, '0')}`,
|
|
28
|
+
runId: 'run_1',
|
|
29
|
+
eventType,
|
|
30
|
+
correlationId: options.correlationId,
|
|
31
|
+
createdAt,
|
|
32
|
+
occurredAt:
|
|
33
|
+
options.occurredAt === undefined
|
|
34
|
+
? createdAt
|
|
35
|
+
: new Date(BASE_TIME + options.occurredAt * 1000),
|
|
36
|
+
eventData: {},
|
|
37
|
+
} as unknown as Event;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The same event under the older ID scheme, whose IDs are ULIDs rather than
|
|
42
|
+
* slots. A backend serving such a log may order it by `(createdAt, eventId)`
|
|
43
|
+
* instead of by ID, so the ID alone does not fix the log position.
|
|
44
|
+
*/
|
|
45
|
+
function ulidEvent(...args: Parameters<typeof event>): Event {
|
|
46
|
+
const slotEvent = event(...args);
|
|
47
|
+
const slot = slotEvent.eventId.slice('evnt_'.length).replace(/^0+/, '');
|
|
48
|
+
return {
|
|
49
|
+
...slotEvent,
|
|
50
|
+
eventId: `evnt_01K${slot.padStart(23, '0')}`,
|
|
51
|
+
} as Event;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('findDuplicateEventIds', () => {
|
|
55
|
+
it('returns nothing for a log with no repeats', () => {
|
|
56
|
+
const events = [
|
|
57
|
+
event('run_created'),
|
|
58
|
+
event('run_started'),
|
|
59
|
+
event('step_created', { correlationId: 'step_a' }),
|
|
60
|
+
event('step_started', { correlationId: 'step_a' }),
|
|
61
|
+
event('step_completed', { correlationId: 'step_a' }),
|
|
62
|
+
event('run_completed'),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('flags every repeat a finished entity collects', () => {
|
|
69
|
+
const created = event('step_created', { correlationId: 'step_a' });
|
|
70
|
+
const started = event('step_started', { correlationId: 'step_a' });
|
|
71
|
+
const completed = event('step_completed', { correlationId: 'step_a' });
|
|
72
|
+
const createdAgain = event('step_created', { correlationId: 'step_a' });
|
|
73
|
+
const startedAgain = event('step_started', { correlationId: 'step_a' });
|
|
74
|
+
|
|
75
|
+
expect(
|
|
76
|
+
findDuplicateEventIds(
|
|
77
|
+
[created, started, completed, createdAgain, startedAgain],
|
|
78
|
+
COMPLETE
|
|
79
|
+
)
|
|
80
|
+
).toEqual(new Set([createdAgain.eventId, startedAgain.eventId]));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('leaves a class the log has not recorded for the entity yet', () => {
|
|
84
|
+
// The step finished without a step_started in the log, so this one repeats
|
|
85
|
+
// nothing. The runtime reports that as divergence rather than passing it
|
|
86
|
+
// over, and the UI must not present it as a settled repeat.
|
|
87
|
+
const events = [
|
|
88
|
+
event('step_created', { correlationId: 'step_a' }),
|
|
89
|
+
event('step_completed', { correlationId: 'step_a' }),
|
|
90
|
+
event('step_started', { correlationId: 'step_a' }),
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('treats completed and failed as one terminal class', () => {
|
|
97
|
+
// A concurrent replay writing the other outcome does not move the step off
|
|
98
|
+
// the outcome the run acted on.
|
|
99
|
+
const failed = event('step_failed', { correlationId: 'step_a' });
|
|
100
|
+
const completed = event('step_completed', { correlationId: 'step_a' });
|
|
101
|
+
|
|
102
|
+
expect(findDuplicateEventIds([failed, completed], COMPLETE)).toEqual(
|
|
103
|
+
new Set([completed.eventId])
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('keys on the correlation id, so sibling entities never collide', () => {
|
|
108
|
+
const events = [
|
|
109
|
+
event('step_created', { correlationId: 'step_a' }),
|
|
110
|
+
event('step_created', { correlationId: 'step_b' }),
|
|
111
|
+
event('step_completed', { correlationId: 'step_a' }),
|
|
112
|
+
event('step_completed', { correlationId: 'step_b' }),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('does not flag the repeated events of a retried step', () => {
|
|
119
|
+
// Each attempt legitimately records its own start, and each retryable
|
|
120
|
+
// failure its own step_retrying. The step's consumer is registered for the
|
|
121
|
+
// whole sequence and takes all of them.
|
|
122
|
+
const events = [
|
|
123
|
+
event('step_created', { correlationId: 'step_a' }),
|
|
124
|
+
event('step_started', { correlationId: 'step_a' }),
|
|
125
|
+
event('step_retrying', { correlationId: 'step_a' }),
|
|
126
|
+
event('step_started', { correlationId: 'step_a' }),
|
|
127
|
+
event('step_retrying', { correlationId: 'step_a' }),
|
|
128
|
+
event('step_started', { correlationId: 'step_a' }),
|
|
129
|
+
event('step_completed', { correlationId: 'step_a' }),
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('does not flag a second step_created while the step is still open', () => {
|
|
136
|
+
// The step's consumer is registered and absorbs it, so the run does not
|
|
137
|
+
// read past this event.
|
|
138
|
+
const events = [
|
|
139
|
+
event('step_created', { correlationId: 'step_a' }),
|
|
140
|
+
event('step_created', { correlationId: 'step_a' }),
|
|
141
|
+
event('step_started', { correlationId: 'step_a' }),
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not flag repeated hook deliveries', () => {
|
|
148
|
+
const events = [
|
|
149
|
+
event('hook_created', { correlationId: 'hook_a' }),
|
|
150
|
+
event('hook_received', { correlationId: 'hook_a' }),
|
|
151
|
+
event('hook_received', { correlationId: 'hook_a' }),
|
|
152
|
+
event('hook_disposed', { correlationId: 'hook_a' }),
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
expect(findDuplicateEventIds(events, COMPLETE)).toEqual(new Set());
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('flags a second start of the run, which carries no correlation id', () => {
|
|
159
|
+
const started = event('run_started');
|
|
160
|
+
const startedAgain = event('run_started');
|
|
161
|
+
|
|
162
|
+
expect(findDuplicateEventIds([started, startedAgain], COMPLETE)).toEqual(
|
|
163
|
+
new Set([startedAgain.eventId])
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('leaves a second outcome for the run alone', () => {
|
|
168
|
+
// Nothing consumes the run's own terminal events: the runtime exits rather
|
|
169
|
+
// than replaying the body once the log holds one. A second is a fault
|
|
170
|
+
// worth seeing, not a repeat the run passed over.
|
|
171
|
+
const completed = event('run_completed');
|
|
172
|
+
const cancelled = event('run_cancelled');
|
|
173
|
+
|
|
174
|
+
expect(findDuplicateEventIds([completed, cancelled], COMPLETE)).toEqual(
|
|
175
|
+
new Set()
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('folds in log order, not in createdAt order', () => {
|
|
180
|
+
const created = event('wait_created', { correlationId: 'wait_a', at: 1 });
|
|
181
|
+
const completed = event('wait_completed', {
|
|
182
|
+
correlationId: 'wait_a',
|
|
183
|
+
at: 3,
|
|
184
|
+
});
|
|
185
|
+
// The repeat entered before the completion it lost to and only took its
|
|
186
|
+
// log position afterwards, so its createdAt is the earliest of the three.
|
|
187
|
+
const createdAgain = event('wait_created', {
|
|
188
|
+
correlationId: 'wait_a',
|
|
189
|
+
at: 0,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
expect(
|
|
193
|
+
findDuplicateEventIds([created, completed, createdAgain], COMPLETE)
|
|
194
|
+
).toEqual(new Set([createdAgain.eventId]));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('folds in log order, not in occurredAt order', () => {
|
|
198
|
+
const created = event('wait_created', { correlationId: 'wait_a' });
|
|
199
|
+
const completed = event('wait_completed', { correlationId: 'wait_a' });
|
|
200
|
+
// occurredAt is measured on the writer's clock, which can run behind.
|
|
201
|
+
const createdAgain = event('wait_created', {
|
|
202
|
+
correlationId: 'wait_a',
|
|
203
|
+
occurredAt: -60,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(
|
|
207
|
+
findDuplicateEventIds([created, completed, createdAgain], COMPLETE)
|
|
208
|
+
).toEqual(new Set([createdAgain.eventId]));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('gives the same answer whichever way the caller sorted', () => {
|
|
212
|
+
const events = [
|
|
213
|
+
event('wait_created', { correlationId: 'wait_a' }),
|
|
214
|
+
event('wait_completed', { correlationId: 'wait_a' }),
|
|
215
|
+
event('wait_created', { correlationId: 'wait_a' }),
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
const ascending = findDuplicateEventIds(events, COMPLETE);
|
|
219
|
+
const descending = findDuplicateEventIds([...events].reverse(), COMPLETE);
|
|
220
|
+
|
|
221
|
+
expect(ascending).toEqual(new Set([events[2].eventId]));
|
|
222
|
+
expect(descending).toEqual(ascending);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('gives the same answer on tied timestamps whichever way the caller sorted', () => {
|
|
226
|
+
// Two replays that stamped the same millisecond. Only the log position
|
|
227
|
+
// separates them, so the answer must not depend on the caller's order.
|
|
228
|
+
const events = [
|
|
229
|
+
event('wait_created', { correlationId: 'wait_a', at: 5 }),
|
|
230
|
+
event('wait_completed', { correlationId: 'wait_a', at: 5 }),
|
|
231
|
+
event('wait_created', { correlationId: 'wait_a', at: 5 }),
|
|
232
|
+
];
|
|
233
|
+
|
|
234
|
+
const ascending = findDuplicateEventIds(events, COMPLETE);
|
|
235
|
+
const descending = findDuplicateEventIds([...events].reverse(), COMPLETE);
|
|
236
|
+
|
|
237
|
+
expect(ascending).toEqual(new Set([events[2].eventId]));
|
|
238
|
+
expect(descending).toEqual(ascending);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('classifies a ULID log whose timestamps corroborate its ids', () => {
|
|
242
|
+
const created = ulidEvent('wait_created', { correlationId: 'wait_a' });
|
|
243
|
+
const completed = ulidEvent('wait_completed', { correlationId: 'wait_a' });
|
|
244
|
+
const createdAgain = ulidEvent('wait_created', { correlationId: 'wait_a' });
|
|
245
|
+
|
|
246
|
+
expect(
|
|
247
|
+
findDuplicateEventIds([created, completed, createdAgain], COMPLETE)
|
|
248
|
+
).toEqual(new Set([createdAgain.eventId]));
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('classifies nothing on a ULID log whose timestamps contradict its ids', () => {
|
|
252
|
+
// A ULID carries no log position: one backend returns such a log in
|
|
253
|
+
// createdAt order and another in id order, and createdAt is stamped when
|
|
254
|
+
// the write arrives rather than when it commits. With the two orders
|
|
255
|
+
// disagreeing, which wait_created the run acted on depends on the backend,
|
|
256
|
+
// so naming either would be a guess.
|
|
257
|
+
const created = ulidEvent('wait_created', { correlationId: 'wait_a' });
|
|
258
|
+
const completed = ulidEvent('wait_completed', { correlationId: 'wait_a' });
|
|
259
|
+
const createdAgain = ulidEvent('wait_created', {
|
|
260
|
+
correlationId: 'wait_a',
|
|
261
|
+
at: -60,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
expect(
|
|
265
|
+
findDuplicateEventIds([created, completed, createdAgain], COMPLETE)
|
|
266
|
+
).toEqual(new Set());
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('classifies nothing past the point the run diverged', () => {
|
|
270
|
+
// The step finished without a step_started, so the runtime reports
|
|
271
|
+
// divergence on the first trailing start and exits. The second start and
|
|
272
|
+
// the wait's repeat after it went unread, and neither is a repeat the run
|
|
273
|
+
// passed over. The wait's repeat before it still is.
|
|
274
|
+
const events = [
|
|
275
|
+
event('wait_created', { correlationId: 'wait_a' }),
|
|
276
|
+
event('wait_completed', { correlationId: 'wait_a' }),
|
|
277
|
+
event('wait_created', { correlationId: 'wait_a' }),
|
|
278
|
+
event('step_created', { correlationId: 'step_a' }),
|
|
279
|
+
event('step_completed', { correlationId: 'step_a' }),
|
|
280
|
+
event('step_started', { correlationId: 'step_a' }),
|
|
281
|
+
event('step_started', { correlationId: 'step_a' }),
|
|
282
|
+
event('wait_created', { correlationId: 'wait_a' }),
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
expect(findDuplicateEventIds(events, COMPLETE)).toEqual(
|
|
286
|
+
new Set([events[2].eventId])
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('classifies nothing when the caller holds part of the log', () => {
|
|
291
|
+
// A newest-first page can open on the repeat and omit the event it
|
|
292
|
+
// repeats, which would invert the answer.
|
|
293
|
+
const events = [
|
|
294
|
+
event('wait_completed', { correlationId: 'wait_a' }),
|
|
295
|
+
event('wait_created', { correlationId: 'wait_a' }),
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
expect(findDuplicateEventIds(events, { isCompleteHistory: false })).toEqual(
|
|
299
|
+
new Set()
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('skips events with no id, which callers cannot match on', () => {
|
|
304
|
+
const anonymous = (eventType: EventType) =>
|
|
305
|
+
({
|
|
306
|
+
...event(eventType, { correlationId: 'wait_a' }),
|
|
307
|
+
eventId: undefined,
|
|
308
|
+
}) as unknown as Event;
|
|
309
|
+
|
|
310
|
+
expect(
|
|
311
|
+
findDuplicateEventIds(
|
|
312
|
+
[
|
|
313
|
+
anonymous('wait_created'),
|
|
314
|
+
anonymous('wait_completed'),
|
|
315
|
+
anonymous('wait_created'),
|
|
316
|
+
],
|
|
317
|
+
COMPLETE
|
|
318
|
+
)
|
|
319
|
+
).toEqual(new Set());
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The other half of these runs against `EventsConsumer` in `@workflow/core`,
|
|
325
|
+
* so a fixture whose expectation moves fails on both sides.
|
|
326
|
+
*/
|
|
327
|
+
describe('shared duplicate-event fixtures', () => {
|
|
328
|
+
for (const fixture of DUPLICATE_EVENT_FIXTURES) {
|
|
329
|
+
it(`classifies the right events: ${fixture.name}`, () => {
|
|
330
|
+
const events = fixture.events.map((spec, index) =>
|
|
331
|
+
event(spec.eventType, { correlationId: spec.entity, slot: index + 1 })
|
|
332
|
+
);
|
|
333
|
+
|
|
334
|
+
expect(findDuplicateEventIds(events, COMPLETE)).toEqual(
|
|
335
|
+
new Set(fixture.ignoredIndices.map((index) => events[index].eventId))
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EntityEventClass,
|
|
3
|
+
type Event,
|
|
4
|
+
entityEventClass,
|
|
5
|
+
isSlotEventId,
|
|
6
|
+
} from '@workflow/world';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Identifies events a replay reads past.
|
|
10
|
+
*
|
|
11
|
+
* Concurrent replays of one run write to a shared log, so a replay working
|
|
12
|
+
* from a stale prefix can commit a second `step_created` / `step_started` /
|
|
13
|
+
* `wait_created` for an entity the log already records one of. Every replay
|
|
14
|
+
* reads the first event of that class at the same position, so a later one
|
|
15
|
+
* cannot change what the workflow observes.
|
|
16
|
+
*
|
|
17
|
+
* The classification mirrors `entityEventClass` in `@workflow/world`, which is
|
|
18
|
+
* what the runtime keys its own duplicate detection on. What it cannot mirror
|
|
19
|
+
* is consumer state: the runtime passes over an event only after every
|
|
20
|
+
* registered callback has declined it, and a callback registered for a
|
|
21
|
+
* still-open entity legitimately claims a repeat (each retry of a step writes
|
|
22
|
+
* another `step_started`, and a live step consumer absorbs a second
|
|
23
|
+
* `step_created`). So a repeat counts here only once a terminal event for the
|
|
24
|
+
* same entity sits earlier in the log, which is the point past which no
|
|
25
|
+
* consumer remains.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Classes whose event closes its entity: no consumer is left for it after.
|
|
30
|
+
*
|
|
31
|
+
* The run's own terminal events are absent because `entityEventClass` gives
|
|
32
|
+
* them no class. The runtime exits rather than replaying the body once the log
|
|
33
|
+
* holds one, so nothing ever consumes them and nothing can repeat them.
|
|
34
|
+
*/
|
|
35
|
+
const TERMINAL_EVENT_CLASSES: ReadonlySet<EntityEventClass> = new Set([
|
|
36
|
+
'step_terminal',
|
|
37
|
+
'wait_completed',
|
|
38
|
+
'hook_disposed',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
/** Classes with no entity to close first: the log records one per run. */
|
|
42
|
+
const SINGLETON_EVENT_CLASSES: ReadonlySet<EntityEventClass> = new Set([
|
|
43
|
+
'run_started',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Entity key for events that carry no correlation ID (the run itself). */
|
|
47
|
+
const RUN_ENTITY_KEY = '';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Shown against an event this module reports. Deliberately says what the log
|
|
51
|
+
* shows rather than what the runtime did with it: tolerating these repeats is
|
|
52
|
+
* recent, and on a run recorded before it a repeat no consumer claimed failed
|
|
53
|
+
* the replay instead of being passed over.
|
|
54
|
+
*/
|
|
55
|
+
export const DUPLICATE_EVENT_MESSAGE =
|
|
56
|
+
'Written by a concurrent replay after an event of the same kind was already recorded and acted on. The run follows the earlier one.';
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Candidate log order, by event ID.
|
|
60
|
+
*
|
|
61
|
+
* Event IDs are fixed-width and monotonic within a run under both the ULID and
|
|
62
|
+
* the slot scheme. Length is compared first so a shorter ID never sorts after
|
|
63
|
+
* a longer one on a log that mixes widths.
|
|
64
|
+
*
|
|
65
|
+
* Whether this *is* the log order depends on the ID scheme, which is why
|
|
66
|
+
* {@link hasKnowableLogOrder} gates the fold. See its doc.
|
|
67
|
+
*/
|
|
68
|
+
function compareEventId(a: Event, b: Event): number {
|
|
69
|
+
if (a.eventId.length !== b.eventId.length) {
|
|
70
|
+
return a.eventId.length - b.eventId.length;
|
|
71
|
+
}
|
|
72
|
+
return a.eventId < b.eventId ? -1 : a.eventId > b.eventId ? 1 : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createdAtMs(event: Event): number {
|
|
76
|
+
const createdAt = event.createdAt;
|
|
77
|
+
return createdAt instanceof Date
|
|
78
|
+
? createdAt.getTime()
|
|
79
|
+
: new Date(createdAt as unknown as string).getTime();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the order the run consumed its log in can be recovered from the
|
|
84
|
+
* events alone.
|
|
85
|
+
*
|
|
86
|
+
* Which occurrence of a class came first is the whole question here, so the
|
|
87
|
+
* fold needs the log's order, not an order. The backends do not agree on how
|
|
88
|
+
* to recover it for every ID scheme:
|
|
89
|
+
*
|
|
90
|
+
* - A **slot-numbered** run carries its position in the ID. The slot is drawn
|
|
91
|
+
* at the publish, which is the linearization point, so slot order is log
|
|
92
|
+
* order everywhere and the ID alone settles it.
|
|
93
|
+
* - A **ULID-numbered** run does not. One backend returns such a log in
|
|
94
|
+
* `(createdAt, eventId)` order while another returns it keyed on the ID, and
|
|
95
|
+
* `createdAt` is stamped when the write request arrives rather than when it
|
|
96
|
+
* commits. Concurrent writers can therefore produce opposite timestamp and
|
|
97
|
+
* ID orders, and the run consumed whichever its own backend served.
|
|
98
|
+
*
|
|
99
|
+
* So a ULID log is only knowable where the two orders agree. Where they
|
|
100
|
+
* contradict, the fold could name the surviving event and pass over the one
|
|
101
|
+
* the run acted on, which is worse than saying nothing.
|
|
102
|
+
*/
|
|
103
|
+
function hasKnowableLogOrder(orderedById: readonly Event[]): boolean {
|
|
104
|
+
if (orderedById.every((event) => isSlotEventId(event.eventId))) return true;
|
|
105
|
+
|
|
106
|
+
for (let index = 1; index < orderedById.length; index++) {
|
|
107
|
+
const previous = createdAtMs(orderedById[index - 1]);
|
|
108
|
+
const current = createdAtMs(orderedById[index]);
|
|
109
|
+
// A missing or unparseable timestamp leaves nothing to corroborate the ID
|
|
110
|
+
// order with, which is the same position as a contradiction.
|
|
111
|
+
if (Number.isNaN(previous) || Number.isNaN(current)) return false;
|
|
112
|
+
if (current < previous) return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The fold itself, over a log whose order is known. */
|
|
119
|
+
function foldDuplicates(ordered: readonly Event[]): Set<string> {
|
|
120
|
+
const duplicates = new Set<string>();
|
|
121
|
+
const seenClasses = new Set<string>();
|
|
122
|
+
const closedEntities = new Set<string>();
|
|
123
|
+
|
|
124
|
+
for (const event of ordered) {
|
|
125
|
+
const eventClass = entityEventClass(event.eventType);
|
|
126
|
+
if (eventClass === undefined) continue;
|
|
127
|
+
|
|
128
|
+
const entity = event.correlationId ?? RUN_ENTITY_KEY;
|
|
129
|
+
const classKey = `${eventClass}:${entity}`;
|
|
130
|
+
const repeatsClass = seenClasses.has(classKey);
|
|
131
|
+
const entityWasClosed = closedEntities.has(entity);
|
|
132
|
+
|
|
133
|
+
if (TERMINAL_EVENT_CLASSES.has(eventClass)) {
|
|
134
|
+
closedEntities.add(entity);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!repeatsClass) {
|
|
138
|
+
// First of its class, but the entity already finished: no consumer is
|
|
139
|
+
// left to take it and it repeats nothing, so the runtime reports
|
|
140
|
+
// divergence here and exits. Everything past this point went unread, so
|
|
141
|
+
// the fold stops with it rather than recording the class and presenting
|
|
142
|
+
// a later event of it as a repeat the run passed over.
|
|
143
|
+
if (entityWasClosed) break;
|
|
144
|
+
seenClasses.add(classKey);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The entity is still open, so a consumer is registered for it and takes
|
|
149
|
+
// this event: another attempt, not a repeat read past.
|
|
150
|
+
if (entityWasClosed || SINGLETON_EVENT_CLASSES.has(eventClass)) {
|
|
151
|
+
duplicates.add(event.eventId);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return duplicates;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The IDs of the events in `events` that repeat a class the log already
|
|
160
|
+
* records for the same entity, after that entity finished.
|
|
161
|
+
*
|
|
162
|
+
* `isCompleteHistory` must be false whenever the caller holds a subset of the
|
|
163
|
+
* run's log: one page of a paginated list, or the result of a search. Which
|
|
164
|
+
* occurrence of a class came first is a property of the whole log, so on a
|
|
165
|
+
* subset the earlier event may simply be missing, and the fold would report
|
|
166
|
+
* the surviving one. Nothing is classified in that case.
|
|
167
|
+
*
|
|
168
|
+
* Two other things make the answer unknowable and yield the same empty result:
|
|
169
|
+
* a log whose order cannot be recovered from the events (see
|
|
170
|
+
* {@link hasKnowableLogOrder}), and everything past the point the run
|
|
171
|
+
* diverged, since the run exited there and read no further.
|
|
172
|
+
*/
|
|
173
|
+
export function findDuplicateEventIds(
|
|
174
|
+
events: readonly Event[],
|
|
175
|
+
{ isCompleteHistory }: { isCompleteHistory: boolean }
|
|
176
|
+
): Set<string> {
|
|
177
|
+
if (!isCompleteHistory || events.length < 2) return new Set();
|
|
178
|
+
|
|
179
|
+
// Dropped before the sort, not during the fold: an event with no ID has no
|
|
180
|
+
// log position to order on, and the caller could not match it either.
|
|
181
|
+
const ordered = events
|
|
182
|
+
.filter((event) => Boolean(event.eventId))
|
|
183
|
+
.sort(compareEventId);
|
|
184
|
+
|
|
185
|
+
if (!hasKnowableLogOrder(ordered)) return new Set();
|
|
186
|
+
|
|
187
|
+
return foldDuplicates(ordered);
|
|
188
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Event, EventType } from '@workflow/world';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { materializeSteps } from './event-materialization';
|
|
4
|
+
|
|
5
|
+
const BASE_TIME = Date.parse('2026-01-01T00:00:00.000Z');
|
|
6
|
+
|
|
7
|
+
let nextId = 0;
|
|
8
|
+
|
|
9
|
+
function event(
|
|
10
|
+
eventType: EventType,
|
|
11
|
+
options: { correlationId?: string; at?: number } = {}
|
|
12
|
+
): Event {
|
|
13
|
+
nextId += 1;
|
|
14
|
+
const offsetSeconds = options.at ?? nextId;
|
|
15
|
+
return {
|
|
16
|
+
eventId: `evt_${nextId}`,
|
|
17
|
+
runId: 'run_1',
|
|
18
|
+
eventType,
|
|
19
|
+
correlationId: options.correlationId,
|
|
20
|
+
createdAt: new Date(BASE_TIME + offsetSeconds * 1000),
|
|
21
|
+
occurredAt: new Date(BASE_TIME + offsetSeconds * 1000),
|
|
22
|
+
eventData: eventType === 'step_created' ? { stepName: 'doWork' } : {},
|
|
23
|
+
} as unknown as Event;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('materializeSteps', () => {
|
|
27
|
+
it('derives status and timings from the run of a well-formed step', () => {
|
|
28
|
+
const events = [
|
|
29
|
+
event('step_created', { correlationId: 'step_a', at: 1 }),
|
|
30
|
+
event('step_started', { correlationId: 'step_a', at: 2 }),
|
|
31
|
+
event('step_completed', { correlationId: 'step_a', at: 5 }),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const [step] = materializeSteps(events);
|
|
35
|
+
|
|
36
|
+
expect(step.status).toBe('completed');
|
|
37
|
+
expect(step.attempt).toBe(1);
|
|
38
|
+
expect(step.startedAt?.getTime()).toBe(BASE_TIME + 2000);
|
|
39
|
+
expect(step.completedAt?.getTime()).toBe(BASE_TIME + 5000);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('counts one attempt per start across retries', () => {
|
|
43
|
+
const events = [
|
|
44
|
+
event('step_created', { correlationId: 'step_a', at: 1 }),
|
|
45
|
+
event('step_started', { correlationId: 'step_a', at: 2 }),
|
|
46
|
+
event('step_retrying', { correlationId: 'step_a', at: 3 }),
|
|
47
|
+
event('step_started', { correlationId: 'step_a', at: 4 }),
|
|
48
|
+
event('step_completed', { correlationId: 'step_a', at: 6 }),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const [step] = materializeSteps(events);
|
|
52
|
+
|
|
53
|
+
expect(step.attempt).toBe(2);
|
|
54
|
+
expect(step.status).toBe('completed');
|
|
55
|
+
// The first start is when the step went from queued to running.
|
|
56
|
+
expect(step.startedAt?.getTime()).toBe(BASE_TIME + 2000);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps the outcome the run acted on when a replay writes another one', () => {
|
|
60
|
+
const events = [
|
|
61
|
+
event('step_created', { correlationId: 'step_a', at: 1 }),
|
|
62
|
+
event('step_started', { correlationId: 'step_a', at: 2 }),
|
|
63
|
+
event('step_failed', { correlationId: 'step_a', at: 3 }),
|
|
64
|
+
// Written by a concurrent replay working from a stale prefix.
|
|
65
|
+
event('step_completed', { correlationId: 'step_a', at: 9 }),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const [step] = materializeSteps(events, { isCompleteHistory: true });
|
|
69
|
+
|
|
70
|
+
expect(step.status).toBe('failed');
|
|
71
|
+
expect(step.completedAt?.getTime()).toBe(BASE_TIME + 3000);
|
|
72
|
+
expect(step.updatedAt.getTime()).toBe(BASE_TIME + 3000);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('still lists the passed-over event on the entity', () => {
|
|
76
|
+
const events = [
|
|
77
|
+
event('step_created', { correlationId: 'step_a', at: 1 }),
|
|
78
|
+
event('step_started', { correlationId: 'step_a', at: 2 }),
|
|
79
|
+
event('step_failed', { correlationId: 'step_a', at: 3 }),
|
|
80
|
+
event('step_completed', { correlationId: 'step_a', at: 9 }),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
const [step] = materializeSteps(events, { isCompleteHistory: true });
|
|
84
|
+
|
|
85
|
+
expect(step.events).toHaveLength(4);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('takes the last outcome when the log may be incomplete', () => {
|
|
89
|
+
const events = [
|
|
90
|
+
event('step_created', { correlationId: 'step_a', at: 1 }),
|
|
91
|
+
event('step_started', { correlationId: 'step_a', at: 2 }),
|
|
92
|
+
event('step_failed', { correlationId: 'step_a', at: 3 }),
|
|
93
|
+
event('step_completed', { correlationId: 'step_a', at: 9 }),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
// On a page of the log there is no telling which failure the run acted on,
|
|
97
|
+
// so nothing is passed over and the fold reports what it was given.
|
|
98
|
+
const [step] = materializeSteps(events);
|
|
99
|
+
|
|
100
|
+
expect(step.status).toBe('completed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('counts a repeated creation as one attempt while the step is open', () => {
|
|
104
|
+
const events = [
|
|
105
|
+
event('step_created', { correlationId: 'step_a', at: 1 }),
|
|
106
|
+
// A live step consumer claims this, so it is an attempt, not a repeat.
|
|
107
|
+
event('step_created', { correlationId: 'step_a', at: 2 }),
|
|
108
|
+
event('step_started', { correlationId: 'step_a', at: 3 }),
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
const [step] = materializeSteps(events, { isCompleteHistory: true });
|
|
112
|
+
|
|
113
|
+
expect(step.events).toHaveLength(3);
|
|
114
|
+
expect(step.status).toBe('running');
|
|
115
|
+
expect(step.attempt).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
});
|