@reventlessdev/reventless-core 3.0.0-alpha.137 → 3.0.0-alpha.139
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 +14 -0
- package/package.json +5 -5
- package/src/components/Aggregate/Aggregate_Callback.res +158 -12
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +131 -9
- package/src/components/DcbEventLog/DcbEventLog_Adapter.res +1 -1
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +10 -5
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +24 -14
- package/tests/aggregate/AggregateSnapshotTest.res +357 -0
- package/tests/aggregate/AggregateSnapshotTest.res.mjs +645 -0
- package/tests/dcb/DcbFixtures.res +2 -2
- package/tests/dcb/DcbFixtures.res.mjs +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# 3.0.0-alpha.139 (2026-07-05)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **postgres:** add reventless-postgres backend + local-platform integration ([6913200](https://github.com/ReventlessDev/reventless-core/commit/69132001f9271e832a5af33416acd5b645feaf47))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.138 (2026-07-04)
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* **core:** wire aggregate snapshots into the command callback (aggregate-snapshotting plan, step 5) ([09fda78](https://github.com/ReventlessDev/reventless-core/commit/09fda78a19bb758482111cebedfdb115d0ec7a35))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.137 (2026-07-03)
|
|
7
21
|
|
|
8
22
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.139",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
"sury": "11.0.0-alpha.4",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.24",
|
|
32
|
-
"@reventlessdev/rescript-
|
|
32
|
+
"@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
|
|
33
33
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.5",
|
|
34
34
|
"@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
|
|
35
|
-
"@reventlessdev/rescript-
|
|
35
|
+
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
|
|
37
|
-
"@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
|
|
38
37
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.13",
|
|
38
|
+
"@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
|
|
39
39
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.23",
|
|
40
40
|
"@reventlessdev/reventless-spec": "3.0.0-alpha.64",
|
|
41
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
41
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.85"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "^12.3.0",
|
|
@@ -122,6 +122,154 @@ module Make = (
|
|
|
122
122
|
|
|
123
123
|
let resetCache = () => replayCache->Lru.clear
|
|
124
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
|
+
|
|
125
273
|
// Reports per-command outcomes on the inline side-channels and produces the
|
|
126
274
|
// per-reference Ok/Error array consumed by SQS-style consumers. Domain rejections
|
|
127
275
|
// are always Ok(reference) — SQS deletes the message because retry would not help —
|
|
@@ -160,20 +308,17 @@ module Make = (
|
|
|
160
308
|
) => {
|
|
161
309
|
let idStr = id->Spec.Id.toString
|
|
162
310
|
|
|
163
|
-
// Warm path: seed from the
|
|
164
|
-
// OCC append below fences any staleness. Cold
|
|
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.
|
|
165
315
|
let readState = switch replayCache->Lru.get(idStr) {
|
|
166
316
|
| Some((state, seqNr)) =>
|
|
167
317
|
EffectLogger.logInfo(
|
|
168
318
|
~comp,
|
|
169
319
|
`replay skipped (cached): id=${idStr}, seq=${seqNr->Int.toString}`,
|
|
170
320
|
)->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
|
-
)
|
|
321
|
+
| None => coldReadState(id)
|
|
177
322
|
}
|
|
178
323
|
|
|
179
324
|
readState
|
|
@@ -222,10 +367,11 @@ module Make = (
|
|
|
222
367
|
| Ok(_) =>
|
|
223
368
|
// The post-decide fold state IS the post-append replay result;
|
|
224
369
|
// cache it so the next command for this id skips the replay.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
)
|
|
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)
|
|
229
375
|
let perRef = reportFinalOutcomes(
|
|
230
376
|
outcomes,
|
|
231
377
|
~entityId=idStr,
|
|
@@ -1,13 +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";
|
|
7
13
|
import * as Lru$ReventlessCore from "../../util/Lru.res.mjs";
|
|
8
14
|
import * as Message$ReventlessCore from "../../Message.res.mjs";
|
|
9
15
|
import * as LogFormat$ReventlessCore from "../../util/LogFormat.res.mjs";
|
|
10
16
|
import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
|
|
17
|
+
import * as SchemaWalker$ReventlessCore from "../../plugin/component/SchemaWalker.res.mjs";
|
|
11
18
|
import * as CommandTopic_Helpers$ReventlessCore from "../CommandTopic/CommandTopic_Helpers.res.mjs";
|
|
12
19
|
|
|
13
20
|
function Make(Spec) {
|
|
@@ -67,6 +74,125 @@ function Make(Spec) {
|
|
|
67
74
|
};
|
|
68
75
|
let replayCache = Lru$ReventlessCore.make(100);
|
|
69
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
|
+
};
|
|
70
196
|
let reportFinalOutcomes = (outcomes, entityId, appendSucceeded, appendedEventCount, appendErrorDetailOpt) => {
|
|
71
197
|
let appendErrorDetail = appendErrorDetailOpt !== undefined ? appendErrorDetailOpt : "";
|
|
72
198
|
return outcomes.map(param => {
|
|
@@ -115,13 +241,7 @@ function Make(Spec) {
|
|
|
115
241
|
seqNr
|
|
116
242
|
]);
|
|
117
243
|
} else {
|
|
118
|
-
readState =
|
|
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)`));
|
|
244
|
+
readState = coldReadState(id);
|
|
125
245
|
}
|
|
126
246
|
return Effect.flatMap(readState, param => {
|
|
127
247
|
let sequenceNr = param[1];
|
|
@@ -156,10 +276,12 @@ function Make(Spec) {
|
|
|
156
276
|
let eventJsons = eventsToAppend.map(event$p => Message$ReventlessCore.encode(event$p.event, Spec.eventSchema));
|
|
157
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 => {
|
|
158
278
|
if (appendResult.TAG === "Ok") {
|
|
279
|
+
let newSeq = sequenceNr + eventsToAppend.length | 0;
|
|
159
280
|
Lru$ReventlessCore.put(replayCache, idStr, [
|
|
160
281
|
finalState,
|
|
161
|
-
|
|
282
|
+
newSeq
|
|
162
283
|
]);
|
|
284
|
+
maybeWriteSnapshot(id, idStr, sequenceNr, newSeq, finalState);
|
|
163
285
|
let perRef = reportFinalOutcomes(outcomes, idStr, true, eventsToAppend.length, undefined);
|
|
164
286
|
return Effect.map(EffectLogger$ReventlessCore.logInfo(comp, undefined, `append: id=` + idStr), () => ({
|
|
165
287
|
TAG: "Ok",
|
|
@@ -234,4 +356,4 @@ function Make(Spec) {
|
|
|
234
356
|
export {
|
|
235
357
|
Make,
|
|
236
358
|
}
|
|
237
|
-
/*
|
|
359
|
+
/* S Not a pure module */
|
|
@@ -27,7 +27,7 @@ type operations = {
|
|
|
27
27
|
append: (
|
|
28
28
|
array<rawStoredEvent>,
|
|
29
29
|
~condition: Reventless.DcbTag.appendCondition=?,
|
|
30
|
-
) => promise<result<Reventless.DcbTag.sequencePosition,
|
|
30
|
+
) => promise<result<Reventless.DcbTag.sequencePosition, ReventlessInfra.DcbEventLog.appendError>>,
|
|
31
31
|
readStream: (
|
|
32
32
|
~query: Reventless.DcbTag.query,
|
|
33
33
|
~after: Reventless.DcbTag.sequencePosition=?,
|
|
@@ -358,6 +358,12 @@ module Make = (
|
|
|
358
358
|
_ => Ok("ok"),
|
|
359
359
|
)
|
|
360
360
|
| Error(err) =>
|
|
361
|
+
// Typed classification (was substring-matching the error string,
|
|
362
|
+
// which disagreed with the backends' casing).
|
|
363
|
+
let (errorCode, errDetail) = switch err {
|
|
364
|
+
| ReventlessInfra.DcbEventLog.Conflict => ("Conflict", "conflict: condition check failed")
|
|
365
|
+
| StorageFailure(msg) => ("AppendFailed", msg)
|
|
366
|
+
}
|
|
361
367
|
if retries > 0 {
|
|
362
368
|
// Re-seed from the just-read snapshot so the retry reads only
|
|
363
369
|
// the delta the conflicting writer added, not full history.
|
|
@@ -369,13 +375,12 @@ module Make = (
|
|
|
369
375
|
EffectLogger.logWarn(
|
|
370
376
|
~comp,
|
|
371
377
|
`append failed (retrying ${(maxRetries - retries + 1)
|
|
372
|
-
->Int.toString}/${maxRetries->Int.toString}): ${
|
|
378
|
+
->Int.toString}/${maxRetries->Int.toString}): ${errDetail}`,
|
|
373
379
|
)->Effect.flatMap(_ => attempt(~retries=retries - 1))
|
|
374
380
|
} else {
|
|
375
381
|
// Retries exhausted — drop any cached snapshot so the next
|
|
376
382
|
// command for this entity takes the cold full-read path.
|
|
377
383
|
cacheInvalidate()
|
|
378
|
-
let errorCode = err->String.startsWith("Conflict") ? "Conflict" : "AppendFailed"
|
|
379
384
|
// A surfaced Conflict means the 3-retry loop could not absorb
|
|
380
385
|
// the contention — the operator signal for "this slice is hot"
|
|
381
386
|
// (consider sharding / async — Issue 10, §4 of the analysis).
|
|
@@ -384,12 +389,12 @@ module Make = (
|
|
|
384
389
|
}
|
|
385
390
|
CommandTopic_Helpers.reportRejected(
|
|
386
391
|
cmdJson.meta.msgId,
|
|
387
|
-
{errorCode, errorDetail:
|
|
392
|
+
{errorCode, errorDetail: errDetail},
|
|
388
393
|
)
|
|
389
394
|
EffectLogger.logError(
|
|
390
395
|
~comp,
|
|
391
|
-
`append failed, retries exhausted: ${
|
|
392
|
-
)->Effect.map(_ => Error(
|
|
396
|
+
`append failed, retries exhausted: ${errDetail}`,
|
|
397
|
+
)->Effect.map(_ => Error(errDetail))
|
|
393
398
|
}
|
|
394
399
|
},
|
|
395
400
|
)
|
|
@@ -235,27 +235,37 @@ function Make(Spec) {
|
|
|
235
235
|
}));
|
|
236
236
|
}
|
|
237
237
|
let err = appendResult._0;
|
|
238
|
+
let match;
|
|
239
|
+
match = typeof err !== "object" ? [
|
|
240
|
+
"Conflict",
|
|
241
|
+
"conflict: condition check failed"
|
|
242
|
+
] : [
|
|
243
|
+
"AppendFailed",
|
|
244
|
+
err._0
|
|
245
|
+
];
|
|
246
|
+
let errDetail = match[1];
|
|
247
|
+
let errorCode = match[0];
|
|
238
248
|
if (retries > 0) {
|
|
239
249
|
seed.contents = [
|
|
240
250
|
state,
|
|
241
251
|
headPosition
|
|
242
252
|
];
|
|
243
253
|
Metrics$ReventlessCore.emitCount("AppendRetry", Spec.name, undefined);
|
|
244
|
-
return Effect.flatMap(EffectLogger$ReventlessCore.logWarn(comp, undefined, `append failed (retrying ` + ((3 - retries | 0) + 1 | 0).toString() + `/` + (3).toString() + `): ` +
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
254
|
+
return Effect.flatMap(EffectLogger$ReventlessCore.logWarn(comp, undefined, `append failed (retrying ` + ((3 - retries | 0) + 1 | 0).toString() + `/` + (3).toString() + `): ` + errDetail), () => attempt(retries - 1 | 0));
|
|
255
|
+
} else {
|
|
256
|
+
cacheInvalidate();
|
|
257
|
+
if (errorCode === "Conflict") {
|
|
258
|
+
Metrics$ReventlessCore.emitCount("AppendConflict", Spec.name, undefined);
|
|
259
|
+
}
|
|
260
|
+
CommandTopic_Helpers$ReventlessCore.reportRejected(cmdJson.meta.msgId, {
|
|
261
|
+
errorCode: errorCode,
|
|
262
|
+
errorDetail: errDetail
|
|
263
|
+
});
|
|
264
|
+
return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed, retries exhausted: ` + errDetail), () => ({
|
|
265
|
+
TAG: "Error",
|
|
266
|
+
_0: errDetail
|
|
267
|
+
}));
|
|
250
268
|
}
|
|
251
|
-
CommandTopic_Helpers$ReventlessCore.reportRejected(cmdJson.meta.msgId, {
|
|
252
|
-
errorCode: errorCode,
|
|
253
|
-
errorDetail: err
|
|
254
|
-
});
|
|
255
|
-
return Effect.map(EffectLogger$ReventlessCore.logError(comp, undefined, `append failed, retries exhausted: ` + err), () => ({
|
|
256
|
-
TAG: "Error",
|
|
257
|
-
_0: err
|
|
258
|
-
}));
|
|
259
269
|
});
|
|
260
270
|
}
|
|
261
271
|
cachePut([
|