@reventlessdev/reventless-core 3.0.0-alpha.135 → 3.0.0-alpha.137
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 +8 -8
- package/src/Message.res +43 -10
- package/src/Message.res.mjs +21 -4
- package/src/Projection.res +83 -55
- package/src/Projection.res.mjs +69 -57
- package/src/components/Aggregate/Aggregate_Callback.res +67 -9
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +42 -13
- 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 +33 -2
- 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 +48 -36
- package/src/components/EventLog/EventLog_Operations.res.mjs +36 -15
- 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/component/SchemaWalker.res +13 -1
- package/src/plugin/component/SchemaWalker.res.mjs +5 -4
- package/src/plugin/lifecycle/PluginBehavior.res +6 -0
- package/src/plugin/lifecycle/PluginBehavior.res.mjs +3 -0
- package/src/util/CommandPublisher.res +20 -10
- package/src/util/CommandPublisher.res.mjs +6 -10
- package/src/util/Logger.res +10 -0
- package/src/util/Logger.res.mjs +12 -2
- package/src/util/Util_Array.res +1 -1
- package/src/util/Util_Array.res.mjs +1 -1
- 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 +17 -6
- package/tests/aggregate/AggregateConflictTest.res.mjs +27 -4
- package/tests/aggregate/AggregateFixtures.res +13 -5
- package/tests/aggregate/AggregateFixtures.res.mjs +26 -3
- package/tests/aggregate/AggregateRejectionTest.res +4 -1
- package/tests/aggregate/AggregateRejectionTest.res.mjs +4 -1
- package/tests/eventlog/EventLogFixtures.res +15 -4
- package/tests/eventlog/EventLogFixtures.res.mjs +35 -5
- package/tests/eventlog/EventLogRetryTest.res +12 -11
- package/tests/eventlog/EventLogRetryTest.res.mjs +31 -7
- package/tests/logger/LoggerLazyTest.res +36 -0
- package/tests/logger/LoggerLazyTest.res.mjs +40 -0
- package/tests/projection/ProjectionOptimizeTest.res +38 -0
- package/tests/projection/ProjectionOptimizeTest.res.mjs +75 -0
- package/tests/util/CommandPublisherTest.res +56 -0
- package/tests/util/CommandPublisherTest.res.mjs +78 -0
package/src/Projection.res.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import * as Stream$1 from "effect/Stream";
|
|
|
10
10
|
import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
|
|
11
11
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
12
12
|
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
13
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
13
14
|
import * as Logger$ReventlessCore from "./util/Logger.res.mjs";
|
|
14
15
|
import * as DisplayName$Reventless from "@reventlessdev/reventless-spec/src/components/DisplayName.res.mjs";
|
|
15
16
|
import * as Message$ReventlessCore from "./Message.res.mjs";
|
|
@@ -30,8 +31,8 @@ let Mapping = {
|
|
|
30
31
|
MakeGenericSource: MakeGenericSource
|
|
31
32
|
};
|
|
32
33
|
|
|
33
|
-
function logAction(
|
|
34
|
-
log.
|
|
34
|
+
function logAction(makeStr) {
|
|
35
|
+
log.debugLazy("Projection", makeStr);
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function overlayDisplayName(state, stateSchema, spec) {
|
|
@@ -148,15 +149,19 @@ async function applyChanges(action, id, beforeStates, afterStates, param, param$
|
|
|
148
149
|
let addedSubIds = Belt_SetString.diff(afterSubIds, beforeSubIds);
|
|
149
150
|
let addedStates = afterStates.filter(state => Belt_SetString.has(addedSubIds, getSubId(state)));
|
|
150
151
|
let addedCount = addedStates.length;
|
|
152
|
+
let afterBySubId = {};
|
|
153
|
+
afterStates.forEach(after => {
|
|
154
|
+
afterBySubId[getSubId(after)] = after;
|
|
155
|
+
});
|
|
151
156
|
let changedStates = Stdlib_Array.filterMap(beforeStates, before => {
|
|
152
|
-
let
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}
|
|
157
|
+
let after = afterBySubId[getSubId(before)];
|
|
158
|
+
if (after === undefined) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
let after$1 = Primitive_option.valFromOption(after);
|
|
162
|
+
if (Primitive_object.notequal(after$1, before)) {
|
|
163
|
+
return Primitive_option.some(after$1);
|
|
164
|
+
}
|
|
160
165
|
});
|
|
161
166
|
let changedCount = changedStates.length;
|
|
162
167
|
let batchToSave = changedStates.concat(addedStates).map(state => [
|
|
@@ -173,19 +178,24 @@ async function applyChanges(action, id, beforeStates, afterStates, param, param$
|
|
|
173
178
|
]
|
|
174
179
|
]);
|
|
175
180
|
let deletedCount = batchToDelete.length;
|
|
176
|
-
|
|
177
|
-
await Promise.all([
|
|
181
|
+
log.debugLazy("Projection", () => action + `(` + id + `): beforeStates:` + beforeCount.toString() + ` afterStates:` + afterCount.toString() + ` added:` + addedCount.toString() + ` changed:` + changedCount.toString() + ` deleted:` + deletedCount.toString());
|
|
182
|
+
let result = await Promise.all([
|
|
178
183
|
param.deleteBatch(batchToDelete),
|
|
179
184
|
param.saveBatch(batchToSave)
|
|
180
185
|
]);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
186
|
+
let err = result.find(r => r.TAG !== "Ok");
|
|
187
|
+
if (err !== undefined) {
|
|
188
|
+
return err;
|
|
189
|
+
} else {
|
|
190
|
+
return {
|
|
191
|
+
TAG: "Ok",
|
|
192
|
+
_0: undefined
|
|
193
|
+
};
|
|
194
|
+
}
|
|
185
195
|
}
|
|
186
196
|
|
|
187
197
|
function stateToString(state) {
|
|
188
|
-
return Stdlib_Option.
|
|
198
|
+
return Stdlib_Option.getOr(JSON.stringify(state), "<unserializable>");
|
|
189
199
|
}
|
|
190
200
|
|
|
191
201
|
function statesToString(states) {
|
|
@@ -211,7 +221,7 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
211
221
|
_0: e
|
|
212
222
|
})));
|
|
213
223
|
if (typeof action !== "object") {
|
|
214
|
-
|
|
224
|
+
log.debugLazy("Projection", () => "Ignore");
|
|
215
225
|
return {
|
|
216
226
|
TAG: "Ok",
|
|
217
227
|
_0: undefined
|
|
@@ -221,7 +231,7 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
221
231
|
case "Create" :
|
|
222
232
|
let state = action._1;
|
|
223
233
|
let id = action._0;
|
|
224
|
-
|
|
234
|
+
log.debugLazy("Projection", () => `Create(` + id + `, ` + stateToString(state) + `)`);
|
|
225
235
|
return await save(id, state, "Init", undefined);
|
|
226
236
|
case "CreateMany" :
|
|
227
237
|
let batch = action._0.map(param => [
|
|
@@ -229,8 +239,7 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
229
239
|
param[1],
|
|
230
240
|
undefined
|
|
231
241
|
]);
|
|
232
|
-
|
|
233
|
-
logAction(`CreateMany(` + statesStr + `)`);
|
|
242
|
+
log.debugLazy("Projection", () => `CreateMany(` + batch.map(param => `(` + param[0] + `,` + stateToString(param[1]) + `)`).join(", ") + `)`);
|
|
234
243
|
return await saveBatch(batch);
|
|
235
244
|
case "Update" :
|
|
236
245
|
let id$1 = action._0;
|
|
@@ -245,13 +254,13 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
245
254
|
let len = states$1.length;
|
|
246
255
|
if (len !== 1) {
|
|
247
256
|
if (len !== 0) {
|
|
248
|
-
|
|
257
|
+
log.debugLazy("Projection", () => `Update Error: Multiple oldStates for ` + id$1 + `)`);
|
|
249
258
|
return {
|
|
250
259
|
TAG: "Error",
|
|
251
260
|
_0: "StaleState"
|
|
252
261
|
};
|
|
253
262
|
} else {
|
|
254
|
-
|
|
263
|
+
log.debugLazy("Projection", () => `Update Error: No oldState for ` + id$1 + `)`);
|
|
255
264
|
return {
|
|
256
265
|
TAG: "Error",
|
|
257
266
|
_0: "StaleState"
|
|
@@ -260,35 +269,35 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
260
269
|
}
|
|
261
270
|
let oldState = states$1[0];
|
|
262
271
|
let newState = action._1(oldState);
|
|
263
|
-
|
|
272
|
+
log.debugLazy("Projection", () => `Update(` + id$1 + `, ` + stateToString(oldState) + ` => ` + stateToString(newState) + `)`);
|
|
264
273
|
return await save(id$1, newState, "Overwrite", undefined);
|
|
265
274
|
case "UpdateWithDefault" :
|
|
266
275
|
let $$default = action._1;
|
|
267
276
|
let id$2 = action._0;
|
|
268
|
-
log.
|
|
277
|
+
log.debugLazy("Projection", () => `UpdateWithDefault(` + id$2 + `, loading ...`);
|
|
269
278
|
let states$2 = await loadAtMost(2, id$2);
|
|
270
279
|
if (states$2.TAG === "Ok") {
|
|
271
280
|
let states$3 = states$2._0;
|
|
272
281
|
let len$1 = states$3.length;
|
|
273
282
|
if (len$1 !== 1) {
|
|
274
283
|
if (len$1 !== 0) {
|
|
275
|
-
|
|
284
|
+
log.debugLazy("Projection", () => `UpdateWithDefault Error: Multiple oldStates for ` + id$2 + `)`);
|
|
276
285
|
return {
|
|
277
286
|
TAG: "Error",
|
|
278
287
|
_0: "StaleState"
|
|
279
288
|
};
|
|
280
289
|
} else {
|
|
281
|
-
|
|
290
|
+
log.debugLazy("Projection", () => `UpdateWithDefault(` + id$2 + `, default: ` + stateToString($$default) + `)`);
|
|
282
291
|
return await save(id$2, $$default, "Init", undefined);
|
|
283
292
|
}
|
|
284
293
|
}
|
|
285
294
|
let oldState$1 = states$3[0];
|
|
286
295
|
let newState$1 = action._2(oldState$1);
|
|
287
|
-
|
|
296
|
+
log.debugLazy("Projection", () => `UpdateWithDefault(` + id$2 + `, ` + stateToString(oldState$1) + ` => ` + stateToString(newState$1) + `)`);
|
|
288
297
|
return await save(id$2, newState$1, "Overwrite", undefined);
|
|
289
298
|
}
|
|
290
299
|
let err = states$2._0;
|
|
291
|
-
|
|
300
|
+
log.debugLazy("Projection", () => `UpdateWithDefault Error: Couldn't load oldState(s) for ` + id$2 + `: ` + QueryDb$ReventlessCore.storageErrorToString(err) + `)`);
|
|
292
301
|
return {
|
|
293
302
|
TAG: "Error",
|
|
294
303
|
_0: err
|
|
@@ -296,7 +305,7 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
296
305
|
case "Set" :
|
|
297
306
|
let state$1 = action._1;
|
|
298
307
|
let id$3 = action._0;
|
|
299
|
-
|
|
308
|
+
log.debugLazy("Projection", () => `Set(` + id$3 + `, ` + stateToString(state$1) + `)`);
|
|
300
309
|
return await save(id$3, state$1, "Any", undefined);
|
|
301
310
|
case "SetMany" :
|
|
302
311
|
let set = action._1;
|
|
@@ -305,16 +314,15 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
305
314
|
set(id),
|
|
306
315
|
undefined
|
|
307
316
|
]);
|
|
308
|
-
|
|
309
|
-
logAction(`SetMany(` + statesStr$1 + `)`);
|
|
317
|
+
log.debugLazy("Projection", () => `SetMany(` + batch$1.map(param => `(` + param[0] + `,` + stateToString(param[1]) + `)`).join(", ") + `)`);
|
|
310
318
|
return await saveBatch(batch$1);
|
|
311
319
|
case "Delete" :
|
|
312
320
|
let id$4 = action._0;
|
|
313
|
-
|
|
321
|
+
log.debugLazy("Projection", () => `Delete(` + id$4 + `)`);
|
|
314
322
|
return await operations.delete(id$4, undefined);
|
|
315
323
|
case "DeleteMany" :
|
|
316
324
|
let ids = action._0;
|
|
317
|
-
|
|
325
|
+
log.debugLazy("Projection", () => `DeleteMany(` + ids.join(", ") + `)`);
|
|
318
326
|
return await operations.deleteBatch(ids.map(id => [
|
|
319
327
|
id,
|
|
320
328
|
undefined
|
|
@@ -322,7 +330,7 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
322
330
|
case "CreateMultiState" :
|
|
323
331
|
let states$4 = action._1;
|
|
324
332
|
let id$5 = action._0;
|
|
325
|
-
|
|
333
|
+
log.debugLazy("Projection", () => `CreateMultiState(` + id$5 + `, ` + states$4.map(stateToString).join(", ") + `)`);
|
|
326
334
|
let len$2 = states$4.length;
|
|
327
335
|
if (len$2 !== 1) {
|
|
328
336
|
if (len$2 === 0) {
|
|
@@ -349,30 +357,27 @@ async function handleAction(action, operations, subIdConfig) {
|
|
|
349
357
|
let afterStates = action._1(states$5);
|
|
350
358
|
return await applyChanges("UpdateMultiState", id$6, states$5, afterStates, operations, subIdConfig);
|
|
351
359
|
}
|
|
352
|
-
|
|
353
|
-
return {
|
|
354
|
-
TAG: "Error",
|
|
355
|
-
_0: "MissingSubIdConfig"
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
if (subIdConfig !== undefined) {
|
|
360
|
+
} else if (subIdConfig !== undefined) {
|
|
359
361
|
let err$1 = match._0;
|
|
360
|
-
|
|
362
|
+
log.debugLazy("Projection", () => `UpdateMultiState Error: Couldn't load states for ` + id$6 + `: ` + QueryDb$ReventlessCore.storageErrorToString(err$1) + `)`);
|
|
361
363
|
return {
|
|
362
364
|
TAG: "Error",
|
|
363
365
|
_0: err$1
|
|
364
366
|
};
|
|
365
367
|
}
|
|
366
|
-
|
|
368
|
+
log.debugLazy("Projection", () => "UpdateMultiState Error: Missing SubIdConfig !");
|
|
367
369
|
return {
|
|
368
370
|
TAG: "Error",
|
|
369
371
|
_0: "MissingSubIdConfig"
|
|
370
372
|
};
|
|
371
373
|
default:
|
|
372
|
-
|
|
374
|
+
log.debugLazy("Projection", () => "Error: projection action not supported");
|
|
373
375
|
return {
|
|
374
|
-
TAG: "
|
|
375
|
-
_0:
|
|
376
|
+
TAG: "Error",
|
|
377
|
+
_0: {
|
|
378
|
+
TAG: "NotSavedToStorage",
|
|
379
|
+
_0: "unsupported projection action"
|
|
380
|
+
}
|
|
376
381
|
};
|
|
377
382
|
}
|
|
378
383
|
}
|
|
@@ -416,8 +421,7 @@ function actionsWithId(action) {
|
|
|
416
421
|
case "UpdateManyWithDefault" :
|
|
417
422
|
case "DeleteIf" :
|
|
418
423
|
case "DeleteManyIf" :
|
|
419
|
-
|
|
420
|
-
return [];
|
|
424
|
+
break;
|
|
421
425
|
case "UpdateManyMultiStates" :
|
|
422
426
|
let update = action._1;
|
|
423
427
|
return action._0.map(id => [
|
|
@@ -434,18 +438,26 @@ function actionsWithId(action) {
|
|
|
434
438
|
action
|
|
435
439
|
]];
|
|
436
440
|
}
|
|
441
|
+
log.debugLazy("Projection", () => "Error: Action not yet supported !");
|
|
442
|
+
return [];
|
|
437
443
|
}
|
|
438
444
|
|
|
439
445
|
function groupActionsById(actions) {
|
|
440
446
|
let allActionsWithId = actions.map(actionsWithId).flat();
|
|
441
|
-
let
|
|
442
|
-
|
|
447
|
+
let groups = {};
|
|
448
|
+
allActionsWithId.forEach(param => {
|
|
449
|
+
let action = param[1];
|
|
450
|
+
let id = param[0];
|
|
451
|
+
let arr = groups[id];
|
|
452
|
+
if (arr !== undefined) {
|
|
453
|
+
arr.push(action);
|
|
454
|
+
} else {
|
|
455
|
+
groups[id] = [action];
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
return Belt_SetString.toArray(Belt_SetString.fromArray(Object.keys(groups))).map(id => [
|
|
443
459
|
id,
|
|
444
|
-
|
|
445
|
-
if (param[0] === id) {
|
|
446
|
-
return param[1];
|
|
447
|
-
}
|
|
448
|
-
})
|
|
460
|
+
groups[id]
|
|
449
461
|
]);
|
|
450
462
|
}
|
|
451
463
|
|
|
@@ -456,7 +468,7 @@ function optimizeActions(actions) {
|
|
|
456
468
|
return [action];
|
|
457
469
|
}
|
|
458
470
|
let lastAction = optimizedActions[optimizedActionsCount - 1 | 0];
|
|
459
|
-
let previousActions =
|
|
471
|
+
let previousActions = optimizedActions.slice(0, optimizedActionsCount - 1 | 0);
|
|
460
472
|
if (typeof lastAction === "object") {
|
|
461
473
|
switch (lastAction.TAG) {
|
|
462
474
|
case "Create" :
|
|
@@ -7,6 +7,11 @@ module type Ops = {
|
|
|
7
7
|
module type T = {
|
|
8
8
|
module Spec: Reventless.Aggregate.Spec
|
|
9
9
|
let handleCommands: CommandTopic.commandsHandler<Message.command'<Spec.Id.t, Spec.command>>
|
|
10
|
+
|
|
11
|
+
/** Flushes this aggregate's in-process replay cache. The cache is a pure read
|
|
12
|
+
optimization (the OCC append fences staleness), so this is only needed for
|
|
13
|
+
test isolation — production code never has to call it. */
|
|
14
|
+
let resetCache: unit => unit
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
module Make = (
|
|
@@ -95,6 +100,28 @@ module Make = (
|
|
|
95
100
|
|
|
96
101
|
let maxConflictRetries = 3
|
|
97
102
|
|
|
103
|
+
// In-process replay cache (per warm Lambda instance, per aggregate — the
|
|
104
|
+
// functor instance owns the cache, so the event log is implicit and the key
|
|
105
|
+
// is just the aggregate id). Holds the `(state, sequenceNr)` the previous
|
|
106
|
+
// command batch for that id left behind.
|
|
107
|
+
//
|
|
108
|
+
// On a hit, `replayProcessAppend` skips the event-log replay entirely and
|
|
109
|
+
// decides on the cached state; after a successful append it stores the
|
|
110
|
+
// already-folded post-append state (aggregates have no consumed-vs-produced
|
|
111
|
+
// event split, so `evolve` over the appended events IS the replay result).
|
|
112
|
+
// Correctness rests on the append's optimistic-concurrency check: a stale
|
|
113
|
+
// cached state only changes the *decision*, and a stale sequenceNr conflicts
|
|
114
|
+
// at append time → the conflict branch invalidates the entry and the retry
|
|
115
|
+
// replays cold. Mirrors the StateChangeSlice decision-model cache, including
|
|
116
|
+
// the fixed capacity (a per-aggregate knob is a future refinement — see
|
|
117
|
+
// docs/plans/aggregate-snapshotting.md).
|
|
118
|
+
let replayCacheCapacity = 100
|
|
119
|
+
let replayCache: Lru.t<string, (Behavior.state, int)> = Lru.make(
|
|
120
|
+
~capacity=replayCacheCapacity,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
let resetCache = () => replayCache->Lru.clear
|
|
124
|
+
|
|
98
125
|
// Reports per-command outcomes on the inline side-channels and produces the
|
|
99
126
|
// per-reference Ok/Error array consumed by SQS-style consumers. Domain rejections
|
|
100
127
|
// are always Ok(reference) — SQS deletes the message because retry would not help —
|
|
@@ -133,13 +160,25 @@ module Make = (
|
|
|
133
160
|
) => {
|
|
134
161
|
let idStr = id->Spec.Id.toString
|
|
135
162
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
->
|
|
139
|
-
|
|
140
|
-
|
|
163
|
+
// Warm path: seed from the replay cache and skip the event-log read; the
|
|
164
|
+
// OCC append below fences any staleness. Cold path: full replay as before.
|
|
165
|
+
let readState = switch replayCache->Lru.get(idStr) {
|
|
166
|
+
| Some((state, seqNr)) =>
|
|
167
|
+
EffectLogger.logInfo(
|
|
168
|
+
~comp,
|
|
169
|
+
`replay skipped (cached): id=${idStr}, seq=${seqNr->Int.toString}`,
|
|
170
|
+
)->Effect.map(_ => (state, seqNr))
|
|
171
|
+
| None =>
|
|
172
|
+
Ops.eventLog.replayStream(id)
|
|
173
|
+
->Stream.runFold((Behavior.initialState, 0), ((st, n), ev) => (Behavior.evolve(st, ev), n + 1))
|
|
174
|
+
->Effect.tap(((_, n)) =>
|
|
175
|
+
EffectLogger.logInfo(~comp, `replay: id=${idStr}, ${n->Int.toString} event(s)`)
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
readState
|
|
141
180
|
->Effect.flatMap(((initialState, sequenceNr)) => {
|
|
142
|
-
let (
|
|
181
|
+
let (finalState, outcomes) =
|
|
143
182
|
topicItemsForId->Array.reduce((initialState, []), processCommand)
|
|
144
183
|
|
|
145
184
|
let eventsToAppend =
|
|
@@ -154,6 +193,9 @@ module Make = (
|
|
|
154
193
|
|
|
155
194
|
switch eventsToAppend {
|
|
156
195
|
| [] =>
|
|
196
|
+
// Nothing appended, but the read snapshot is valid — keep it warm for
|
|
197
|
+
// the next command (also refreshes recency on a cache hit).
|
|
198
|
+
replayCache->Lru.put(idStr, (initialState, sequenceNr))
|
|
157
199
|
let perRef = reportFinalOutcomes(
|
|
158
200
|
outcomes,
|
|
159
201
|
~entityId=idStr,
|
|
@@ -178,6 +220,12 @@ module Make = (
|
|
|
178
220
|
appendResult =>
|
|
179
221
|
switch appendResult {
|
|
180
222
|
| Ok(_) =>
|
|
223
|
+
// The post-decide fold state IS the post-append replay result;
|
|
224
|
+
// cache it so the next command for this id skips the replay.
|
|
225
|
+
replayCache->Lru.put(
|
|
226
|
+
idStr,
|
|
227
|
+
(finalState, sequenceNr + generatedEvents'->Array.length),
|
|
228
|
+
)
|
|
181
229
|
let perRef = reportFinalOutcomes(
|
|
182
230
|
outcomes,
|
|
183
231
|
~entityId=idStr,
|
|
@@ -185,11 +233,21 @@ module Make = (
|
|
|
185
233
|
~appendedEventCount=generatedEvents'->Array.length,
|
|
186
234
|
)
|
|
187
235
|
EffectLogger.logInfo(~comp, `append: id=${idStr}`)->Effect.map(_ => Ok(perRef))
|
|
188
|
-
| Error(
|
|
236
|
+
| Error(EventLog.Conflict) =>
|
|
237
|
+
// Another writer advanced the stream past our (possibly cached)
|
|
238
|
+
// sequenceNr — drop the entry so the retry replays cold.
|
|
239
|
+
replayCache->Lru.invalidate(idStr)
|
|
240
|
+
// Signal the outer replay+re-decide retry loop (which matches
|
|
241
|
+
// Error(_)); the string is an internal marker, no longer a
|
|
242
|
+
// cross-component substring sentinel.
|
|
189
243
|
EffectLogger.logWarn(~comp, `conflict: id=${idStr}, will retry`)->Effect.map(
|
|
190
|
-
_ => Error(
|
|
244
|
+
_ => Error("conflict"),
|
|
191
245
|
)
|
|
192
|
-
| Error(msg) =>
|
|
246
|
+
| Error(EventLog.StorageFailure(msg)) =>
|
|
247
|
+
// Appends are atomic, so the read snapshot is likely still valid —
|
|
248
|
+
// but a storage error means we can't be sure what committed;
|
|
249
|
+
// drop the entry so the next attempt reads authoritative state.
|
|
250
|
+
replayCache->Lru.invalidate(idStr)
|
|
193
251
|
let perRef = reportFinalOutcomes(
|
|
194
252
|
outcomes,
|
|
195
253
|
~entityId=idStr,
|
|
@@ -4,6 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
|
4
4
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
5
|
import * as Effect from "effect/Effect";
|
|
6
6
|
import * as Stream from "effect/Stream";
|
|
7
|
+
import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
|
|
7
8
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
8
9
|
import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
|
|
9
10
|
import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
|
|
@@ -64,6 +65,8 @@ function Make(Spec) {
|
|
|
64
65
|
]])
|
|
65
66
|
];
|
|
66
67
|
};
|
|
68
|
+
let replayCache = Lru$ReventlessCore.make(100);
|
|
69
|
+
let resetCache = () => Lru$ReventlessCore.clear(replayCache);
|
|
67
70
|
let reportFinalOutcomes = (outcomes, entityId, appendSucceeded, appendedEventCount, appendErrorDetailOpt) => {
|
|
68
71
|
let appendErrorDetail = appendErrorDetailOpt !== undefined ? appendErrorDetailOpt : "";
|
|
69
72
|
return outcomes.map(param => {
|
|
@@ -102,19 +105,33 @@ function Make(Spec) {
|
|
|
102
105
|
};
|
|
103
106
|
let replayProcessAppend = (id, topicItemsForId) => {
|
|
104
107
|
let idStr = Spec.Id.toString(id);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
let match = Lru$ReventlessCore.get(replayCache, idStr);
|
|
109
|
+
let readState;
|
|
110
|
+
if (match !== undefined) {
|
|
111
|
+
let seqNr = match[1];
|
|
112
|
+
let state = match[0];
|
|
113
|
+
readState = Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay skipped (cached): id=` + idStr + `, seq=` + seqNr.toString()), () => [
|
|
114
|
+
state,
|
|
115
|
+
seqNr
|
|
116
|
+
]);
|
|
117
|
+
} else {
|
|
118
|
+
readState = Effect.tap(Stream.runFold(Ops.eventLog.replayStream(id, undefined), [
|
|
119
|
+
Behavior.initialState,
|
|
120
|
+
0
|
|
121
|
+
], (param, ev) => [
|
|
122
|
+
Behavior.evolve(param[0], ev),
|
|
123
|
+
param[1] + 1 | 0
|
|
124
|
+
]), param => EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay: id=` + idStr + `, ` + param[1].toString() + ` event(s)`));
|
|
125
|
+
}
|
|
126
|
+
return Effect.flatMap(readState, param => {
|
|
112
127
|
let sequenceNr = param[1];
|
|
128
|
+
let initialState = param[0];
|
|
113
129
|
let match = Stdlib_Array.reduce(topicItemsForId, [
|
|
114
|
-
|
|
130
|
+
initialState,
|
|
115
131
|
[]
|
|
116
132
|
], processCommand);
|
|
117
133
|
let outcomes = match[1];
|
|
134
|
+
let finalState = match[0];
|
|
118
135
|
let eventsToAppend = outcomes.map(param => {
|
|
119
136
|
let meta = param[2];
|
|
120
137
|
let outcome = param[1];
|
|
@@ -139,6 +156,10 @@ function Make(Spec) {
|
|
|
139
156
|
let eventJsons = eventsToAppend.map(event$p => Message$ReventlessCore.encode(event$p.event, Spec.eventSchema));
|
|
140
157
|
return Effect.flatMap(Effect.flatMap(EffectLogger$ReventlessCore.logInfo(comp, eventJsons, `produced ` + eventCount + ` event(s): [` + eventDetails + `]`), () => Effect.promise(() => Ops.eventLog.append(sequenceNr, id, eventsToAppend))), appendResult => {
|
|
141
158
|
if (appendResult.TAG === "Ok") {
|
|
159
|
+
Lru$ReventlessCore.put(replayCache, idStr, [
|
|
160
|
+
finalState,
|
|
161
|
+
sequenceNr + eventsToAppend.length | 0
|
|
162
|
+
]);
|
|
142
163
|
let perRef = reportFinalOutcomes(outcomes, idStr, true, eventsToAppend.length, undefined);
|
|
143
164
|
return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `append: id=` + idStr), () => ({
|
|
144
165
|
TAG: "Ok",
|
|
@@ -146,19 +167,26 @@ function Make(Spec) {
|
|
|
146
167
|
}));
|
|
147
168
|
}
|
|
148
169
|
let msg = appendResult._0;
|
|
149
|
-
if (msg
|
|
170
|
+
if (typeof msg !== "object") {
|
|
171
|
+
Lru$ReventlessCore.invalidate(replayCache, idStr);
|
|
150
172
|
return Effect.map(EffectLogger$ReventlessCore.logWarn(comp, undefined, `conflict: id=` + idStr + `, will retry`), () => ({
|
|
151
173
|
TAG: "Error",
|
|
152
|
-
_0:
|
|
174
|
+
_0: "conflict"
|
|
153
175
|
}));
|
|
154
176
|
}
|
|
155
|
-
let
|
|
156
|
-
|
|
177
|
+
let msg$1 = msg._0;
|
|
178
|
+
Lru$ReventlessCore.invalidate(replayCache, idStr);
|
|
179
|
+
let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg$1);
|
|
180
|
+
return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg$1), () => ({
|
|
157
181
|
TAG: "Ok",
|
|
158
182
|
_0: perRef$1
|
|
159
183
|
}));
|
|
160
184
|
});
|
|
161
185
|
}
|
|
186
|
+
Lru$ReventlessCore.put(replayCache, idStr, [
|
|
187
|
+
initialState,
|
|
188
|
+
sequenceNr
|
|
189
|
+
]);
|
|
162
190
|
let perRef = reportFinalOutcomes(outcomes, idStr, true, 0, undefined);
|
|
163
191
|
return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `no events produced: id=` + idStr), () => ({
|
|
164
192
|
TAG: "Ok",
|
|
@@ -197,7 +225,8 @@ function Make(Spec) {
|
|
|
197
225
|
}), prim => prim.flat()));
|
|
198
226
|
return {
|
|
199
227
|
Spec: Spec,
|
|
200
|
-
handleCommands: handleCommands
|
|
228
|
+
handleCommands: handleCommands,
|
|
229
|
+
resetCache: resetCache
|
|
201
230
|
};
|
|
202
231
|
});
|
|
203
232
|
}
|
|
@@ -92,6 +92,9 @@ module Make = (Ops: Ops): T => {
|
|
|
92
92
|
eventCount: finalRawEventsJson->Array.length,
|
|
93
93
|
eventsJson: finalRawEventsJson,
|
|
94
94
|
meta: representativeMeta,
|
|
95
|
+
// Bare payloads carry no meta inline — expose the per-event metas so
|
|
96
|
+
// afterPublish consumers can correlate each published event.
|
|
97
|
+
metas: rawEvents->Array.map(re => re.meta),
|
|
95
98
|
}
|
|
96
99
|
let _ = await hook(published)
|
|
97
100
|
} catch {
|
|
@@ -62,12 +62,14 @@ function Make(Ops) {
|
|
|
62
62
|
}
|
|
63
63
|
try {
|
|
64
64
|
let published_eventCount$1 = finalRawEventsJson.length;
|
|
65
|
+
let published_metas = rawEvents.map(re => re.meta);
|
|
65
66
|
let published$1 = {
|
|
66
67
|
componentName: name,
|
|
67
68
|
entityId: name,
|
|
68
69
|
eventCount: published_eventCount$1,
|
|
69
70
|
eventsJson: finalRawEventsJson,
|
|
70
|
-
meta: representativeMeta
|
|
71
|
+
meta: representativeMeta,
|
|
72
|
+
metas: published_metas
|
|
71
73
|
};
|
|
72
74
|
await hook$1(published$1);
|
|
73
75
|
return;
|
|
@@ -7,10 +7,39 @@ type component<'operations> = Component.t<t, outputs, 'operations>
|
|
|
7
7
|
|
|
8
8
|
exception ReplayError(string)
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// Typed append outcome. `Conflict` is the optimistic-concurrency sentinel — the
|
|
11
|
+
// deliberate append-condition failure (seq_nr/position already taken) that the
|
|
12
|
+
// caller retries by re-reading and re-deciding. Every other failure is a
|
|
13
|
+
// `StorageFailure` carrying its message. This replaces the former string sentinel
|
|
14
|
+
// that was detected by substring-matching a pretty-printed Cause across three
|
|
15
|
+
// layers — a real error whose text happened to contain "conflict" would have
|
|
16
|
+
// been retried as an OCC conflict, and a misclassified conflict would surface as
|
|
17
|
+
// a permanent error.
|
|
18
|
+
type appendError =
|
|
19
|
+
| Conflict
|
|
20
|
+
| StorageFailure(string)
|
|
21
|
+
|
|
22
|
+
// A persisted aggregate-state snapshot: `state` is the fold of events
|
|
23
|
+
// seq 0..seqNr-1, so `seqNr` doubles as the sequence number to resume the
|
|
24
|
+
// replay from (and the OCC condition for the next append). `schemaHash` gates
|
|
25
|
+
// staleness — consumers ignore a snapshot whose hash differs from their current
|
|
26
|
+
// state schema and fall back to full replay. Snapshots are a read optimization
|
|
27
|
+
// only; the OCC append remains the sole consistency primitive
|
|
28
|
+
// (docs/plans/aggregate-snapshotting.md).
|
|
29
|
+
type snapshot = {seqNr: int, state: JSON.t, schemaHash: string}
|
|
30
|
+
|
|
31
|
+
type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, appendError>>
|
|
11
32
|
type replay<'id, 'event> = 'id => promise<array<'event>>
|
|
12
|
-
|
|
33
|
+
// `fromSeq` starts the replay at that sequence number (inclusive; default 0) —
|
|
34
|
+
// the delta read after seeding from a snapshot at seqNr = fromSeq.
|
|
35
|
+
type replayStream<'id, 'event> = ('id, ~fromSeq: int=?) => Stream.t<'event, string, unit>
|
|
13
36
|
type appendStream<'id, 'event> = (int, 'id, Stream.t<'event, string, unit>) => Effect.t<unit, string, unit>
|
|
37
|
+
// Keep-one semantics: `writeSnapshot` overwrites the single snapshot per
|
|
38
|
+
// aggregate; recovery from a corrupt snapshot is full replay, not older
|
|
39
|
+
// snapshots. Failures are plain strings — a snapshot op failure must never
|
|
40
|
+
// fail a command, so no retryable/typed error channel is needed.
|
|
41
|
+
type latestSnapshot<'id> = 'id => promise<result<option<snapshot>, string>>
|
|
42
|
+
type writeSnapshot<'id> = ('id, snapshot) => promise<result<unit, string>>
|
|
14
43
|
|
|
15
44
|
module type T = {
|
|
16
45
|
module Spec: ReventlessInfra.EventLog.T
|
|
@@ -20,6 +49,8 @@ module type T = {
|
|
|
20
49
|
replay: replay<Spec.Id.t, Spec.event>,
|
|
21
50
|
replayStream: replayStream<Spec.Id.t, Spec.event>,
|
|
22
51
|
appendStream: appendStream<Spec.Id.t, Spec.event>,
|
|
52
|
+
latestSnapshot: latestSnapshot<Spec.Id.t>,
|
|
53
|
+
writeSnapshot: writeSnapshot<Spec.Id.t>,
|
|
23
54
|
}
|
|
24
55
|
type component = component<operations>
|
|
25
56
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
type operations = {
|
|
2
2
|
append: EventLog.append<string, JSON.t>,
|
|
3
3
|
replay: EventLog.replay<string, JSON.t>,
|
|
4
|
-
replayStream: string => Stream.t<JSON.t, string, unit>,
|
|
4
|
+
replayStream: (string, ~fromSeq: int=?) => Stream.t<JSON.t, string, unit>,
|
|
5
5
|
appendStream: EventLog.appendStream<string, JSON.t>,
|
|
6
|
+
latestSnapshot: EventLog.latestSnapshot<string>,
|
|
7
|
+
writeSnapshot: EventLog.writeSnapshot<string>,
|
|
6
8
|
}
|
|
7
9
|
type storage = {
|
|
8
10
|
resources: array<ReventlessInfra.Adapter.resource>,
|
|
@@ -10,6 +10,8 @@ module Make = (
|
|
|
10
10
|
replay: EventLog.replay<Spec.Id.t, Spec.event>,
|
|
11
11
|
replayStream: EventLog.replayStream<Spec.Id.t, Spec.event>,
|
|
12
12
|
appendStream: EventLog.appendStream<Spec.Id.t, Spec.event>,
|
|
13
|
+
latestSnapshot: EventLog.latestSnapshot<Spec.Id.t>,
|
|
14
|
+
writeSnapshot: EventLog.writeSnapshot<Spec.Id.t>,
|
|
13
15
|
}
|
|
14
16
|
type component = Component.t<EventLog.t, EventLog.outputs, operations>
|
|
15
17
|
|
|
@@ -44,6 +46,8 @@ module Make = (
|
|
|
44
46
|
replay: Ops.replay,
|
|
45
47
|
replayStream: Ops.replayStream,
|
|
46
48
|
appendStream: Ops.appendStream,
|
|
49
|
+
latestSnapshot: Ops.latestSnapshot,
|
|
50
|
+
writeSnapshot: Ops.writeSnapshot,
|
|
47
51
|
}
|
|
48
52
|
}),
|
|
49
53
|
)
|
|
@@ -32,7 +32,9 @@ function Make(Spec) {
|
|
|
32
32
|
append: Ops.append,
|
|
33
33
|
replay: Ops.replay,
|
|
34
34
|
replayStream: Ops.replayStream,
|
|
35
|
-
appendStream: Ops.appendStream
|
|
35
|
+
appendStream: Ops.appendStream,
|
|
36
|
+
latestSnapshot: Ops.latestSnapshot,
|
|
37
|
+
writeSnapshot: Ops.writeSnapshot
|
|
36
38
|
};
|
|
37
39
|
}));
|
|
38
40
|
let outputs_resources = storage.resources;
|