@reventlessdev/reventless-core 3.0.0-alpha.136 → 3.0.0-alpha.138
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 +19 -0
- package/package.json +7 -7
- package/src/Message.res +43 -10
- package/src/Message.res.mjs +21 -4
- package/src/Projection.res +65 -46
- package/src/Projection.res.mjs +53 -49
- package/src/components/Aggregate/Aggregate_Callback.res +207 -6
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +160 -10
- package/src/components/DcbEventLog/DcbEventLog_Operations.res +3 -0
- package/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs +3 -1
- package/src/components/EventLog/EventLog.res +20 -1
- package/src/components/EventLog/EventLog_Adapter.res +3 -1
- package/src/components/EventLog/EventLog_Builder.res +4 -0
- package/src/components/EventLog/EventLog_Builder.res.mjs +3 -1
- package/src/components/EventLog/EventLog_Operations.res +11 -2
- package/src/components/EventLog/EventLog_Operations.res.mjs +6 -2
- package/src/components/EventLog/EventPublish_Callback.res +7 -0
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +26 -6
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +21 -6
- package/src/plugin/lifecycle/PluginBehavior.res +6 -0
- package/src/plugin/lifecycle/PluginBehavior.res.mjs +3 -0
- package/src/util/Logger.res +10 -0
- package/src/util/Logger.res.mjs +12 -2
- package/tests/aggregate/AggregateCacheTest.res +271 -0
- package/tests/aggregate/AggregateCacheTest.res.mjs +433 -0
- package/tests/aggregate/AggregateCallbackTest.res +4 -1
- package/tests/aggregate/AggregateCallbackTest.res.mjs +4 -1
- package/tests/aggregate/AggregateCausationTest.res +4 -1
- package/tests/aggregate/AggregateCausationTest.res.mjs +4 -1
- package/tests/aggregate/AggregateConflictTest.res +14 -3
- package/tests/aggregate/AggregateConflictTest.res.mjs +26 -3
- package/tests/aggregate/AggregateFixtures.res +10 -2
- package/tests/aggregate/AggregateFixtures.res.mjs +22 -2
- package/tests/aggregate/AggregateRejectionTest.res +4 -1
- package/tests/aggregate/AggregateRejectionTest.res.mjs +4 -1
- package/tests/aggregate/AggregateSnapshotTest.res +357 -0
- package/tests/aggregate/AggregateSnapshotTest.res.mjs +645 -0
- package/tests/eventlog/EventLogFixtures.res +13 -2
- package/tests/eventlog/EventLogFixtures.res.mjs +27 -3
- package/tests/logger/LoggerLazyTest.res +36 -0
- package/tests/logger/LoggerLazyTest.res.mjs +40 -0
|
@@ -4,6 +4,13 @@ type publishedEvent = {
|
|
|
4
4
|
eventCount: int,
|
|
5
5
|
eventsJson: array<JSON.t>,
|
|
6
6
|
meta: Reventless.Message.meta,
|
|
7
|
+
// Per-event metas, parallel to eventsJson. Populated by publishers whose
|
|
8
|
+
// eventsJson entries do NOT carry the meta fields inline (DcbEventLog: bare
|
|
9
|
+
// {TAG, ...data} payloads) so hook consumers can still correlate per event
|
|
10
|
+
// (e.g. the local projection checkpoint resolves pending appends by msgId).
|
|
11
|
+
// Aggregate EventLog eventsJson are flat stored events with meta at the top
|
|
12
|
+
// level, so it omits this field.
|
|
13
|
+
metas?: array<Reventless.Message.meta>,
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
type beforePublishHook = publishedEvent => promise<publishedEvent>
|
|
@@ -199,13 +199,33 @@ module Make = (
|
|
|
199
199
|
Some(Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec))
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
// Fail closed: a query that can't be serialized yields `None`, not the empty
|
|
203
|
+
// string. The old `""` fallback made every unserializable query collide on
|
|
204
|
+
// one shared cache slot, so one slice's folded state could seed another's —
|
|
205
|
+
// a silent correctness bug. `None` simply bypasses the cache (cold read, no
|
|
206
|
+
// write) for that operation.
|
|
207
|
+
let cacheKey = query->JSON.stringifyAny
|
|
208
|
+
let cacheGet = () =>
|
|
209
|
+
switch cacheKey {
|
|
210
|
+
| Some(k) => Lru.get(projectionCache, k)
|
|
211
|
+
| None => None
|
|
212
|
+
}
|
|
213
|
+
let cachePut = v =>
|
|
214
|
+
switch cacheKey {
|
|
215
|
+
| Some(k) => Lru.put(projectionCache, k, v)
|
|
216
|
+
| None => ()
|
|
217
|
+
}
|
|
218
|
+
let cacheInvalidate = () =>
|
|
219
|
+
switch cacheKey {
|
|
220
|
+
| Some(k) => Lru.invalidate(projectionCache, k)
|
|
221
|
+
| None => ()
|
|
222
|
+
}
|
|
203
223
|
|
|
204
224
|
// Working seed across retries: the `(state, head)` the read folds from.
|
|
205
225
|
// Starts from the projection cache (warm) or empty (cold); the conflict
|
|
206
226
|
// branch re-seeds it with the just-read `(state, head)` so each retry reads
|
|
207
227
|
// only the delta the conflicting writer added rather than full history.
|
|
208
|
-
let seed = ref(
|
|
228
|
+
let seed = ref(cacheGet())
|
|
209
229
|
|
|
210
230
|
let rec attempt = (~retries) => {
|
|
211
231
|
let (baseState, afterPos) = switch seed.contents {
|
|
@@ -275,7 +295,7 @@ module Make = (
|
|
|
275
295
|
switch Behavior.decide(state, command'.command) {
|
|
276
296
|
| Ok(newEvents) if newEvents->Array.length == 0 =>
|
|
277
297
|
// No append, but the read snapshot is valid — cache it for the next command.
|
|
278
|
-
|
|
298
|
+
cachePut((state, headPosition))
|
|
279
299
|
CommandTopic_Helpers.reportAccepted(
|
|
280
300
|
cmdJson.meta.msgId,
|
|
281
301
|
switch entityId {
|
|
@@ -326,7 +346,7 @@ module Make = (
|
|
|
326
346
|
// Cache the decided-on state at the read head (NOT including the
|
|
327
347
|
// events we just appended, and NOT the returned position): the
|
|
328
348
|
// next command's delta read picks our events up from `headPosition`.
|
|
329
|
-
|
|
349
|
+
cachePut((state, headPosition))
|
|
330
350
|
CommandTopic_Helpers.reportAccepted(
|
|
331
351
|
cmdJson.meta.msgId,
|
|
332
352
|
switch entityId {
|
|
@@ -354,7 +374,7 @@ module Make = (
|
|
|
354
374
|
} else {
|
|
355
375
|
// Retries exhausted — drop any cached snapshot so the next
|
|
356
376
|
// command for this entity takes the cold full-read path.
|
|
357
|
-
|
|
377
|
+
cacheInvalidate()
|
|
358
378
|
let errorCode = err->String.startsWith("Conflict") ? "Conflict" : "AppendFailed"
|
|
359
379
|
// A surfaced Conflict means the 3-retry loop could not absorb
|
|
360
380
|
// the contention — the operator signal for "this slice is hot"
|
|
@@ -375,7 +395,7 @@ module Make = (
|
|
|
375
395
|
)
|
|
376
396
|
| Error(error) =>
|
|
377
397
|
// Business-rule rejection — no append, but the read snapshot is valid; cache it.
|
|
378
|
-
|
|
398
|
+
cachePut((state, headPosition))
|
|
379
399
|
let errorJson = error->S.reverseConvertToJsonOrThrow(Spec.errorSchema)
|
|
380
400
|
let errorCode = errorJson->Message.variantNameOfJson
|
|
381
401
|
let (_, payloadDict) = errorJson->Message.splitMessage
|
|
@@ -82,9 +82,24 @@ function Make(Spec) {
|
|
|
82
82
|
let tags = DcbTag$Reventless.extractTags(Spec.commandSchema, command$p.command);
|
|
83
83
|
entityId = DcbTag$Reventless.getCompositePartitionKeyValue(tags, derivedPartitionTag._0);
|
|
84
84
|
}
|
|
85
|
-
let cacheKey =
|
|
85
|
+
let cacheKey = JSON.stringify(query);
|
|
86
|
+
let cacheGet = () => {
|
|
87
|
+
if (cacheKey !== undefined) {
|
|
88
|
+
return Lru$ReventlessCore.get(projectionCache, cacheKey);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
let cachePut = v => {
|
|
92
|
+
if (cacheKey !== undefined) {
|
|
93
|
+
return Lru$ReventlessCore.put(projectionCache, cacheKey, v);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
let cacheInvalidate = () => {
|
|
97
|
+
if (cacheKey !== undefined) {
|
|
98
|
+
return Lru$ReventlessCore.invalidate(projectionCache, cacheKey);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
86
101
|
let seed = {
|
|
87
|
-
contents:
|
|
102
|
+
contents: cacheGet()
|
|
88
103
|
};
|
|
89
104
|
let attempt = retries => {
|
|
90
105
|
let match = seed.contents;
|
|
@@ -150,7 +165,7 @@ function Make(Spec) {
|
|
|
150
165
|
if (newEvents.TAG === "Ok") {
|
|
151
166
|
let newEvents$1 = newEvents._0;
|
|
152
167
|
if (newEvents$1.length === 0) {
|
|
153
|
-
|
|
168
|
+
cachePut([
|
|
154
169
|
state,
|
|
155
170
|
headPosition
|
|
156
171
|
]);
|
|
@@ -204,7 +219,7 @@ function Make(Spec) {
|
|
|
204
219
|
};
|
|
205
220
|
return Effect.flatMap(Effect.flatMap(EffectLogger$ReventlessCore.logInfo(comp, eventJsons, `produced ` + eventCount + ` event(s): [` + eventDetails + `]`), () => Effect.promise(() => dcbEventLog.append(rawEvents, condition))), appendResult => {
|
|
206
221
|
if (appendResult.TAG === "Ok") {
|
|
207
|
-
|
|
222
|
+
cachePut([
|
|
208
223
|
state,
|
|
209
224
|
headPosition
|
|
210
225
|
]);
|
|
@@ -228,7 +243,7 @@ function Make(Spec) {
|
|
|
228
243
|
Metrics$ReventlessCore.emitCount("AppendRetry", Spec.name, undefined);
|
|
229
244
|
return Effect.flatMap(EffectLogger$ReventlessCore.logWarn(comp, undefined, `append failed (retrying ` + ((3 - retries | 0) + 1 | 0).toString() + `/` + (3).toString() + `): ` + err), () => attempt(retries - 1 | 0));
|
|
230
245
|
}
|
|
231
|
-
|
|
246
|
+
cacheInvalidate();
|
|
232
247
|
let errorCode = err.startsWith("Conflict") ? "Conflict" : "AppendFailed";
|
|
233
248
|
if (errorCode === "Conflict") {
|
|
234
249
|
Metrics$ReventlessCore.emitCount("AppendConflict", Spec.name, undefined);
|
|
@@ -243,7 +258,7 @@ function Make(Spec) {
|
|
|
243
258
|
}));
|
|
244
259
|
});
|
|
245
260
|
}
|
|
246
|
-
|
|
261
|
+
cachePut([
|
|
247
262
|
state,
|
|
248
263
|
headPosition
|
|
249
264
|
]);
|
|
@@ -30,6 +30,12 @@ type state = {
|
|
|
30
30
|
|
|
31
31
|
let initialState = {current: None, known: Dict.make()}
|
|
32
32
|
|
|
33
|
+
// The @@reventless.behavior snapshot injection is gated to Aggregate/ folders;
|
|
34
|
+
// this framework-internal behavior lives under plugin/lifecycle, so the
|
|
35
|
+
// Behavior.T field is satisfied manually. The admin Plugin aggregate's history
|
|
36
|
+
// is short (lifecycle transitions), so persisted snapshots would buy nothing.
|
|
37
|
+
let snapshot = None
|
|
38
|
+
|
|
33
39
|
let atomicCounter = None
|
|
34
40
|
|
|
35
41
|
let uiRegisterEvents = (pluginId, manifest) =>
|
|
@@ -366,6 +366,8 @@ function evolve(state, event) {
|
|
|
366
366
|
|
|
367
367
|
let Spec;
|
|
368
368
|
|
|
369
|
+
let snapshot;
|
|
370
|
+
|
|
369
371
|
let atomicCounter;
|
|
370
372
|
|
|
371
373
|
let moduleUrl = "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginBehavior.res.mjs";
|
|
@@ -376,6 +378,7 @@ export {
|
|
|
376
378
|
knownVersionSchema,
|
|
377
379
|
stateSchema,
|
|
378
380
|
initialState,
|
|
381
|
+
snapshot,
|
|
379
382
|
atomicCounter,
|
|
380
383
|
uiRegisterEvents,
|
|
381
384
|
uiDeregisterEvents,
|
package/src/util/Logger.res
CHANGED
|
@@ -37,6 +37,11 @@ type t = {
|
|
|
37
37
|
info: logFn,
|
|
38
38
|
warn: logFn,
|
|
39
39
|
error: logFn,
|
|
40
|
+
// Lazy debug: the message thunk runs only when debug output is actually
|
|
41
|
+
// enabled. On a hot path (e.g. per-projection-action logging) this avoids
|
|
42
|
+
// building — and serializing state into — a string that is then dropped at
|
|
43
|
+
// the default Info level.
|
|
44
|
+
debugLazy: (~comp: string=?, unit => string) => unit,
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
type level = Debug | Info | Warn | Error
|
|
@@ -172,6 +177,10 @@ let makeLogger = (~minLevel=Info): t => {
|
|
|
172
177
|
info: (~comp=?, ~data=?, msg) => log(Info, ~comp?, ~data?, msg),
|
|
173
178
|
warn: (~comp=?, ~data=?, msg) => log(Warn, ~comp?, ~data?, msg),
|
|
174
179
|
error: (~comp=?, ~data=?, msg) => log(Error, ~comp?, ~data?, msg),
|
|
180
|
+
debugLazy: (~comp=?, makeMsg) =>
|
|
181
|
+
if shouldLog(Debug) {
|
|
182
|
+
emit(~level=Debug, ~comp?, makeMsg())
|
|
183
|
+
},
|
|
175
184
|
}
|
|
176
185
|
}
|
|
177
186
|
|
|
@@ -180,6 +189,7 @@ let silent: t = {
|
|
|
180
189
|
info: (~comp as _=?, ~data as _=?, _) => (),
|
|
181
190
|
warn: (~comp as _=?, ~data as _=?, _) => (),
|
|
182
191
|
error: (~comp as _=?, ~data as _=?, _) => (),
|
|
192
|
+
debugLazy: (~comp as _=?, _) => (),
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
// Reads LOG_LEVEL from process.env and returns a configured logger.
|
package/src/util/Logger.res.mjs
CHANGED
|
@@ -156,7 +156,12 @@ function makeLogger(minLevelOpt) {
|
|
|
156
156
|
debug: (comp, data, msg) => log("Debug", comp, data, msg),
|
|
157
157
|
info: (comp, data, msg) => log("Info", comp, data, msg),
|
|
158
158
|
warn: (comp, data, msg) => log("Warn", comp, data, msg),
|
|
159
|
-
error: (comp, data, msg) => log("Error", comp, data, msg)
|
|
159
|
+
error: (comp, data, msg) => log("Error", comp, data, msg),
|
|
160
|
+
debugLazy: (comp, makeMsg) => {
|
|
161
|
+
if (shouldLog("Debug")) {
|
|
162
|
+
return emit("Debug", comp, undefined, undefined, undefined, makeMsg());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
160
165
|
};
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -176,11 +181,16 @@ function silent_error(param, param$1, param$2) {
|
|
|
176
181
|
|
|
177
182
|
}
|
|
178
183
|
|
|
184
|
+
function silent_debugLazy(param, param$1) {
|
|
185
|
+
|
|
186
|
+
}
|
|
187
|
+
|
|
179
188
|
let silent = {
|
|
180
189
|
debug: silent_debug,
|
|
181
190
|
info: silent_info,
|
|
182
191
|
warn: silent_warn,
|
|
183
|
-
error: silent_error
|
|
192
|
+
error: silent_error,
|
|
193
|
+
debugLazy: silent_debugLazy
|
|
184
194
|
};
|
|
185
195
|
|
|
186
196
|
function fromEnv() {
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Tests for the Aggregate_Callback in-process replay cache (Phase 1 of
|
|
2
|
+
// docs/plans/aggregate-snapshotting.md): a warm same-id command skips the
|
|
3
|
+
// event-log replay and decides on the cached (state, sequenceNr); the OCC
|
|
4
|
+
// append fences staleness — a stale cache conflicts, invalidates, and the
|
|
5
|
+
// retry replays cold.
|
|
6
|
+
//
|
|
7
|
+
// Unlike the other aggregate mocks, this one enforces optimistic concurrency
|
|
8
|
+
// (append conflicts unless seqNr matches the stored count), because the cache's
|
|
9
|
+
// correctness argument rests on exactly that fence. The Checkpoint command
|
|
10
|
+
// records the decision state's size as an event, making the cached fold state
|
|
11
|
+
// observable through ordinary domain behavior.
|
|
12
|
+
|
|
13
|
+
open JestGlobals
|
|
14
|
+
|
|
15
|
+
S.enableJson()
|
|
16
|
+
|
|
17
|
+
module AggSpec = {
|
|
18
|
+
module Id = Reventless.Id.StringPure
|
|
19
|
+
let name = "CacheTestAggregate"
|
|
20
|
+
|
|
21
|
+
@schema
|
|
22
|
+
type command =
|
|
23
|
+
| Add({name: string})
|
|
24
|
+
| Checkpoint
|
|
25
|
+
| Noop
|
|
26
|
+
|
|
27
|
+
@schema
|
|
28
|
+
type event =
|
|
29
|
+
| Added({name: string})
|
|
30
|
+
| CheckpointTaken({count: int})
|
|
31
|
+
|
|
32
|
+
@schema
|
|
33
|
+
type error = | Never
|
|
34
|
+
|
|
35
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module TestBehavior = {
|
|
39
|
+
module Spec = AggSpec
|
|
40
|
+
type state = {names: array<string>}
|
|
41
|
+
|
|
42
|
+
let initialState = {names: []}
|
|
43
|
+
|
|
44
|
+
// Hand-written behavior (no @@reventless.behavior PPX) — satisfy the
|
|
45
|
+
// Behavior.T snapshot field manually.
|
|
46
|
+
let snapshot = None
|
|
47
|
+
|
|
48
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
49
|
+
|
|
50
|
+
let evolve = (state: state, event: AggSpec.event): state =>
|
|
51
|
+
switch event {
|
|
52
|
+
| Added({name}) => {names: state.names->Array.concat([name])}
|
|
53
|
+
| CheckpointTaken(_) => state
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let decide = (state: state, command: AggSpec.command): result<
|
|
57
|
+
array<AggSpec.event>,
|
|
58
|
+
AggSpec.error,
|
|
59
|
+
> =>
|
|
60
|
+
switch command {
|
|
61
|
+
| Add({name}) => Ok([AggSpec.Added({name: name})])
|
|
62
|
+
| Checkpoint => Ok([AggSpec.CheckpointTaken({count: state.names->Array.length})])
|
|
63
|
+
| Noop => Ok([])
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let testMeta: Message.meta = {
|
|
68
|
+
service: "CacheTestAggregate",
|
|
69
|
+
time: "2024-01-01T00:00:00Z",
|
|
70
|
+
ip: "127.0.0.1",
|
|
71
|
+
user: "test-user",
|
|
72
|
+
msgId: "msg-1",
|
|
73
|
+
correlationId: "corr-1",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type mockEL = {
|
|
77
|
+
appendFn: (
|
|
78
|
+
int,
|
|
79
|
+
string,
|
|
80
|
+
array<Message.event'<string, AggSpec.event>>,
|
|
81
|
+
) => promise<result<unit, EventLog.appendError>>,
|
|
82
|
+
replayFn: string => promise<array<AggSpec.event>>,
|
|
83
|
+
replayStreamFn: string => Stream.t<AggSpec.event, string, unit>,
|
|
84
|
+
getAll: unit => array<Message.event'<string, AggSpec.event>>,
|
|
85
|
+
// Appends directly to storage, bypassing the handler — simulates a second
|
|
86
|
+
// writer the warm cache doesn't know about.
|
|
87
|
+
injectExternal: (string, AggSpec.event) => unit,
|
|
88
|
+
appendCallCount: ref<int>,
|
|
89
|
+
replayCallCount: ref<int>,
|
|
90
|
+
reset: unit => unit,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let makeMockEL = (): mockEL => {
|
|
94
|
+
let storedRef: ref<array<Message.event'<string, AggSpec.event>>> = ref([])
|
|
95
|
+
let appendCount = ref(0)
|
|
96
|
+
let replayCount = ref(0)
|
|
97
|
+
|
|
98
|
+
let eventsFor = id => storedRef.contents->Array.filter(e => e.id == id)
|
|
99
|
+
|
|
100
|
+
// Real OCC: the append succeeds only when seqNr matches the current count.
|
|
101
|
+
let appendFn = async (seqNr, id, newEvents) => {
|
|
102
|
+
appendCount := appendCount.contents + 1
|
|
103
|
+
if seqNr != eventsFor(id)->Array.length {
|
|
104
|
+
Error(EventLog.Conflict)
|
|
105
|
+
} else {
|
|
106
|
+
storedRef := storedRef.contents->Array.concat(newEvents)
|
|
107
|
+
Ok(())
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let replayFn = async id => {
|
|
112
|
+
replayCount := replayCount.contents + 1
|
|
113
|
+
eventsFor(id)->Array.map(e => e.event)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let replayStreamFn = id => {
|
|
117
|
+
replayCount := replayCount.contents + 1
|
|
118
|
+
eventsFor(id)->Array.map(e => e.event)->Stream.fromIterable
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
appendFn,
|
|
123
|
+
replayFn,
|
|
124
|
+
replayStreamFn,
|
|
125
|
+
getAll: () => storedRef.contents,
|
|
126
|
+
injectExternal: (id, event) =>
|
|
127
|
+
storedRef := storedRef.contents->Array.concat([{Message.id, meta: testMeta, event}]),
|
|
128
|
+
appendCallCount: appendCount,
|
|
129
|
+
replayCallCount: replayCount,
|
|
130
|
+
reset: () => {
|
|
131
|
+
storedRef := []
|
|
132
|
+
appendCount := 0
|
|
133
|
+
replayCount := 0
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let mock = makeMockEL()
|
|
139
|
+
|
|
140
|
+
module OuterEventLog = EventLog
|
|
141
|
+
|
|
142
|
+
module TestOps = {
|
|
143
|
+
module Spec = AggSpec
|
|
144
|
+
module EventLog = {
|
|
145
|
+
module Spec = {
|
|
146
|
+
module Id = AggSpec.Id
|
|
147
|
+
let name = "CacheMockEventLog"
|
|
148
|
+
@schema
|
|
149
|
+
type event = AggSpec.event
|
|
150
|
+
}
|
|
151
|
+
type operations = {
|
|
152
|
+
append: (
|
|
153
|
+
int,
|
|
154
|
+
string,
|
|
155
|
+
array<Message.event'<string, AggSpec.event>>,
|
|
156
|
+
) => promise<result<unit, EventLog.appendError>>,
|
|
157
|
+
replay: string => promise<array<AggSpec.event>>,
|
|
158
|
+
replayStream: (string, ~fromSeq: int=?) => Stream.t<AggSpec.event, string, unit>,
|
|
159
|
+
appendStream: (int, string, Stream.t<AggSpec.event, string, unit>) => Effect.t<
|
|
160
|
+
unit,
|
|
161
|
+
string,
|
|
162
|
+
unit,
|
|
163
|
+
>,
|
|
164
|
+
latestSnapshot: string => promise<result<option<EventLog.snapshot>, string>>,
|
|
165
|
+
writeSnapshot: (string, EventLog.snapshot) => promise<result<unit, string>>,
|
|
166
|
+
}
|
|
167
|
+
type component = Component.t<OuterEventLog.t, OuterEventLog.outputs, operations>
|
|
168
|
+
// Never called — satisfies module type only
|
|
169
|
+
let make = (~name as _: string, ~opts as _=?): component => Obj.magic(0)
|
|
170
|
+
}
|
|
171
|
+
let eventLog: EventLog.operations = {
|
|
172
|
+
append: mock.appendFn,
|
|
173
|
+
replay: mock.replayFn,
|
|
174
|
+
replayStream: (id, ~fromSeq as _=?) => mock.replayStreamFn(id),
|
|
175
|
+
appendStream: (_startingSeqNr, _id, stream) => stream->Stream.runDrain,
|
|
176
|
+
latestSnapshot: async _ => Ok(None),
|
|
177
|
+
writeSnapshot: async (_, _) => Ok(),
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module TestHandler = Aggregate_Callback.Make(AggSpec, TestBehavior, TestOps)
|
|
182
|
+
|
|
183
|
+
let makeTopicItem = (reference, command): CommandTopic.topicItem<
|
|
184
|
+
Message.command'<string, AggSpec.command>,
|
|
185
|
+
> => {
|
|
186
|
+
command: {
|
|
187
|
+
id: "agg-1",
|
|
188
|
+
meta: testMeta,
|
|
189
|
+
command,
|
|
190
|
+
},
|
|
191
|
+
reference,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let run = async command =>
|
|
195
|
+
await Stream.fromIterable([makeTopicItem("ref", command)])
|
|
196
|
+
->TestHandler.handleCommands
|
|
197
|
+
->Effect.runPromise
|
|
198
|
+
|
|
199
|
+
let _ = beforeEach(() => {
|
|
200
|
+
mock.reset()
|
|
201
|
+
TestHandler.resetCache()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe("Aggregate_Callback — replay cache:", () => {
|
|
205
|
+
testPromise("warm same-id command skips the replay", async () => {
|
|
206
|
+
let r1 = await run(AggSpec.Add({name: "a"}))
|
|
207
|
+
let r2 = await run(AggSpec.Add({name: "b"}))
|
|
208
|
+
expect((r1, r2))->toEqual(([Ok("ref")], [Ok("ref")]))
|
|
209
|
+
// Only the first command replayed; the second decided on the cached state.
|
|
210
|
+
expect(mock.replayCallCount.contents)->toBe(1)
|
|
211
|
+
// Both appends landed at their correct OCC positions (no conflict retries).
|
|
212
|
+
expect((mock.appendCallCount.contents, mock.getAll()->Array.length))->toEqual((2, 2))
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
testPromise("cached fold state equals a cold replay of the log", async () => {
|
|
216
|
+
let _ = await run(AggSpec.Add({name: "a"}))
|
|
217
|
+
let _ = await run(AggSpec.Add({name: "b"}))
|
|
218
|
+
let _ = await run(AggSpec.Add({name: "c"}))
|
|
219
|
+
// Warm checkpoint: decided on the cached state (no replay since call 1).
|
|
220
|
+
let _ = await run(AggSpec.Checkpoint)
|
|
221
|
+
expect(mock.replayCallCount.contents)->toBe(1)
|
|
222
|
+
// Cold checkpoint: same aggregate replayed from the log.
|
|
223
|
+
TestHandler.resetCache()
|
|
224
|
+
let _ = await run(AggSpec.Checkpoint)
|
|
225
|
+
expect(mock.replayCallCount.contents)->toBe(2)
|
|
226
|
+
// Both checkpoints observed the same state — cached fold ≡ replayed fold.
|
|
227
|
+
let checkpoints =
|
|
228
|
+
mock.getAll()
|
|
229
|
+
->Array.filterMap(e =>
|
|
230
|
+
switch e.event {
|
|
231
|
+
| CheckpointTaken({count}) => Some(count)
|
|
232
|
+
| _ => None
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
expect(checkpoints)->toEqual([3, 3])
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
testPromise("stale cache conflicts, invalidates, and self-heals", async () => {
|
|
239
|
+
let _ = await run(AggSpec.Add({name: "a"}))
|
|
240
|
+
// A second writer appends behind the warm cache's back.
|
|
241
|
+
mock.injectExternal("agg-1", AggSpec.Added({name: "external"}))
|
|
242
|
+
// Warm path appends at the stale seq → OCC conflict → invalidate →
|
|
243
|
+
// cold retry reads the authoritative log and succeeds.
|
|
244
|
+
let results = await run(AggSpec.Add({name: "b"}))
|
|
245
|
+
expect(results)->toEqual([Ok("ref")])
|
|
246
|
+
// Replays: initial cold + post-conflict cold (the warm attempt skipped).
|
|
247
|
+
expect(mock.replayCallCount.contents)->toBe(2)
|
|
248
|
+
expect(mock.getAll()->Array.length)->toBe(3)
|
|
249
|
+
// The refreshed state includes the external event.
|
|
250
|
+
let _ = await run(AggSpec.Checkpoint)
|
|
251
|
+
let last = mock.getAll()->Array.getUnsafe(3)
|
|
252
|
+
expect(last.event)->toEqual(AggSpec.CheckpointTaken({count: 3}))
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
testPromise("Ok([]) keeps the read snapshot warm", async () => {
|
|
256
|
+
let _ = await run(AggSpec.Add({name: "a"}))
|
|
257
|
+
let _ = await run(AggSpec.Noop)
|
|
258
|
+
let _ = await run(AggSpec.Add({name: "b"}))
|
|
259
|
+
// Neither the Noop nor the third command replayed.
|
|
260
|
+
expect(mock.replayCallCount.contents)->toBe(1)
|
|
261
|
+
expect(mock.getAll()->Array.length)->toBe(2)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
testPromise("resetCache forces the next command to replay cold", async () => {
|
|
265
|
+
let _ = await run(AggSpec.Add({name: "a"}))
|
|
266
|
+
TestHandler.resetCache()
|
|
267
|
+
let _ = await run(AggSpec.Add({name: "b"}))
|
|
268
|
+
expect(mock.replayCallCount.contents)->toBe(2)
|
|
269
|
+
expect(mock.getAll()->Array.length)->toBe(2)
|
|
270
|
+
})
|
|
271
|
+
})
|