@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
|
@@ -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,176 @@ 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
|
+
|
|
125
|
+
// Persisted-snapshot configuration (docs/plans/aggregate-snapshotting.md).
|
|
126
|
+
// `None` (the default) keeps full replay; `Some({interval, stateSchema})`
|
|
127
|
+
// seeds cold replays from the latest persisted snapshot and writes a fresh
|
|
128
|
+
// one every `interval` events. Snapshots are a read optimization only — the
|
|
129
|
+
// OCC append remains the sole consistency primitive, so a missing, drifted,
|
|
130
|
+
// or corrupt snapshot degrades silently to full replay.
|
|
131
|
+
let snapshotConfig = Behavior.snapshot
|
|
132
|
+
|
|
133
|
+
// Structural hash of the state schema, computed once. Stored on every written
|
|
134
|
+
// snapshot and compared on read: a snapshot whose hash differs from the
|
|
135
|
+
// current schema (a state-shape change since it was written) is ignored and
|
|
136
|
+
// overwritten at the next boundary, so a redeploy that changes `type state`
|
|
137
|
+
// can't decode stale bytes into the wrong shape. NOTE: this catches *shape*
|
|
138
|
+
// drift only — a semantics-only `evolve` change with an unchanged state type
|
|
139
|
+
// is invisible (see the plan's open question; for alpha, wipe snapshots on
|
|
140
|
+
// such changes, consistent with the alpha-wipe-over-migration convention).
|
|
141
|
+
let stateSchemaHash = switch snapshotConfig {
|
|
142
|
+
| Some({stateSchema}) =>
|
|
143
|
+
HashObj.hashDict(
|
|
144
|
+
~dict=Dict.fromArray([("state", SchemaWalker.describeSchema(stateSchema->Obj.magic))]),
|
|
145
|
+
~options={algorithm: SHA256},
|
|
146
|
+
)
|
|
147
|
+
| None => ""
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// State ⇄ JSON via the configured stateSchema. Both swallow sury failures to
|
|
151
|
+
// `None` — an unserializable state just skips the snapshot write, and an
|
|
152
|
+
// undecodable snapshot falls back to full replay; neither can fail a command.
|
|
153
|
+
let encodeState = (state: Behavior.state): option<JSON.t> =>
|
|
154
|
+
switch snapshotConfig {
|
|
155
|
+
| Some({stateSchema}) =>
|
|
156
|
+
switch state->S.reverseConvertToJsonOrThrow(stateSchema) {
|
|
157
|
+
| json => Some(json)
|
|
158
|
+
| exception _ => None
|
|
159
|
+
}
|
|
160
|
+
| None => None
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let decodeState = (json: JSON.t): option<Behavior.state> =>
|
|
164
|
+
switch snapshotConfig {
|
|
165
|
+
| Some({stateSchema}) =>
|
|
166
|
+
switch json->S.parseJsonOrThrow(stateSchema) {
|
|
167
|
+
| state => Some(state)
|
|
168
|
+
| exception _ => None
|
|
169
|
+
}
|
|
170
|
+
| None => None
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Cold read (in-process cache miss): when snapshots are enabled, seed from the
|
|
174
|
+
// latest persisted snapshot (hash-gated) and replay only the events after it;
|
|
175
|
+
// otherwise replay full history from seq 0. Returns `(state, sequenceNr)` where
|
|
176
|
+
// sequenceNr is the total event count (the OCC condition for the next append).
|
|
177
|
+
let coldReadState = id => {
|
|
178
|
+
let idStr = id->Spec.Id.toString
|
|
179
|
+
let seedEffect = switch snapshotConfig {
|
|
180
|
+
| None => Effect.succeed((Behavior.initialState, 0))
|
|
181
|
+
| Some(_) =>
|
|
182
|
+
Effect.promise(() => Ops.eventLog.latestSnapshot(id))
|
|
183
|
+
->Effect.map(snapResult =>
|
|
184
|
+
switch snapResult {
|
|
185
|
+
| Ok(Some(snap)) if snap.EventLog.schemaHash == stateSchemaHash =>
|
|
186
|
+
switch decodeState(snap.state) {
|
|
187
|
+
| Some(state) => (state, snap.seqNr)
|
|
188
|
+
| None =>
|
|
189
|
+
EffectLogger.logWarn(
|
|
190
|
+
~comp,
|
|
191
|
+
`snapshot ignored (undecodable): id=${idStr}, seq=${snap.seqNr->Int.toString} — full replay`,
|
|
192
|
+
)->Effect.runSync
|
|
193
|
+
(Behavior.initialState, 0)
|
|
194
|
+
}
|
|
195
|
+
| Ok(Some(snap)) =>
|
|
196
|
+
EffectLogger.logWarn(
|
|
197
|
+
~comp,
|
|
198
|
+
`snapshot ignored (schema drift): id=${idStr}, seq=${snap.EventLog.seqNr->Int.toString} — full replay`,
|
|
199
|
+
)->Effect.runSync
|
|
200
|
+
(Behavior.initialState, 0)
|
|
201
|
+
| Ok(None) => (Behavior.initialState, 0)
|
|
202
|
+
| Error(msg) =>
|
|
203
|
+
EffectLogger.logWarn(
|
|
204
|
+
~comp,
|
|
205
|
+
`snapshot read failed (ignored): id=${idStr}: ${msg} — full replay`,
|
|
206
|
+
)->Effect.runSync
|
|
207
|
+
(Behavior.initialState, 0)
|
|
208
|
+
}
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
seedEffect->Effect.flatMap(((seedState, seedSeq)) =>
|
|
212
|
+
Ops.eventLog.replayStream(id, ~fromSeq=seedSeq)
|
|
213
|
+
->Stream.runFold((seedState, seedSeq), ((st, n), ev) => (Behavior.evolve(st, ev), n + 1))
|
|
214
|
+
->Effect.tap(((_, n)) => {
|
|
215
|
+
let detail =
|
|
216
|
+
seedSeq > 0
|
|
217
|
+
? `(snapshot@${seedSeq->Int.toString}, ${(n - seedSeq)->Int.toString} delta event(s))`
|
|
218
|
+
: `${n->Int.toString} event(s)`
|
|
219
|
+
EffectLogger.logInfo(~comp, `replay: id=${idStr}, ${detail}`)
|
|
220
|
+
})
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Fire-and-forget snapshot write — never awaited, never fails the command. A
|
|
225
|
+
// failed write just means the next cold replay reads a longer delta.
|
|
226
|
+
let fireSnapshotWrite = (id, idStr, snap: EventLog.snapshot) => {
|
|
227
|
+
let _ =
|
|
228
|
+
Ops.eventLog.writeSnapshot(id, snap)
|
|
229
|
+
->Promise.then(result => {
|
|
230
|
+
switch result {
|
|
231
|
+
| Ok() =>
|
|
232
|
+
EffectLogger.logDebug(
|
|
233
|
+
~comp,
|
|
234
|
+
`snapshot written: id=${idStr}, seq=${snap.seqNr->Int.toString}`,
|
|
235
|
+
)->Effect.runSync
|
|
236
|
+
| Error(msg) =>
|
|
237
|
+
EffectLogger.logWarn(
|
|
238
|
+
~comp,
|
|
239
|
+
`snapshot write failed (ignored): id=${idStr}: ${msg}`,
|
|
240
|
+
)->Effect.runSync
|
|
241
|
+
}
|
|
242
|
+
Promise.resolve()
|
|
243
|
+
})
|
|
244
|
+
->Promise.catch(err => {
|
|
245
|
+
let msg = err->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
|
|
246
|
+
EffectLogger.logWarn(
|
|
247
|
+
~comp,
|
|
248
|
+
`snapshot write threw (ignored): id=${idStr}: ${msg}`,
|
|
249
|
+
)->Effect.runSync
|
|
250
|
+
Promise.resolve()
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Write a snapshot when this append crossed an `interval` boundary (a multiple
|
|
255
|
+
// of interval lies in (oldSeq, newSeq]). Keep-one: on a batch that crosses
|
|
256
|
+
// several boundaries we still write just the latest state at newSeq. No-op
|
|
257
|
+
// when snapshots are disabled or the state can't be serialized.
|
|
258
|
+
let maybeWriteSnapshot = (id, idStr, ~oldSeq, ~newSeq, state) =>
|
|
259
|
+
switch snapshotConfig {
|
|
260
|
+
| Some({interval}) if interval > 0 && newSeq / interval > oldSeq / interval =>
|
|
261
|
+
switch encodeState(state) {
|
|
262
|
+
| Some(stateJson) =>
|
|
263
|
+
fireSnapshotWrite(id, idStr, {EventLog.seqNr: newSeq, state: stateJson, schemaHash: stateSchemaHash})
|
|
264
|
+
| None =>
|
|
265
|
+
EffectLogger.logWarn(
|
|
266
|
+
~comp,
|
|
267
|
+
`snapshot skipped (state not serializable): id=${idStr}, seq=${newSeq->Int.toString}`,
|
|
268
|
+
)->Effect.runSync
|
|
269
|
+
}
|
|
270
|
+
| _ => ()
|
|
271
|
+
}
|
|
272
|
+
|
|
98
273
|
// Reports per-command outcomes on the inline side-channels and produces the
|
|
99
274
|
// per-reference Ok/Error array consumed by SQS-style consumers. Domain rejections
|
|
100
275
|
// are always Ok(reference) — SQS deletes the message because retry would not help —
|
|
@@ -133,13 +308,22 @@ module Make = (
|
|
|
133
308
|
) => {
|
|
134
309
|
let idStr = id->Spec.Id.toString
|
|
135
310
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
311
|
+
// Warm path: seed from the in-process cache and skip both the snapshot read
|
|
312
|
+
// and the event-log replay; the OCC append below fences any staleness. Cold
|
|
313
|
+
// path (`coldReadState`): seed from the persisted snapshot when enabled, else
|
|
314
|
+
// full replay.
|
|
315
|
+
let readState = switch replayCache->Lru.get(idStr) {
|
|
316
|
+
| Some((state, seqNr)) =>
|
|
317
|
+
EffectLogger.logInfo(
|
|
318
|
+
~comp,
|
|
319
|
+
`replay skipped (cached): id=${idStr}, seq=${seqNr->Int.toString}`,
|
|
320
|
+
)->Effect.map(_ => (state, seqNr))
|
|
321
|
+
| None => coldReadState(id)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
readState
|
|
141
325
|
->Effect.flatMap(((initialState, sequenceNr)) => {
|
|
142
|
-
let (
|
|
326
|
+
let (finalState, outcomes) =
|
|
143
327
|
topicItemsForId->Array.reduce((initialState, []), processCommand)
|
|
144
328
|
|
|
145
329
|
let eventsToAppend =
|
|
@@ -154,6 +338,9 @@ module Make = (
|
|
|
154
338
|
|
|
155
339
|
switch eventsToAppend {
|
|
156
340
|
| [] =>
|
|
341
|
+
// Nothing appended, but the read snapshot is valid — keep it warm for
|
|
342
|
+
// the next command (also refreshes recency on a cache hit).
|
|
343
|
+
replayCache->Lru.put(idStr, (initialState, sequenceNr))
|
|
157
344
|
let perRef = reportFinalOutcomes(
|
|
158
345
|
outcomes,
|
|
159
346
|
~entityId=idStr,
|
|
@@ -178,6 +365,13 @@ module Make = (
|
|
|
178
365
|
appendResult =>
|
|
179
366
|
switch appendResult {
|
|
180
367
|
| Ok(_) =>
|
|
368
|
+
// The post-decide fold state IS the post-append replay result;
|
|
369
|
+
// cache it so the next command for this id skips the replay.
|
|
370
|
+
let newSeq = sequenceNr + generatedEvents'->Array.length
|
|
371
|
+
replayCache->Lru.put(idStr, (finalState, newSeq))
|
|
372
|
+
// Persist a snapshot if this append crossed an interval boundary
|
|
373
|
+
// (fire-and-forget — never blocks or fails the command).
|
|
374
|
+
maybeWriteSnapshot(id, idStr, ~oldSeq=sequenceNr, ~newSeq, finalState)
|
|
181
375
|
let perRef = reportFinalOutcomes(
|
|
182
376
|
outcomes,
|
|
183
377
|
~entityId=idStr,
|
|
@@ -186,6 +380,9 @@ module Make = (
|
|
|
186
380
|
)
|
|
187
381
|
EffectLogger.logInfo(~comp, `append: id=${idStr}`)->Effect.map(_ => Ok(perRef))
|
|
188
382
|
| Error(EventLog.Conflict) =>
|
|
383
|
+
// Another writer advanced the stream past our (possibly cached)
|
|
384
|
+
// sequenceNr — drop the entry so the retry replays cold.
|
|
385
|
+
replayCache->Lru.invalidate(idStr)
|
|
189
386
|
// Signal the outer replay+re-decide retry loop (which matches
|
|
190
387
|
// Error(_)); the string is an internal marker, no longer a
|
|
191
388
|
// cross-component substring sentinel.
|
|
@@ -193,6 +390,10 @@ module Make = (
|
|
|
193
390
|
_ => Error("conflict"),
|
|
194
391
|
)
|
|
195
392
|
| Error(EventLog.StorageFailure(msg)) =>
|
|
393
|
+
// Appends are atomic, so the read snapshot is likely still valid —
|
|
394
|
+
// but a storage error means we can't be sure what committed;
|
|
395
|
+
// drop the entry so the next attempt reads authoritative state.
|
|
396
|
+
replayCache->Lru.invalidate(idStr)
|
|
196
397
|
let perRef = reportFinalOutcomes(
|
|
197
398
|
outcomes,
|
|
198
399
|
~entityId=idStr,
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import HashObject from "hash-object";
|
|
3
5
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
|
+
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
7
|
+
import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
|
|
4
8
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
9
|
import * as Effect from "effect/Effect";
|
|
6
10
|
import * as Stream from "effect/Stream";
|
|
11
|
+
import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
|
|
12
|
+
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
13
|
+
import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
|
|
7
14
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
8
15
|
import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
|
|
9
16
|
import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
|
|
17
|
+
import * as SchemaWalker$ReventlessCore from "../../plugin/component/SchemaWalker.res.mjs";
|
|
10
18
|
import * as CommandTopic_Helpers$ReventlessCore from "../CommandTopic/CommandTopic_Helpers.res.mjs";
|
|
11
19
|
|
|
12
20
|
function Make(Spec) {
|
|
@@ -64,6 +72,127 @@ function Make(Spec) {
|
|
|
64
72
|
]])
|
|
65
73
|
];
|
|
66
74
|
};
|
|
75
|
+
let replayCache = Lru$ReventlessCore.make(100);
|
|
76
|
+
let resetCache = () => Lru$ReventlessCore.clear(replayCache);
|
|
77
|
+
let snapshotConfig = Behavior.snapshot;
|
|
78
|
+
let stateSchemaHash = snapshotConfig !== undefined ? HashObject(Object.fromEntries([[
|
|
79
|
+
"state",
|
|
80
|
+
SchemaWalker$ReventlessCore.describeSchema(snapshotConfig.stateSchema)
|
|
81
|
+
]]), {
|
|
82
|
+
algorithm: "sha256"
|
|
83
|
+
}) : "";
|
|
84
|
+
let encodeState = state => {
|
|
85
|
+
if (snapshotConfig === undefined) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
let json;
|
|
89
|
+
try {
|
|
90
|
+
json = S.reverseConvertToJsonOrThrow(state, snapshotConfig.stateSchema);
|
|
91
|
+
} catch (exn) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
return json;
|
|
95
|
+
};
|
|
96
|
+
let decodeState = json => {
|
|
97
|
+
if (snapshotConfig === undefined) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
let state;
|
|
101
|
+
try {
|
|
102
|
+
state = S.parseJsonOrThrow(json, snapshotConfig.stateSchema);
|
|
103
|
+
} catch (exn) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
return Primitive_option.some(state);
|
|
107
|
+
};
|
|
108
|
+
let coldReadState = id => {
|
|
109
|
+
let idStr = Spec.Id.toString(id);
|
|
110
|
+
let seedEffect = snapshotConfig !== undefined ? Effect.map(Effect.promise(() => Ops.eventLog.latestSnapshot(id)), snapResult => {
|
|
111
|
+
if (snapResult.TAG === "Ok") {
|
|
112
|
+
let snap = snapResult._0;
|
|
113
|
+
if (snap === undefined) {
|
|
114
|
+
return [
|
|
115
|
+
Behavior.initialState,
|
|
116
|
+
0
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
if (snap.schemaHash === stateSchemaHash) {
|
|
120
|
+
let state = decodeState(snap.state);
|
|
121
|
+
if (state !== undefined) {
|
|
122
|
+
return [
|
|
123
|
+
Primitive_option.valFromOption(state),
|
|
124
|
+
snap.seqNr
|
|
125
|
+
];
|
|
126
|
+
} else {
|
|
127
|
+
Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot ignored (undecodable): id=` + idStr + `, seq=` + snap.seqNr.toString() + ` — full replay`));
|
|
128
|
+
return [
|
|
129
|
+
Behavior.initialState,
|
|
130
|
+
0
|
|
131
|
+
];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot ignored (schema drift): id=` + idStr + `, seq=` + snap.seqNr.toString() + ` — full replay`));
|
|
135
|
+
return [
|
|
136
|
+
Behavior.initialState,
|
|
137
|
+
0
|
|
138
|
+
];
|
|
139
|
+
}
|
|
140
|
+
Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot read failed (ignored): id=` + idStr + `: ` + snapResult._0 + ` — full replay`));
|
|
141
|
+
return [
|
|
142
|
+
Behavior.initialState,
|
|
143
|
+
0
|
|
144
|
+
];
|
|
145
|
+
}) : Effect.succeed([
|
|
146
|
+
Behavior.initialState,
|
|
147
|
+
0
|
|
148
|
+
]);
|
|
149
|
+
return Effect.flatMap(seedEffect, param => {
|
|
150
|
+
let seedSeq = param[1];
|
|
151
|
+
return Effect.tap(Stream.runFold(Ops.eventLog.replayStream(id, seedSeq), [
|
|
152
|
+
param[0],
|
|
153
|
+
seedSeq
|
|
154
|
+
], (param, ev) => [
|
|
155
|
+
Behavior.evolve(param[0], ev),
|
|
156
|
+
param[1] + 1 | 0
|
|
157
|
+
]), param => {
|
|
158
|
+
let n = param[1];
|
|
159
|
+
let detail = seedSeq > 0 ? `(snapshot@` + seedSeq.toString() + `, ` + (n - seedSeq | 0).toString() + ` delta event(s))` : n.toString() + ` event(s)`;
|
|
160
|
+
return EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay: id=` + idStr + `, ` + detail);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
let maybeWriteSnapshot = (id, idStr, oldSeq, newSeq, state) => {
|
|
165
|
+
if (snapshotConfig === undefined) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
let interval = snapshotConfig.interval;
|
|
169
|
+
if (!(interval > 0 && Primitive_int.div(newSeq, interval) > Primitive_int.div(oldSeq, interval))) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
let stateJson = encodeState(state);
|
|
173
|
+
if (stateJson !== undefined) {
|
|
174
|
+
let snap = {
|
|
175
|
+
seqNr: newSeq,
|
|
176
|
+
state: stateJson,
|
|
177
|
+
schemaHash: stateSchemaHash
|
|
178
|
+
};
|
|
179
|
+
Stdlib_Promise.$$catch(Ops.eventLog.writeSnapshot(id, snap).then(result => {
|
|
180
|
+
if (result.TAG === "Ok") {
|
|
181
|
+
Effect.runSync(EffectLogger$ReventlessCore.logDebug(comp, undefined, `snapshot written: id=` + idStr + `, seq=` + snap.seqNr.toString()));
|
|
182
|
+
} else {
|
|
183
|
+
Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot write failed (ignored): id=` + idStr + `: ` + result._0));
|
|
184
|
+
}
|
|
185
|
+
return Promise.resolve();
|
|
186
|
+
}), err => {
|
|
187
|
+
let msg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(err), Stdlib_JsExn.message), "unknown");
|
|
188
|
+
Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot write threw (ignored): id=` + idStr + `: ` + msg));
|
|
189
|
+
return Promise.resolve();
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
} else {
|
|
193
|
+
return Effect.runSync(EffectLogger$ReventlessCore.logWarn(comp, undefined, `snapshot skipped (state not serializable): id=` + idStr + `, seq=` + newSeq.toString()));
|
|
194
|
+
}
|
|
195
|
+
};
|
|
67
196
|
let reportFinalOutcomes = (outcomes, entityId, appendSucceeded, appendedEventCount, appendErrorDetailOpt) => {
|
|
68
197
|
let appendErrorDetail = appendErrorDetailOpt !== undefined ? appendErrorDetailOpt : "";
|
|
69
198
|
return outcomes.map(param => {
|
|
@@ -102,19 +231,27 @@ function Make(Spec) {
|
|
|
102
231
|
};
|
|
103
232
|
let replayProcessAppend = (id, topicItemsForId) => {
|
|
104
233
|
let idStr = Spec.Id.toString(id);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
234
|
+
let match = Lru$ReventlessCore.get(replayCache, idStr);
|
|
235
|
+
let readState;
|
|
236
|
+
if (match !== undefined) {
|
|
237
|
+
let seqNr = match[1];
|
|
238
|
+
let state = match[0];
|
|
239
|
+
readState = Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `replay skipped (cached): id=` + idStr + `, seq=` + seqNr.toString()), () => [
|
|
240
|
+
state,
|
|
241
|
+
seqNr
|
|
242
|
+
]);
|
|
243
|
+
} else {
|
|
244
|
+
readState = coldReadState(id);
|
|
245
|
+
}
|
|
246
|
+
return Effect.flatMap(readState, param => {
|
|
112
247
|
let sequenceNr = param[1];
|
|
248
|
+
let initialState = param[0];
|
|
113
249
|
let match = Stdlib_Array.reduce(topicItemsForId, [
|
|
114
|
-
|
|
250
|
+
initialState,
|
|
115
251
|
[]
|
|
116
252
|
], processCommand);
|
|
117
253
|
let outcomes = match[1];
|
|
254
|
+
let finalState = match[0];
|
|
118
255
|
let eventsToAppend = outcomes.map(param => {
|
|
119
256
|
let meta = param[2];
|
|
120
257
|
let outcome = param[1];
|
|
@@ -139,6 +276,12 @@ function Make(Spec) {
|
|
|
139
276
|
let eventJsons = eventsToAppend.map(event$p => Message$ReventlessCore.encode(event$p.event, Spec.eventSchema));
|
|
140
277
|
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
278
|
if (appendResult.TAG === "Ok") {
|
|
279
|
+
let newSeq = sequenceNr + eventsToAppend.length | 0;
|
|
280
|
+
Lru$ReventlessCore.put(replayCache, idStr, [
|
|
281
|
+
finalState,
|
|
282
|
+
newSeq
|
|
283
|
+
]);
|
|
284
|
+
maybeWriteSnapshot(id, idStr, sequenceNr, newSeq, finalState);
|
|
142
285
|
let perRef = reportFinalOutcomes(outcomes, idStr, true, eventsToAppend.length, undefined);
|
|
143
286
|
return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `append: id=` + idStr), () => ({
|
|
144
287
|
TAG: "Ok",
|
|
@@ -147,12 +290,14 @@ function Make(Spec) {
|
|
|
147
290
|
}
|
|
148
291
|
let msg = appendResult._0;
|
|
149
292
|
if (typeof msg !== "object") {
|
|
293
|
+
Lru$ReventlessCore.invalidate(replayCache, idStr);
|
|
150
294
|
return Effect.map(EffectLogger$ReventlessCore.logWarn(comp, undefined, `conflict: id=` + idStr + `, will retry`), () => ({
|
|
151
295
|
TAG: "Error",
|
|
152
296
|
_0: "conflict"
|
|
153
297
|
}));
|
|
154
298
|
}
|
|
155
299
|
let msg$1 = msg._0;
|
|
300
|
+
Lru$ReventlessCore.invalidate(replayCache, idStr);
|
|
156
301
|
let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg$1);
|
|
157
302
|
return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg$1), () => ({
|
|
158
303
|
TAG: "Ok",
|
|
@@ -160,6 +305,10 @@ function Make(Spec) {
|
|
|
160
305
|
}));
|
|
161
306
|
});
|
|
162
307
|
}
|
|
308
|
+
Lru$ReventlessCore.put(replayCache, idStr, [
|
|
309
|
+
initialState,
|
|
310
|
+
sequenceNr
|
|
311
|
+
]);
|
|
163
312
|
let perRef = reportFinalOutcomes(outcomes, idStr, true, 0, undefined);
|
|
164
313
|
return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `no events produced: id=` + idStr), () => ({
|
|
165
314
|
TAG: "Ok",
|
|
@@ -198,7 +347,8 @@ function Make(Spec) {
|
|
|
198
347
|
}), prim => prim.flat()));
|
|
199
348
|
return {
|
|
200
349
|
Spec: Spec,
|
|
201
|
-
handleCommands: handleCommands
|
|
350
|
+
handleCommands: handleCommands,
|
|
351
|
+
resetCache: resetCache
|
|
202
352
|
};
|
|
203
353
|
});
|
|
204
354
|
}
|
|
@@ -206,4 +356,4 @@ function Make(Spec) {
|
|
|
206
356
|
export {
|
|
207
357
|
Make,
|
|
208
358
|
}
|
|
209
|
-
/*
|
|
359
|
+
/* S Not a pure module */
|
|
@@ -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;
|
|
@@ -19,10 +19,27 @@ type appendError =
|
|
|
19
19
|
| Conflict
|
|
20
20
|
| StorageFailure(string)
|
|
21
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
|
+
|
|
22
31
|
type append<'id, 'event> = (int, 'id, array<'event>) => promise<result<unit, appendError>>
|
|
23
32
|
type replay<'id, 'event> = 'id => promise<array<'event>>
|
|
24
|
-
|
|
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>
|
|
25
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>>
|
|
26
43
|
|
|
27
44
|
module type T = {
|
|
28
45
|
module Spec: ReventlessInfra.EventLog.T
|
|
@@ -32,6 +49,8 @@ module type T = {
|
|
|
32
49
|
replay: replay<Spec.Id.t, Spec.event>,
|
|
33
50
|
replayStream: replayStream<Spec.Id.t, Spec.event>,
|
|
34
51
|
appendStream: appendStream<Spec.Id.t, Spec.event>,
|
|
52
|
+
latestSnapshot: latestSnapshot<Spec.Id.t>,
|
|
53
|
+
writeSnapshot: writeSnapshot<Spec.Id.t>,
|
|
35
54
|
}
|
|
36
55
|
type component = component<operations>
|
|
37
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;
|
|
@@ -11,6 +11,8 @@ module type T = {
|
|
|
11
11
|
let replay: EventLog.replay<Spec.Id.t, Spec.event>
|
|
12
12
|
let replayStream: EventLog.replayStream<Spec.Id.t, Spec.event>
|
|
13
13
|
let appendStream: EventLog.appendStream<Spec.Id.t, Spec.event>
|
|
14
|
+
let latestSnapshot: EventLog.latestSnapshot<Spec.Id.t>
|
|
15
|
+
let writeSnapshot: EventLog.writeSnapshot<Spec.Id.t>
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
// Retry schedule for transient storage errors.
|
|
@@ -187,12 +189,19 @@ module Make = (Spec: ReventlessInfra.EventLog.T, Ops: Ops with module Spec = Spe
|
|
|
187
189
|
|
|
188
190
|
// Lazy streaming replay — wraps decodeEvent in Effect.sync so thrown exceptions
|
|
189
191
|
// surface through the stream's error channel rather than as unhandled exceptions.
|
|
190
|
-
let replayStream = id =>
|
|
191
|
-
Ops.storage.replayStream(id->Spec.Id.toString)
|
|
192
|
+
let replayStream = (id, ~fromSeq=?) =>
|
|
193
|
+
Ops.storage.replayStream(id->Spec.Id.toString, ~fromSeq=?fromSeq)
|
|
192
194
|
->Stream.mapEffect(json =>
|
|
193
195
|
Effect.sync(() => decodeEvent(id->Spec.Id.toString, json))
|
|
194
196
|
)
|
|
195
197
|
|
|
198
|
+
// Snapshot passthrough — the state stays JSON at this layer; the consumer
|
|
199
|
+
// (Aggregate_Callback) decodes it against its own state schema and hash-gates
|
|
200
|
+
// staleness, so a drifted snapshot degrades to full replay, never to an error.
|
|
201
|
+
let latestSnapshot = id => Ops.storage.latestSnapshot(id->Spec.Id.toString)
|
|
202
|
+
|
|
203
|
+
let writeSnapshot = (id, snap) => Ops.storage.writeSnapshot(id->Spec.Id.toString, snap)
|
|
204
|
+
|
|
196
205
|
// Streaming append — encodes each Spec.event to the {type, data} storage format
|
|
197
206
|
// and writes sequentially via the storage adapter.
|
|
198
207
|
// Accepts Spec.event items (symmetric with replayStream) to enable direct
|
|
@@ -186,7 +186,9 @@ function Make(Spec) {
|
|
|
186
186
|
let id$1 = Spec.Id.toString(id);
|
|
187
187
|
return eventsJson.map(json => decodeEvent(id$1, json));
|
|
188
188
|
};
|
|
189
|
-
let replayStream = id => Stream.mapEffect(Ops.storage.replayStream(Spec.Id.toString(id)), json => Effect$1.sync(() => decodeEvent(Spec.Id.toString(id), json)));
|
|
189
|
+
let replayStream = (id, fromSeq) => Stream.mapEffect(Ops.storage.replayStream(Spec.Id.toString(id), fromSeq), json => Effect$1.sync(() => decodeEvent(Spec.Id.toString(id), json)));
|
|
190
|
+
let latestSnapshot = id => Ops.storage.latestSnapshot(Spec.Id.toString(id));
|
|
191
|
+
let writeSnapshot = (id, snap) => Ops.storage.writeSnapshot(Spec.Id.toString(id), snap);
|
|
190
192
|
let appendStream = (startingSeqNr, id, stream) => Ops.storage.appendStream(startingSeqNr, Spec.Id.toString(id), Stream.map(stream, event => {
|
|
191
193
|
let json = Message$ReventlessCore.encode(event, Spec.eventSchema);
|
|
192
194
|
let match = Message$ReventlessCore.splitMessage(json);
|
|
@@ -206,7 +208,9 @@ function Make(Spec) {
|
|
|
206
208
|
append: append,
|
|
207
209
|
replay: replay,
|
|
208
210
|
replayStream: replayStream,
|
|
209
|
-
appendStream: appendStream
|
|
211
|
+
appendStream: appendStream,
|
|
212
|
+
latestSnapshot: latestSnapshot,
|
|
213
|
+
writeSnapshot: writeSnapshot
|
|
210
214
|
};
|
|
211
215
|
};
|
|
212
216
|
}
|