@reventlessdev/reventless-core 3.0.0-alpha.136 → 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 +12 -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 +61 -6
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +37 -9
- 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/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,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,
|
|
@@ -186,6 +234,9 @@ module Make = (
|
|
|
186
234
|
)
|
|
187
235
|
EffectLogger.logInfo(~comp, `append: id=${idStr}`)->Effect.map(_ => Ok(perRef))
|
|
188
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)
|
|
189
240
|
// Signal the outer replay+re-decide retry loop (which matches
|
|
190
241
|
// Error(_)); the string is an internal marker, no longer a
|
|
191
242
|
// cross-component substring sentinel.
|
|
@@ -193,6 +244,10 @@ module Make = (
|
|
|
193
244
|
_ => Error("conflict"),
|
|
194
245
|
)
|
|
195
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)
|
|
196
251
|
let perRef = reportFinalOutcomes(
|
|
197
252
|
outcomes,
|
|
198
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",
|
|
@@ -147,12 +168,14 @@ function Make(Spec) {
|
|
|
147
168
|
}
|
|
148
169
|
let msg = appendResult._0;
|
|
149
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
174
|
_0: "conflict"
|
|
153
175
|
}));
|
|
154
176
|
}
|
|
155
177
|
let msg$1 = msg._0;
|
|
178
|
+
Lru$ReventlessCore.invalidate(replayCache, idStr);
|
|
156
179
|
let perRef$1 = reportFinalOutcomes(outcomes, idStr, false, 0, msg$1);
|
|
157
180
|
return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed: id=` + idStr + `: ` + msg$1), () => ({
|
|
158
181
|
TAG: "Ok",
|
|
@@ -160,6 +183,10 @@ function Make(Spec) {
|
|
|
160
183
|
}));
|
|
161
184
|
});
|
|
162
185
|
}
|
|
186
|
+
Lru$ReventlessCore.put(replayCache, idStr, [
|
|
187
|
+
initialState,
|
|
188
|
+
sequenceNr
|
|
189
|
+
]);
|
|
163
190
|
let perRef = reportFinalOutcomes(outcomes, idStr, true, 0, undefined);
|
|
164
191
|
return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `no events produced: id=` + idStr), () => ({
|
|
165
192
|
TAG: "Ok",
|
|
@@ -198,7 +225,8 @@ function Make(Spec) {
|
|
|
198
225
|
}), prim => prim.flat()));
|
|
199
226
|
return {
|
|
200
227
|
Spec: Spec,
|
|
201
|
-
handleCommands: handleCommands
|
|
228
|
+
handleCommands: handleCommands,
|
|
229
|
+
resetCache: resetCache
|
|
202
230
|
};
|
|
203
231
|
});
|
|
204
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;
|
|
@@ -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
|
}
|
|
@@ -4,6 +4,13 @@ type publishedEvent = {
|
|
|
4
4
|
eventCount: int,
|
|
5
5
|
eventsJson: array<JSON.t>,
|
|
6
6
|
meta: Reventless.Message.meta,
|
|
7
|
+
// Per-event metas, parallel to eventsJson. Populated by publishers whose
|
|
8
|
+
// eventsJson entries do NOT carry the meta fields inline (DcbEventLog: bare
|
|
9
|
+
// {TAG, ...data} payloads) so hook consumers can still correlate per event
|
|
10
|
+
// (e.g. the local projection checkpoint resolves pending appends by msgId).
|
|
11
|
+
// Aggregate EventLog eventsJson are flat stored events with meta at the top
|
|
12
|
+
// level, so it omits this field.
|
|
13
|
+
metas?: array<Reventless.Message.meta>,
|
|
7
14
|
}
|
|
8
15
|
|
|
9
16
|
type beforePublishHook = publishedEvent => promise<publishedEvent>
|
|
@@ -199,13 +199,33 @@ module Make = (
|
|
|
199
199
|
Some(Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec))
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
-
|
|
202
|
+
// Fail closed: a query that can't be serialized yields `None`, not the empty
|
|
203
|
+
// string. The old `""` fallback made every unserializable query collide on
|
|
204
|
+
// one shared cache slot, so one slice's folded state could seed another's —
|
|
205
|
+
// a silent correctness bug. `None` simply bypasses the cache (cold read, no
|
|
206
|
+
// write) for that operation.
|
|
207
|
+
let cacheKey = query->JSON.stringifyAny
|
|
208
|
+
let cacheGet = () =>
|
|
209
|
+
switch cacheKey {
|
|
210
|
+
| Some(k) => Lru.get(projectionCache, k)
|
|
211
|
+
| None => None
|
|
212
|
+
}
|
|
213
|
+
let cachePut = v =>
|
|
214
|
+
switch cacheKey {
|
|
215
|
+
| Some(k) => Lru.put(projectionCache, k, v)
|
|
216
|
+
| None => ()
|
|
217
|
+
}
|
|
218
|
+
let cacheInvalidate = () =>
|
|
219
|
+
switch cacheKey {
|
|
220
|
+
| Some(k) => Lru.invalidate(projectionCache, k)
|
|
221
|
+
| None => ()
|
|
222
|
+
}
|
|
203
223
|
|
|
204
224
|
// Working seed across retries: the `(state, head)` the read folds from.
|
|
205
225
|
// Starts from the projection cache (warm) or empty (cold); the conflict
|
|
206
226
|
// branch re-seeds it with the just-read `(state, head)` so each retry reads
|
|
207
227
|
// only the delta the conflicting writer added rather than full history.
|
|
208
|
-
let seed = ref(
|
|
228
|
+
let seed = ref(cacheGet())
|
|
209
229
|
|
|
210
230
|
let rec attempt = (~retries) => {
|
|
211
231
|
let (baseState, afterPos) = switch seed.contents {
|
|
@@ -275,7 +295,7 @@ module Make = (
|
|
|
275
295
|
switch Behavior.decide(state, command'.command) {
|
|
276
296
|
| Ok(newEvents) if newEvents->Array.length == 0 =>
|
|
277
297
|
// No append, but the read snapshot is valid — cache it for the next command.
|
|
278
|
-
|
|
298
|
+
cachePut((state, headPosition))
|
|
279
299
|
CommandTopic_Helpers.reportAccepted(
|
|
280
300
|
cmdJson.meta.msgId,
|
|
281
301
|
switch entityId {
|
|
@@ -326,7 +346,7 @@ module Make = (
|
|
|
326
346
|
// Cache the decided-on state at the read head (NOT including the
|
|
327
347
|
// events we just appended, and NOT the returned position): the
|
|
328
348
|
// next command's delta read picks our events up from `headPosition`.
|
|
329
|
-
|
|
349
|
+
cachePut((state, headPosition))
|
|
330
350
|
CommandTopic_Helpers.reportAccepted(
|
|
331
351
|
cmdJson.meta.msgId,
|
|
332
352
|
switch entityId {
|
|
@@ -354,7 +374,7 @@ module Make = (
|
|
|
354
374
|
} else {
|
|
355
375
|
// Retries exhausted — drop any cached snapshot so the next
|
|
356
376
|
// command for this entity takes the cold full-read path.
|
|
357
|
-
|
|
377
|
+
cacheInvalidate()
|
|
358
378
|
let errorCode = err->String.startsWith("Conflict") ? "Conflict" : "AppendFailed"
|
|
359
379
|
// A surfaced Conflict means the 3-retry loop could not absorb
|
|
360
380
|
// the contention — the operator signal for "this slice is hot"
|
|
@@ -375,7 +395,7 @@ module Make = (
|
|
|
375
395
|
)
|
|
376
396
|
| Error(error) =>
|
|
377
397
|
// Business-rule rejection — no append, but the read snapshot is valid; cache it.
|
|
378
|
-
|
|
398
|
+
cachePut((state, headPosition))
|
|
379
399
|
let errorJson = error->S.reverseConvertToJsonOrThrow(Spec.errorSchema)
|
|
380
400
|
let errorCode = errorJson->Message.variantNameOfJson
|
|
381
401
|
let (_, payloadDict) = errorJson->Message.splitMessage
|
|
@@ -82,9 +82,24 @@ function Make(Spec) {
|
|
|
82
82
|
let tags = DcbTag$Reventless.extractTags(Spec.commandSchema, command$p.command);
|
|
83
83
|
entityId = DcbTag$Reventless.getCompositePartitionKeyValue(tags, derivedPartitionTag._0);
|
|
84
84
|
}
|
|
85
|
-
let cacheKey =
|
|
85
|
+
let cacheKey = JSON.stringify(query);
|
|
86
|
+
let cacheGet = () => {
|
|
87
|
+
if (cacheKey !== undefined) {
|
|
88
|
+
return Lru$ReventlessCore.get(projectionCache, cacheKey);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
let cachePut = v => {
|
|
92
|
+
if (cacheKey !== undefined) {
|
|
93
|
+
return Lru$ReventlessCore.put(projectionCache, cacheKey, v);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
let cacheInvalidate = () => {
|
|
97
|
+
if (cacheKey !== undefined) {
|
|
98
|
+
return Lru$ReventlessCore.invalidate(projectionCache, cacheKey);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
86
101
|
let seed = {
|
|
87
|
-
contents:
|
|
102
|
+
contents: cacheGet()
|
|
88
103
|
};
|
|
89
104
|
let attempt = retries => {
|
|
90
105
|
let match = seed.contents;
|
|
@@ -150,7 +165,7 @@ function Make(Spec) {
|
|
|
150
165
|
if (newEvents.TAG === "Ok") {
|
|
151
166
|
let newEvents$1 = newEvents._0;
|
|
152
167
|
if (newEvents$1.length === 0) {
|
|
153
|
-
|
|
168
|
+
cachePut([
|
|
154
169
|
state,
|
|
155
170
|
headPosition
|
|
156
171
|
]);
|
|
@@ -204,7 +219,7 @@ function Make(Spec) {
|
|
|
204
219
|
};
|
|
205
220
|
return Effect.flatMap(Effect.flatMap(EffectLogger$ReventlessCore.logInfo(comp, eventJsons, `produced ` + eventCount + ` event(s): [` + eventDetails + `]`), () => Effect.promise(() => dcbEventLog.append(rawEvents, condition))), appendResult => {
|
|
206
221
|
if (appendResult.TAG === "Ok") {
|
|
207
|
-
|
|
222
|
+
cachePut([
|
|
208
223
|
state,
|
|
209
224
|
headPosition
|
|
210
225
|
]);
|
|
@@ -228,7 +243,7 @@ function Make(Spec) {
|
|
|
228
243
|
Metrics$ReventlessCore.emitCount("AppendRetry", Spec.name, undefined);
|
|
229
244
|
return Effect.flatMap(EffectLogger$ReventlessCore.logWarn(comp, undefined, `append failed (retrying ` + ((3 - retries | 0) + 1 | 0).toString() + `/` + (3).toString() + `): ` + err), () => attempt(retries - 1 | 0));
|
|
230
245
|
}
|
|
231
|
-
|
|
246
|
+
cacheInvalidate();
|
|
232
247
|
let errorCode = err.startsWith("Conflict") ? "Conflict" : "AppendFailed";
|
|
233
248
|
if (errorCode === "Conflict") {
|
|
234
249
|
Metrics$ReventlessCore.emitCount("AppendConflict", Spec.name, undefined);
|
|
@@ -243,7 +258,7 @@ function Make(Spec) {
|
|
|
243
258
|
}));
|
|
244
259
|
});
|
|
245
260
|
}
|
|
246
|
-
|
|
261
|
+
cachePut([
|
|
247
262
|
state,
|
|
248
263
|
headPosition
|
|
249
264
|
]);
|
|
@@ -30,6 +30,12 @@ type state = {
|
|
|
30
30
|
|
|
31
31
|
let initialState = {current: None, known: Dict.make()}
|
|
32
32
|
|
|
33
|
+
// The @@reventless.behavior snapshot injection is gated to Aggregate/ folders;
|
|
34
|
+
// this framework-internal behavior lives under plugin/lifecycle, so the
|
|
35
|
+
// Behavior.T field is satisfied manually. The admin Plugin aggregate's history
|
|
36
|
+
// is short (lifecycle transitions), so persisted snapshots would buy nothing.
|
|
37
|
+
let snapshot = None
|
|
38
|
+
|
|
33
39
|
let atomicCounter = None
|
|
34
40
|
|
|
35
41
|
let uiRegisterEvents = (pluginId, manifest) =>
|
|
@@ -366,6 +366,8 @@ function evolve(state, event) {
|
|
|
366
366
|
|
|
367
367
|
let Spec;
|
|
368
368
|
|
|
369
|
+
let snapshot;
|
|
370
|
+
|
|
369
371
|
let atomicCounter;
|
|
370
372
|
|
|
371
373
|
let moduleUrl = "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginBehavior.res.mjs";
|
|
@@ -376,6 +378,7 @@ export {
|
|
|
376
378
|
knownVersionSchema,
|
|
377
379
|
stateSchema,
|
|
378
380
|
initialState,
|
|
381
|
+
snapshot,
|
|
379
382
|
atomicCounter,
|
|
380
383
|
uiRegisterEvents,
|
|
381
384
|
uiDeregisterEvents,
|
package/src/util/Logger.res
CHANGED
|
@@ -37,6 +37,11 @@ type t = {
|
|
|
37
37
|
info: logFn,
|
|
38
38
|
warn: logFn,
|
|
39
39
|
error: logFn,
|
|
40
|
+
// Lazy debug: the message thunk runs only when debug output is actually
|
|
41
|
+
// enabled. On a hot path (e.g. per-projection-action logging) this avoids
|
|
42
|
+
// building — and serializing state into — a string that is then dropped at
|
|
43
|
+
// the default Info level.
|
|
44
|
+
debugLazy: (~comp: string=?, unit => string) => unit,
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
type level = Debug | Info | Warn | Error
|
|
@@ -172,6 +177,10 @@ let makeLogger = (~minLevel=Info): t => {
|
|
|
172
177
|
info: (~comp=?, ~data=?, msg) => log(Info, ~comp?, ~data?, msg),
|
|
173
178
|
warn: (~comp=?, ~data=?, msg) => log(Warn, ~comp?, ~data?, msg),
|
|
174
179
|
error: (~comp=?, ~data=?, msg) => log(Error, ~comp?, ~data?, msg),
|
|
180
|
+
debugLazy: (~comp=?, makeMsg) =>
|
|
181
|
+
if shouldLog(Debug) {
|
|
182
|
+
emit(~level=Debug, ~comp?, makeMsg())
|
|
183
|
+
},
|
|
175
184
|
}
|
|
176
185
|
}
|
|
177
186
|
|
|
@@ -180,6 +189,7 @@ let silent: t = {
|
|
|
180
189
|
info: (~comp as _=?, ~data as _=?, _) => (),
|
|
181
190
|
warn: (~comp as _=?, ~data as _=?, _) => (),
|
|
182
191
|
error: (~comp as _=?, ~data as _=?, _) => (),
|
|
192
|
+
debugLazy: (~comp as _=?, _) => (),
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
// Reads LOG_LEVEL from process.env and returns a configured logger.
|
package/src/util/Logger.res.mjs
CHANGED
|
@@ -156,7 +156,12 @@ function makeLogger(minLevelOpt) {
|
|
|
156
156
|
debug: (comp, data, msg) => log("Debug", comp, data, msg),
|
|
157
157
|
info: (comp, data, msg) => log("Info", comp, data, msg),
|
|
158
158
|
warn: (comp, data, msg) => log("Warn", comp, data, msg),
|
|
159
|
-
error: (comp, data, msg) => log("Error", comp, data, msg)
|
|
159
|
+
error: (comp, data, msg) => log("Error", comp, data, msg),
|
|
160
|
+
debugLazy: (comp, makeMsg) => {
|
|
161
|
+
if (shouldLog("Debug")) {
|
|
162
|
+
return emit("Debug", comp, undefined, undefined, undefined, makeMsg());
|
|
163
|
+
}
|
|
164
|
+
}
|
|
160
165
|
};
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -176,11 +181,16 @@ function silent_error(param, param$1, param$2) {
|
|
|
176
181
|
|
|
177
182
|
}
|
|
178
183
|
|
|
184
|
+
function silent_debugLazy(param, param$1) {
|
|
185
|
+
|
|
186
|
+
}
|
|
187
|
+
|
|
179
188
|
let silent = {
|
|
180
189
|
debug: silent_debug,
|
|
181
190
|
info: silent_info,
|
|
182
191
|
warn: silent_warn,
|
|
183
|
-
error: silent_error
|
|
192
|
+
error: silent_error,
|
|
193
|
+
debugLazy: silent_debugLazy
|
|
184
194
|
};
|
|
185
195
|
|
|
186
196
|
function fromEnv() {
|