@reventlessdev/reventless-core 3.0.0-alpha.137 → 3.0.0-alpha.139
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/CHANGELOG.md +14 -0
- package/package.json +5 -5
- package/src/components/Aggregate/Aggregate_Callback.res +158 -12
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +131 -9
- package/src/components/DcbEventLog/DcbEventLog_Adapter.res +1 -1
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +10 -5
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +24 -14
- package/tests/aggregate/AggregateSnapshotTest.res +357 -0
- package/tests/aggregate/AggregateSnapshotTest.res.mjs +645 -0
- package/tests/dcb/DcbFixtures.res +2 -2
- package/tests/dcb/DcbFixtures.res.mjs +2 -2
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Step 5 of docs/plans/aggregate-snapshotting.md — the Aggregate_Callback
|
|
2
|
+
// snapshot wiring: a snapshot-enabled behavior seeds cold replays from the
|
|
3
|
+
// latest persisted snapshot (hash-gated) and writes a fresh one every
|
|
4
|
+
// `interval` events, fire-and-forget. Snapshots never affect correctness — the
|
|
5
|
+
// OCC append is the only consistency primitive — so a drifted/absent snapshot
|
|
6
|
+
// degrades to full replay.
|
|
7
|
+
//
|
|
8
|
+
// The mock EventLog enforces OCC (append conflicts unless seqNr matches the
|
|
9
|
+
// stored count) AND records snapshot reads/writes and replay `fromSeq` offsets,
|
|
10
|
+
// so the tests can assert both the delta-read behavior and the "disabled ⇒ zero
|
|
11
|
+
// snapshot traffic" requirement.
|
|
12
|
+
|
|
13
|
+
open JestGlobals
|
|
14
|
+
|
|
15
|
+
S.enableJson()
|
|
16
|
+
|
|
17
|
+
module AggSpec = {
|
|
18
|
+
module Id = Reventless.Id.StringPure
|
|
19
|
+
let name = "SnapshotTestAggregate"
|
|
20
|
+
|
|
21
|
+
@schema
|
|
22
|
+
type command = Add({item: string}) | Noop
|
|
23
|
+
|
|
24
|
+
@schema
|
|
25
|
+
type event = Added({item: string})
|
|
26
|
+
|
|
27
|
+
@schema
|
|
28
|
+
type error = | Never
|
|
29
|
+
|
|
30
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module TestBehavior = {
|
|
34
|
+
module Spec = AggSpec
|
|
35
|
+
|
|
36
|
+
@schema
|
|
37
|
+
type state = {items: array<string>}
|
|
38
|
+
|
|
39
|
+
let initialState = {items: []}
|
|
40
|
+
|
|
41
|
+
// Snapshot every 10 events.
|
|
42
|
+
let snapshot = Some({
|
|
43
|
+
Reventless.Snapshot.interval: 10,
|
|
44
|
+
stateSchema,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
48
|
+
|
|
49
|
+
let evolve = (state: state, event: AggSpec.event): state =>
|
|
50
|
+
switch event {
|
|
51
|
+
| Added({item}) => {items: state.items->Array.concat([item])}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let decide = (_state: state, command: AggSpec.command): result<
|
|
55
|
+
array<AggSpec.event>,
|
|
56
|
+
AggSpec.error,
|
|
57
|
+
> =>
|
|
58
|
+
switch command {
|
|
59
|
+
| Add({item}) => Ok([AggSpec.Added({item: item})])
|
|
60
|
+
| Noop => Ok([])
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let testMeta: Message.meta = {
|
|
65
|
+
service: "SnapshotTestAggregate",
|
|
66
|
+
time: "2024-01-01T00:00:00Z",
|
|
67
|
+
ip: "127.0.0.1",
|
|
68
|
+
user: "test-user",
|
|
69
|
+
msgId: "msg-1",
|
|
70
|
+
correlationId: "corr-1",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type mockEL = {
|
|
74
|
+
appendFn: (
|
|
75
|
+
int,
|
|
76
|
+
string,
|
|
77
|
+
array<Message.event'<string, AggSpec.event>>,
|
|
78
|
+
) => promise<result<unit, EventLog.appendError>>,
|
|
79
|
+
replayStreamFn: (string, ~fromSeq: int=?) => Stream.t<AggSpec.event, string, unit>,
|
|
80
|
+
latestSnapshotFn: string => promise<result<option<EventLog.snapshot>, string>>,
|
|
81
|
+
writeSnapshotFn: (string, EventLog.snapshot) => promise<result<unit, string>>,
|
|
82
|
+
getAll: unit => array<Message.event'<string, AggSpec.event>>,
|
|
83
|
+
getSnapshot: string => option<EventLog.snapshot>,
|
|
84
|
+
setSnapshot: (string, EventLog.snapshot) => unit,
|
|
85
|
+
replayCallCount: ref<int>,
|
|
86
|
+
lastReplayFromSeq: ref<int>,
|
|
87
|
+
snapshotReadCount: ref<int>,
|
|
88
|
+
snapshotWriteCount: ref<int>,
|
|
89
|
+
failNextWrite: ref<bool>,
|
|
90
|
+
reset: unit => unit,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let makeMockEL = (): mockEL => {
|
|
94
|
+
let stored: ref<array<Message.event'<string, AggSpec.event>>> = ref([])
|
|
95
|
+
let snapshots: dict<EventLog.snapshot> = Dict.make()
|
|
96
|
+
let replayCount = ref(0)
|
|
97
|
+
let lastFromSeq = ref(-1)
|
|
98
|
+
let snapReads = ref(0)
|
|
99
|
+
let snapWrites = ref(0)
|
|
100
|
+
let failWrite = ref(false)
|
|
101
|
+
|
|
102
|
+
let eventsFor = id => stored.contents->Array.filter(e => e.id == id)
|
|
103
|
+
|
|
104
|
+
let appendFn = async (seqNr, id, newEvents) =>
|
|
105
|
+
if seqNr != eventsFor(id)->Array.length {
|
|
106
|
+
Error(EventLog.Conflict)
|
|
107
|
+
} else {
|
|
108
|
+
stored := stored.contents->Array.concat(newEvents)
|
|
109
|
+
Ok()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let replayStreamFn = (id, ~fromSeq=0) => {
|
|
113
|
+
replayCount := replayCount.contents + 1
|
|
114
|
+
lastFromSeq := fromSeq
|
|
115
|
+
eventsFor(id)
|
|
116
|
+
->Array.map(e => e.event)
|
|
117
|
+
->Array.filterWithIndex((_, i) => i >= fromSeq)
|
|
118
|
+
->Stream.fromIterable
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let latestSnapshotFn = async id => {
|
|
122
|
+
snapReads := snapReads.contents + 1
|
|
123
|
+
Ok(snapshots->Dict.get(id))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let writeSnapshotFn = async (id, snap) =>
|
|
127
|
+
if failWrite.contents {
|
|
128
|
+
failWrite := false
|
|
129
|
+
Error("mock write failure")
|
|
130
|
+
} else {
|
|
131
|
+
snapWrites := snapWrites.contents + 1
|
|
132
|
+
snapshots->Dict.set(id, snap)
|
|
133
|
+
Ok()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
appendFn,
|
|
138
|
+
replayStreamFn,
|
|
139
|
+
latestSnapshotFn,
|
|
140
|
+
writeSnapshotFn,
|
|
141
|
+
getAll: () => stored.contents,
|
|
142
|
+
getSnapshot: id => snapshots->Dict.get(id),
|
|
143
|
+
setSnapshot: (id, snap) => snapshots->Dict.set(id, snap),
|
|
144
|
+
replayCallCount: replayCount,
|
|
145
|
+
lastReplayFromSeq: lastFromSeq,
|
|
146
|
+
snapshotReadCount: snapReads,
|
|
147
|
+
snapshotWriteCount: snapWrites,
|
|
148
|
+
failNextWrite: failWrite,
|
|
149
|
+
reset: () => {
|
|
150
|
+
stored := []
|
|
151
|
+
snapshots->Dict.forEachWithKey((_, k) => snapshots->Dict.delete(k))
|
|
152
|
+
replayCount := 0
|
|
153
|
+
lastFromSeq := -1
|
|
154
|
+
snapReads := 0
|
|
155
|
+
snapWrites := 0
|
|
156
|
+
failWrite := false
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let mock = makeMockEL()
|
|
162
|
+
|
|
163
|
+
module OuterEventLog = EventLog
|
|
164
|
+
|
|
165
|
+
module MakeOps = (B: {let name: string}) => {
|
|
166
|
+
module Spec = AggSpec
|
|
167
|
+
module EventLog = {
|
|
168
|
+
module Spec = {
|
|
169
|
+
module Id = AggSpec.Id
|
|
170
|
+
let name = B.name
|
|
171
|
+
@schema
|
|
172
|
+
type event = AggSpec.event
|
|
173
|
+
}
|
|
174
|
+
type operations = {
|
|
175
|
+
append: (
|
|
176
|
+
int,
|
|
177
|
+
string,
|
|
178
|
+
array<Message.event'<string, AggSpec.event>>,
|
|
179
|
+
) => promise<result<unit, EventLog.appendError>>,
|
|
180
|
+
replay: string => promise<array<AggSpec.event>>,
|
|
181
|
+
replayStream: (string, ~fromSeq: int=?) => Stream.t<AggSpec.event, string, unit>,
|
|
182
|
+
appendStream: (int, string, Stream.t<AggSpec.event, string, unit>) => Effect.t<
|
|
183
|
+
unit,
|
|
184
|
+
string,
|
|
185
|
+
unit,
|
|
186
|
+
>,
|
|
187
|
+
latestSnapshot: string => promise<result<option<EventLog.snapshot>, string>>,
|
|
188
|
+
writeSnapshot: (string, EventLog.snapshot) => promise<result<unit, string>>,
|
|
189
|
+
}
|
|
190
|
+
type component = Component.t<OuterEventLog.t, OuterEventLog.outputs, operations>
|
|
191
|
+
let make = (~name as _: string, ~opts as _=?): component => Obj.magic(0)
|
|
192
|
+
}
|
|
193
|
+
let eventLog: EventLog.operations = {
|
|
194
|
+
append: mock.appendFn,
|
|
195
|
+
replay: async id => await mock.replayStreamFn(id)->Stream.runCollect->Effect.runPromise,
|
|
196
|
+
replayStream: mock.replayStreamFn,
|
|
197
|
+
appendStream: (_startingSeqNr, _id, stream) => stream->Stream.runDrain,
|
|
198
|
+
latestSnapshot: mock.latestSnapshotFn,
|
|
199
|
+
writeSnapshot: mock.writeSnapshotFn,
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
module TestOps = MakeOps({let name = "SnapshotMockEventLog"})
|
|
204
|
+
module TestHandler = Aggregate_Callback.Make(AggSpec, TestBehavior, TestOps)
|
|
205
|
+
|
|
206
|
+
// A second behavior with snapshots DISABLED, sharing the same mock, to prove
|
|
207
|
+
// disabled aggregates do zero snapshot traffic.
|
|
208
|
+
module PlainBehavior = {
|
|
209
|
+
module Spec = AggSpec
|
|
210
|
+
|
|
211
|
+
@schema
|
|
212
|
+
type state = {items: array<string>}
|
|
213
|
+
|
|
214
|
+
let initialState = {items: []}
|
|
215
|
+
let snapshot = None
|
|
216
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
217
|
+
|
|
218
|
+
let evolve = (state: state, event: AggSpec.event): state =>
|
|
219
|
+
switch event {
|
|
220
|
+
| Added({item}) => {items: state.items->Array.concat([item])}
|
|
221
|
+
}
|
|
222
|
+
let decide = (_state, command: AggSpec.command) =>
|
|
223
|
+
switch command {
|
|
224
|
+
| Add({item}) => Ok([AggSpec.Added({item: item})])
|
|
225
|
+
| Noop => Ok([])
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module PlainOps = MakeOps({let name = "PlainMockEventLog"})
|
|
230
|
+
module PlainHandler = Aggregate_Callback.Make(AggSpec, PlainBehavior, PlainOps)
|
|
231
|
+
|
|
232
|
+
let makeItem = (~id="agg-1", reference, command): CommandTopic.topicItem<
|
|
233
|
+
Message.command'<string, AggSpec.command>,
|
|
234
|
+
> => {
|
|
235
|
+
command: {id, meta: testMeta, command},
|
|
236
|
+
reference,
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let run = async (~handler, ~id="agg-1", command) =>
|
|
240
|
+
await Stream.fromIterable([makeItem(~id, "ref", command)])
|
|
241
|
+
->handler
|
|
242
|
+
->Effect.runPromise
|
|
243
|
+
|
|
244
|
+
// A microtask tick so fire-and-forget snapshot writes settle before assertions.
|
|
245
|
+
let tick = () => Promise.resolve()->Promise.then(_ => Promise.resolve())
|
|
246
|
+
|
|
247
|
+
let _ = beforeEach(() => {
|
|
248
|
+
mock.reset()
|
|
249
|
+
TestHandler.resetCache()
|
|
250
|
+
PlainHandler.resetCache()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
describe("Aggregate_Callback — snapshots:", () => {
|
|
254
|
+
testPromise("writes a snapshot each time an interval boundary is crossed", async () => {
|
|
255
|
+
// 25 single-event commands → boundaries at 10 and 20 (not 25).
|
|
256
|
+
for i in 1 to 25 {
|
|
257
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Add({item: `i${i->Int.toString}`}))
|
|
258
|
+
}
|
|
259
|
+
let _ = await tick()
|
|
260
|
+
expect(mock.snapshotWriteCount.contents)->toBe(2)
|
|
261
|
+
// Keep-one: the surviving snapshot is the latest boundary (seq 20).
|
|
262
|
+
switch mock.getSnapshot("agg-1") {
|
|
263
|
+
| Some(snap) => expect(snap.seqNr)->toBe(20)
|
|
264
|
+
| None => expect("expected a snapshot")->toBe("none")
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
testPromise("cold replay seeds from the snapshot and reads only the delta", async () => {
|
|
269
|
+
for i in 1 to 12 {
|
|
270
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Add({item: `i${i->Int.toString}`}))
|
|
271
|
+
}
|
|
272
|
+
let _ = await tick()
|
|
273
|
+
// A snapshot exists at seq 10. Evict the in-process cache to force a cold read.
|
|
274
|
+
TestHandler.resetCache()
|
|
275
|
+
mock.replayCallCount := 0
|
|
276
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Add({item: "i13"}))
|
|
277
|
+
// Cold read consulted the snapshot and replayed only events 10..12 (delta).
|
|
278
|
+
expect(mock.snapshotReadCount.contents >= 1)->toBe(true)
|
|
279
|
+
expect(mock.lastReplayFromSeq.contents)->toBe(10)
|
|
280
|
+
// Correct final state: 13 events appended in total.
|
|
281
|
+
expect(mock.getAll()->Array.length)->toBe(13)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
testPromise("state seeded from snapshot equals a full-replay fold", async () => {
|
|
285
|
+
for i in 1 to 15 {
|
|
286
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Add({item: `x${i->Int.toString}`}))
|
|
287
|
+
}
|
|
288
|
+
let _ = await tick()
|
|
289
|
+
// Snapshot-seeded cold read (snapshot at seq 10, delta 10..15).
|
|
290
|
+
TestHandler.resetCache()
|
|
291
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Noop)
|
|
292
|
+
let seededSnap = mock.getSnapshot("agg-1")
|
|
293
|
+
// Force a boundary to capture the seeded state as a fresh snapshot: add up to
|
|
294
|
+
// seq 20 and compare its item count to the true event count.
|
|
295
|
+
for i in 16 to 20 {
|
|
296
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Add({item: `x${i->Int.toString}`}))
|
|
297
|
+
}
|
|
298
|
+
let _ = await tick()
|
|
299
|
+
switch mock.getSnapshot("agg-1") {
|
|
300
|
+
| Some(snap) =>
|
|
301
|
+
expect(snap.seqNr)->toBe(20)
|
|
302
|
+
// 20 items folded through the seeded state — no double-count, no gap.
|
|
303
|
+
let decoded = snap.state->S.parseJsonOrThrow(TestBehavior.stateSchema)
|
|
304
|
+
expect(decoded.items->Array.length)->toBe(20)
|
|
305
|
+
| None => expect("expected snapshot at 20")->toBe("none")
|
|
306
|
+
}
|
|
307
|
+
// sanity: the earlier seeded snapshot was the seq-10 one
|
|
308
|
+
switch seededSnap {
|
|
309
|
+
| Some(s) => expect(s.seqNr)->toBe(10)
|
|
310
|
+
| None => expect("expected seeded snapshot")->toBe("none")
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
testPromise("schema-drift snapshot is ignored → full replay from 0", async () => {
|
|
315
|
+
for i in 1 to 12 {
|
|
316
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Add({item: `i${i->Int.toString}`}))
|
|
317
|
+
}
|
|
318
|
+
let _ = await tick()
|
|
319
|
+
// Corrupt the stored snapshot's hash to simulate a state-shape change.
|
|
320
|
+
switch mock.getSnapshot("agg-1") {
|
|
321
|
+
| Some(snap) => mock.setSnapshot("agg-1", {...snap, schemaHash: "STALE-HASH"})
|
|
322
|
+
| None => ()
|
|
323
|
+
}
|
|
324
|
+
TestHandler.resetCache()
|
|
325
|
+
mock.replayCallCount := 0
|
|
326
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Noop)
|
|
327
|
+
// Drifted hash → seed discarded → full replay from seq 0.
|
|
328
|
+
expect(mock.lastReplayFromSeq.contents)->toBe(0)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
testPromise("snapshot write failure does not fail the command", async () => {
|
|
332
|
+
for i in 1 to 9 {
|
|
333
|
+
let _ = await run(~handler=TestHandler.handleCommands, AggSpec.Add({item: `i${i->Int.toString}`}))
|
|
334
|
+
}
|
|
335
|
+
// The 10th append crosses the boundary; make its snapshot write fail.
|
|
336
|
+
mock.failNextWrite := true
|
|
337
|
+
let result = await run(~handler=TestHandler.handleCommands, AggSpec.Add({item: "i10"}))
|
|
338
|
+
let _ = await tick()
|
|
339
|
+
// Command still succeeds; the event is persisted.
|
|
340
|
+
expect(result)->toEqual([Ok("ref")])
|
|
341
|
+
expect(mock.getAll()->Array.length)->toBe(10)
|
|
342
|
+
// The failed write left no snapshot behind.
|
|
343
|
+
expect(mock.getSnapshot("agg-1"))->toEqual(None)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
testPromise("disabled aggregate does zero snapshot reads and writes", async () => {
|
|
347
|
+
for i in 1 to 12 {
|
|
348
|
+
let _ = await run(~handler=PlainHandler.handleCommands, AggSpec.Add({item: `i${i->Int.toString}`}))
|
|
349
|
+
}
|
|
350
|
+
let _ = await tick()
|
|
351
|
+
PlainHandler.resetCache()
|
|
352
|
+
let _ = await run(~handler=PlainHandler.handleCommands, AggSpec.Noop)
|
|
353
|
+
expect((mock.snapshotReadCount.contents, mock.snapshotWriteCount.contents))->toEqual((0, 0))
|
|
354
|
+
// And a cold read always replays from 0 (no snapshot seeding).
|
|
355
|
+
expect(mock.lastReplayFromSeq.contents)->toBe(0)
|
|
356
|
+
})
|
|
357
|
+
})
|