@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.
Files changed (51) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +8 -8
  3. package/src/Message.res +43 -10
  4. package/src/Message.res.mjs +21 -4
  5. package/src/Projection.res +83 -55
  6. package/src/Projection.res.mjs +69 -57
  7. package/src/components/Aggregate/Aggregate_Callback.res +67 -9
  8. package/src/components/Aggregate/Aggregate_Callback.res.mjs +42 -13
  9. package/src/components/DcbEventLog/DcbEventLog_Operations.res +3 -0
  10. package/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs +3 -1
  11. package/src/components/EventLog/EventLog.res +33 -2
  12. package/src/components/EventLog/EventLog_Adapter.res +3 -1
  13. package/src/components/EventLog/EventLog_Builder.res +4 -0
  14. package/src/components/EventLog/EventLog_Builder.res.mjs +3 -1
  15. package/src/components/EventLog/EventLog_Operations.res +48 -36
  16. package/src/components/EventLog/EventLog_Operations.res.mjs +36 -15
  17. package/src/components/EventLog/EventPublish_Callback.res +7 -0
  18. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +26 -6
  19. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +21 -6
  20. package/src/plugin/component/SchemaWalker.res +13 -1
  21. package/src/plugin/component/SchemaWalker.res.mjs +5 -4
  22. package/src/plugin/lifecycle/PluginBehavior.res +6 -0
  23. package/src/plugin/lifecycle/PluginBehavior.res.mjs +3 -0
  24. package/src/util/CommandPublisher.res +20 -10
  25. package/src/util/CommandPublisher.res.mjs +6 -10
  26. package/src/util/Logger.res +10 -0
  27. package/src/util/Logger.res.mjs +12 -2
  28. package/src/util/Util_Array.res +1 -1
  29. package/src/util/Util_Array.res.mjs +1 -1
  30. package/tests/aggregate/AggregateCacheTest.res +271 -0
  31. package/tests/aggregate/AggregateCacheTest.res.mjs +433 -0
  32. package/tests/aggregate/AggregateCallbackTest.res +4 -1
  33. package/tests/aggregate/AggregateCallbackTest.res.mjs +4 -1
  34. package/tests/aggregate/AggregateCausationTest.res +4 -1
  35. package/tests/aggregate/AggregateCausationTest.res.mjs +4 -1
  36. package/tests/aggregate/AggregateConflictTest.res +17 -6
  37. package/tests/aggregate/AggregateConflictTest.res.mjs +27 -4
  38. package/tests/aggregate/AggregateFixtures.res +13 -5
  39. package/tests/aggregate/AggregateFixtures.res.mjs +26 -3
  40. package/tests/aggregate/AggregateRejectionTest.res +4 -1
  41. package/tests/aggregate/AggregateRejectionTest.res.mjs +4 -1
  42. package/tests/eventlog/EventLogFixtures.res +15 -4
  43. package/tests/eventlog/EventLogFixtures.res.mjs +35 -5
  44. package/tests/eventlog/EventLogRetryTest.res +12 -11
  45. package/tests/eventlog/EventLogRetryTest.res.mjs +31 -7
  46. package/tests/logger/LoggerLazyTest.res +36 -0
  47. package/tests/logger/LoggerLazyTest.res.mjs +40 -0
  48. package/tests/projection/ProjectionOptimizeTest.res +38 -0
  49. package/tests/projection/ProjectionOptimizeTest.res.mjs +75 -0
  50. package/tests/util/CommandPublisherTest.res +56 -0
  51. package/tests/util/CommandPublisherTest.res.mjs +78 -0
@@ -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(str) {
34
- log.debug("Projection", undefined, str);
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 beforeSubId = getSubId(before);
153
- return afterStates.find(after => {
154
- if (getSubId(after) === beforeSubId) {
155
- return Primitive_object.notequal(after, before);
156
- } else {
157
- return false;
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
- logAction(action + `(` + id + `): beforeStates:` + beforeCount.toString() + ` afterStates:` + afterCount.toString() + ` added:` + addedCount.toString() + ` changed:` + changedCount.toString() + ` deleted:` + deletedCount.toString());
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
- return {
182
- TAG: "Ok",
183
- _0: undefined
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.getOrThrow(JSON.stringify(state), undefined);
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
- logAction("Ignore");
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
- logAction(`Create(` + id + `, ` + stateToString(state) + `)`);
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
- let statesStr = batch.map(param => `(` + param[0] + `,` + stateToString(param[1]) + `)`).join(", ");
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
- logAction(`Update Error: Multiple oldStates for ` + id$1 + `)`);
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
- logAction(`Update Error: No oldState for ` + id$1 + `)`);
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
- logAction(`Update(` + id$1 + `, ` + stateToString(oldState) + ` => ` + stateToString(newState) + `)`);
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.debug("Projection", undefined, `UpdateWithDefault(` + id$2 + `, loading ...`);
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
- logAction(`UpdateWithDefault Error: Multiple oldStates for ` + id$2 + `)`);
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
- logAction(`UpdateWithDefault(` + id$2 + `, default: ` + stateToString($$default) + `)`);
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
- logAction(`UpdateWithDefault(` + id$2 + `, ` + stateToString(oldState$1) + ` => ` + stateToString(newState$1) + `)`);
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
- logAction(`UpdateWithDefault Error: Couldn't load oldState(s) for ` + id$2 + `: ` + QueryDb$ReventlessCore.storageErrorToString(err) + `)`);
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
- logAction(`Set(` + id$3 + `, ` + stateToString(state$1) + `)`);
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
- let statesStr$1 = batch$1.map(param => `(` + param[0] + `,` + stateToString(param[1]) + `)`).join(", ");
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
- logAction(`Delete(` + id$4 + `)`);
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
- logAction(`DeleteMany(` + ids.join(", ") + `)`);
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
- logAction(`CreateMultiState(` + id$5 + `, ` + states$4.map(stateToString).join(", ") + `)`);
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
- logAction("UpdateMultiState Error: Missing SubIdConfig !");
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
- logAction(`UpdateMultiState Error: Couldn't load states for ` + id$6 + `: ` + QueryDb$ReventlessCore.storageErrorToString(err$1) + `)`);
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
- logAction("UpdateMultiState Error: Missing SubIdConfig !");
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
- logAction("Error: Action not yet supported !");
374
+ log.debugLazy("Projection", () => "Error: projection action not supported");
373
375
  return {
374
- TAG: "Ok",
375
- _0: undefined
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
- logAction("Error: Action not yet supported !");
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 ids = allActionsWithId.map(param => param[0]);
442
- return Belt_SetString.toArray(Belt_SetString.fromArray(ids)).map(id => [
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
- Stdlib_Array.filterMap(allActionsWithId, param => {
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 = optimizedActionsCount === 1 ? [] : optimizedActions.slice(0, optimizedActionsCount);
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
- Ops.eventLog.replayStream(id)
137
- ->Stream.runFold((Behavior.initialState, 0), ((st, n), ev) => (Behavior.evolve(st, ev), n + 1))
138
- ->Effect.tap(((_, n)) =>
139
- EffectLogger.logInfo(~comp, `replay: id=${idStr}, ${n->Int.toString} event(s)`)
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 (_finalState, outcomes) =
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(msg) if msg->String.includes("conflict") =>
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(msg),
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
- return Effect.flatMap(Effect.tap(Stream.runFold(Ops.eventLog.replayStream(id), [
106
- Behavior.initialState,
107
- 0
108
- ], (param, ev) => [
109
- Behavior.evolve(param[0], ev),
110
- param[1] + 1 | 0
111
- ]), param => EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay: id=` + idStr + `, ` + param[1].toString() + ` event(s)`)), param => {
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
- param[0],
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.includes("conflict")) {
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: msg
174
+ _0: "conflict"
153
175
  }));
154
176
  }
155
- let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg);
156
- return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg), () => ({
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
- type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, string>>
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
- type replayStream<'id, 'event> = 'id => Stream.t<'event, string, unit>
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;