@reventlessdev/reventless-core 3.0.0-alpha.135 → 3.0.0-alpha.137
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/package.json +8 -8
- package/src/Message.res +43 -10
- package/src/Message.res.mjs +21 -4
- package/src/Projection.res +83 -55
- package/src/Projection.res.mjs +69 -57
- package/src/components/Aggregate/Aggregate_Callback.res +67 -9
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +42 -13
- package/src/components/DcbEventLog/DcbEventLog_Operations.res +3 -0
- package/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs +3 -1
- package/src/components/EventLog/EventLog.res +33 -2
- package/src/components/EventLog/EventLog_Adapter.res +3 -1
- package/src/components/EventLog/EventLog_Builder.res +4 -0
- package/src/components/EventLog/EventLog_Builder.res.mjs +3 -1
- package/src/components/EventLog/EventLog_Operations.res +48 -36
- package/src/components/EventLog/EventLog_Operations.res.mjs +36 -15
- package/src/components/EventLog/EventPublish_Callback.res +7 -0
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +26 -6
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +21 -6
- package/src/plugin/component/SchemaWalker.res +13 -1
- package/src/plugin/component/SchemaWalker.res.mjs +5 -4
- package/src/plugin/lifecycle/PluginBehavior.res +6 -0
- package/src/plugin/lifecycle/PluginBehavior.res.mjs +3 -0
- package/src/util/CommandPublisher.res +20 -10
- package/src/util/CommandPublisher.res.mjs +6 -10
- package/src/util/Logger.res +10 -0
- package/src/util/Logger.res.mjs +12 -2
- package/src/util/Util_Array.res +1 -1
- package/src/util/Util_Array.res.mjs +1 -1
- package/tests/aggregate/AggregateCacheTest.res +271 -0
- package/tests/aggregate/AggregateCacheTest.res.mjs +433 -0
- package/tests/aggregate/AggregateCallbackTest.res +4 -1
- package/tests/aggregate/AggregateCallbackTest.res.mjs +4 -1
- package/tests/aggregate/AggregateCausationTest.res +4 -1
- package/tests/aggregate/AggregateCausationTest.res.mjs +4 -1
- package/tests/aggregate/AggregateConflictTest.res +17 -6
- package/tests/aggregate/AggregateConflictTest.res.mjs +27 -4
- package/tests/aggregate/AggregateFixtures.res +13 -5
- package/tests/aggregate/AggregateFixtures.res.mjs +26 -3
- package/tests/aggregate/AggregateRejectionTest.res +4 -1
- package/tests/aggregate/AggregateRejectionTest.res.mjs +4 -1
- package/tests/eventlog/EventLogFixtures.res +15 -4
- package/tests/eventlog/EventLogFixtures.res.mjs +35 -5
- package/tests/eventlog/EventLogRetryTest.res +12 -11
- package/tests/eventlog/EventLogRetryTest.res.mjs +31 -7
- package/tests/logger/LoggerLazyTest.res +36 -0
- package/tests/logger/LoggerLazyTest.res.mjs +40 -0
- package/tests/projection/ProjectionOptimizeTest.res +38 -0
- package/tests/projection/ProjectionOptimizeTest.res.mjs +75 -0
- package/tests/util/CommandPublisherTest.res +56 -0
- package/tests/util/CommandPublisherTest.res.mjs +78 -0
|
@@ -11,19 +11,28 @@ 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.
|
|
17
19
|
// Exponential backoff: 100ms, ~200ms, ~400ms, ~800ms, ~1600ms — max 5 retries.
|
|
18
20
|
// Only retries on recognised transient error messages; permanent errors propagate immediately.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
// Retry only recognised transient storage failures — never a Conflict (a retry
|
|
22
|
+
// of the same append would just conflict again; the OCC retry happens a layer up
|
|
23
|
+
// via replay+re-decide).
|
|
24
|
+
let isTransient = (e: EventLog.appendError) =>
|
|
25
|
+
switch e {
|
|
26
|
+
| EventLog.Conflict => false
|
|
27
|
+
| StorageFailure(msg) =>
|
|
28
|
+
msg->String.includes("ThrottlingException") ||
|
|
29
|
+
msg->String.includes("ProvisionedThroughputExceededException") ||
|
|
30
|
+
msg->String.includes("ServiceUnavailable") ||
|
|
31
|
+
msg->String.includes("RequestLimitExceeded") ||
|
|
32
|
+
msg->String.includes("InternalServerError")
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let storageRetrySchedule: Schedule.t<(Duration.t, int), EventLog.appendError, unit> =
|
|
27
36
|
Schedule.exponential(Duration.millis(100))
|
|
28
37
|
->Schedule.jittered
|
|
29
38
|
->Schedule.intersect(Schedule.recurs(5))
|
|
@@ -112,44 +121,40 @@ module Make = (Spec: ReventlessInfra.EventLog.T, Ops: Ops with module Spec = Spe
|
|
|
112
121
|
}
|
|
113
122
|
}
|
|
114
123
|
|
|
115
|
-
// append returns result<unit,
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
124
|
+
// append returns result<unit, EventLog.appendError> — never throws.
|
|
125
|
+
// Transient storage failures are retried with exponential backoff (up to 5
|
|
126
|
+
// times); a Conflict is returned immediately (retried a layer up), and other
|
|
127
|
+
// failures propagate after retries are exhausted.
|
|
120
128
|
let append = async (sequenceNr, id, events') => {
|
|
121
129
|
let eventsJson = events'->encodeEvents'(id, sequenceNr)
|
|
122
130
|
let idStr = id->Spec.Id.toString
|
|
123
|
-
//
|
|
131
|
+
// A thrown storage error becomes a StorageFailure; a returned Error keeps its
|
|
132
|
+
// typed variant. The Effect fails with the typed appendError so the schedule
|
|
133
|
+
// can retry only transient failures, then we collapse the outcome back into a
|
|
134
|
+
// result (no Cause stringification / substring matching).
|
|
124
135
|
let storageEffect =
|
|
125
136
|
Effect.tryPromise(
|
|
126
|
-
~catch=(err: unknown) =>
|
|
137
|
+
~catch=(err: unknown) =>
|
|
138
|
+
EventLog.StorageFailure(Util.Error.messageFromUnknown(err, "storage error")),
|
|
127
139
|
() => Ops.storage.append(sequenceNr, idStr, eventsJson),
|
|
128
140
|
)
|
|
129
141
|
->Effect.flatMap(result =>
|
|
130
142
|
switch result {
|
|
131
|
-
| Ok(_) => Effect.succeed(
|
|
132
|
-
| Error(
|
|
143
|
+
| Ok(_) => Effect.succeed()
|
|
144
|
+
| Error(e) => Effect.fail(e)
|
|
133
145
|
}
|
|
134
146
|
)
|
|
135
147
|
->Effect.retry(storageRetrySchedule)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
)
|
|
147
|
-
// Propagate conflict errors without wrapping so callers can detect them
|
|
148
|
-
if isConflict(failMsg) {
|
|
149
|
-
Error("conflict")
|
|
150
|
-
} else {
|
|
151
|
-
Error(`EventLog: Error: Couldn't append for ${Spec.name}(${idStr}): ${failMsg}`)
|
|
152
|
-
}
|
|
148
|
+
->Effect.map(_ => Ok())
|
|
149
|
+
->Effect.catchAll(e => Effect.succeed(Error(e)))
|
|
150
|
+
switch await storageEffect->Effect.runPromise {
|
|
151
|
+
| Ok() =>
|
|
152
|
+
(await publishToEventTopic(id, events', eventsJson))->Result.mapError(msg => EventLog.StorageFailure(
|
|
153
|
+
msg,
|
|
154
|
+
))
|
|
155
|
+
| Error(EventLog.Conflict) => Error(EventLog.Conflict)
|
|
156
|
+
| Error(StorageFailure(msg)) =>
|
|
157
|
+
Error(EventLog.StorageFailure(`EventLog: Error: Couldn't append for ${Spec.name}(${idStr}): ${msg}`))
|
|
153
158
|
}
|
|
154
159
|
}
|
|
155
160
|
|
|
@@ -184,12 +189,19 @@ module Make = (Spec: ReventlessInfra.EventLog.T, Ops: Ops with module Spec = Spe
|
|
|
184
189
|
|
|
185
190
|
// Lazy streaming replay — wraps decodeEvent in Effect.sync so thrown exceptions
|
|
186
191
|
// surface through the stream's error channel rather than as unhandled exceptions.
|
|
187
|
-
let replayStream = id =>
|
|
188
|
-
Ops.storage.replayStream(id->Spec.Id.toString)
|
|
192
|
+
let replayStream = (id, ~fromSeq=?) =>
|
|
193
|
+
Ops.storage.replayStream(id->Spec.Id.toString, ~fromSeq=?fromSeq)
|
|
189
194
|
->Stream.mapEffect(json =>
|
|
190
195
|
Effect.sync(() => decodeEvent(id->Spec.Id.toString, json))
|
|
191
196
|
)
|
|
192
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
|
+
|
|
193
205
|
// Streaming append — encodes each Spec.event to the {type, data} storage format
|
|
194
206
|
// and writes sequentially via the storage adapter.
|
|
195
207
|
// Accepts Spec.event items (symmetric with replayStream) to enable direct
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
-
import * as Exit from "@reventlessdev/rescript-effect/src/Exit.res.mjs";
|
|
4
3
|
import * as Effect from "@reventlessdev/rescript-effect/src/Effect.res.mjs";
|
|
5
4
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
6
|
-
import * as Exit$1 from "effect/Exit";
|
|
7
5
|
import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
|
|
8
|
-
import * as Cause from "effect/Cause";
|
|
9
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
|
|
10
8
|
import * as Effect$1 from "effect/Effect";
|
|
11
9
|
import * as Stream from "effect/Stream";
|
|
12
10
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
@@ -18,7 +16,11 @@ import * as Util_Error$ReventlessCore from "../../util/Util_Error.res.mjs";
|
|
|
18
16
|
import * as EffectLogger$ReventlessCore from "../../util/EffectLogger.res.mjs";
|
|
19
17
|
import * as EventPublish_Callback$ReventlessCore from "./EventPublish_Callback.res.mjs";
|
|
20
18
|
|
|
21
|
-
function isTransient(
|
|
19
|
+
function isTransient(e) {
|
|
20
|
+
if (typeof e !== "object") {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
let msg = e._0;
|
|
22
24
|
if (msg.includes("ThrottlingException") || msg.includes("ProvisionedThroughputExceededException") || msg.includes("ServiceUnavailable") || msg.includes("RequestLimitExceeded")) {
|
|
23
25
|
return true;
|
|
24
26
|
} else {
|
|
@@ -111,27 +113,42 @@ function Make(Spec) {
|
|
|
111
113
|
let append = async (sequenceNr, id, events$p) => {
|
|
112
114
|
let eventsJson = encodeEvents$p(events$p, id, sequenceNr);
|
|
113
115
|
let idStr = Spec.Id.toString(id);
|
|
114
|
-
let storageEffect = Effect$1.retry(Effect$1.flatMap(Effect.tryPromise(err =>
|
|
116
|
+
let storageEffect = Effect$1.catchAll(Effect$1.map(Effect$1.retry(Effect$1.flatMap(Effect.tryPromise(err => ({
|
|
117
|
+
TAG: "StorageFailure",
|
|
118
|
+
_0: Util_Error$ReventlessCore.messageFromUnknown(err, "storage error")
|
|
119
|
+
}), () => Ops.storage.append(sequenceNr, idStr, eventsJson)), result => {
|
|
115
120
|
if (result.TAG === "Ok") {
|
|
116
121
|
return Effect$1.succeed();
|
|
117
122
|
} else {
|
|
118
123
|
return Effect$1.fail(result._0);
|
|
119
124
|
}
|
|
120
|
-
}), storageRetrySchedule)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
}), storageRetrySchedule), () => ({
|
|
126
|
+
TAG: "Ok",
|
|
127
|
+
_0: undefined
|
|
128
|
+
})), e => Effect$1.succeed({
|
|
129
|
+
TAG: "Error",
|
|
130
|
+
_0: e
|
|
131
|
+
}));
|
|
132
|
+
let match = await Effect$1.runPromise(storageEffect);
|
|
133
|
+
if (match.TAG === "Ok") {
|
|
134
|
+
return Stdlib_Result.mapError(await publishToEventTopic(id, events$p, eventsJson), msg => ({
|
|
135
|
+
TAG: "StorageFailure",
|
|
136
|
+
_0: msg
|
|
137
|
+
}));
|
|
124
138
|
}
|
|
125
|
-
let
|
|
126
|
-
if (
|
|
139
|
+
let msg = match._0;
|
|
140
|
+
if (typeof msg !== "object") {
|
|
127
141
|
return {
|
|
128
142
|
TAG: "Error",
|
|
129
|
-
_0: "
|
|
143
|
+
_0: "Conflict"
|
|
130
144
|
};
|
|
131
145
|
} else {
|
|
132
146
|
return {
|
|
133
147
|
TAG: "Error",
|
|
134
|
-
_0:
|
|
148
|
+
_0: {
|
|
149
|
+
TAG: "StorageFailure",
|
|
150
|
+
_0: `EventLog: Error: Couldn't append for ` + Spec.name + `(` + idStr + `): ` + msg._0
|
|
151
|
+
}
|
|
135
152
|
};
|
|
136
153
|
}
|
|
137
154
|
};
|
|
@@ -169,7 +186,9 @@ function Make(Spec) {
|
|
|
169
186
|
let id$1 = Spec.Id.toString(id);
|
|
170
187
|
return eventsJson.map(json => decodeEvent(id$1, json));
|
|
171
188
|
};
|
|
172
|
-
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);
|
|
173
192
|
let appendStream = (startingSeqNr, id, stream) => Ops.storage.appendStream(startingSeqNr, Spec.Id.toString(id), Stream.map(stream, event => {
|
|
174
193
|
let json = Message$ReventlessCore.encode(event, Spec.eventSchema);
|
|
175
194
|
let match = Message$ReventlessCore.splitMessage(json);
|
|
@@ -189,7 +208,9 @@ function Make(Spec) {
|
|
|
189
208
|
append: append,
|
|
190
209
|
replay: replay,
|
|
191
210
|
replayStream: replayStream,
|
|
192
|
-
appendStream: appendStream
|
|
211
|
+
appendStream: appendStream,
|
|
212
|
+
latestSnapshot: latestSnapshot,
|
|
213
|
+
writeSnapshot: writeSnapshot
|
|
193
214
|
};
|
|
194
215
|
};
|
|
195
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
|
]);
|
|
@@ -38,7 +38,19 @@ let rec describeSchema = (schema: S.t<unknown>): string =>
|
|
|
38
38
|
| _ => "unknown"
|
|
39
39
|
}
|
|
40
40
|
"array<" ++ itemDesc ++ ">"
|
|
41
|
-
| Object(
|
|
41
|
+
| Object({properties}) =>
|
|
42
|
+
// Recurse into nested records. Rendering every object as the literal
|
|
43
|
+
// "object" made the structural hash blind to nested field changes — a
|
|
44
|
+
// nested schema edit produced an identical SHA256, so drift detection
|
|
45
|
+
// silently missed it. Emit a sorted `{name:type,…}` description instead.
|
|
46
|
+
let inner =
|
|
47
|
+
properties
|
|
48
|
+
->Dict.toArray
|
|
49
|
+
->Array.filter(((name, _)) => name != "TAG")
|
|
50
|
+
->Array.toSorted(((a, _), (b, _)) => String.compare(a, b))
|
|
51
|
+
->Array.map(((name, s)) => name ++ ":" ++ describeSchema(s))
|
|
52
|
+
->Array.join(",")
|
|
53
|
+
"{" ++ inner ++ "}"
|
|
42
54
|
| _ => "unknown"
|
|
43
55
|
}
|
|
44
56
|
|
|
@@ -23,7 +23,8 @@ function describeSchema(schema) {
|
|
|
23
23
|
itemDesc = additionalItems === "strip" || additionalItems === "strict" ? "unknown" : describeSchema(additionalItems);
|
|
24
24
|
return "array<" + itemDesc + ">";
|
|
25
25
|
case "object" :
|
|
26
|
-
|
|
26
|
+
let inner = Object.entries(schema.properties).filter(param => param[0] !== "TAG").toSorted((param, param$1) => Primitive_string.compare(param[0], param$1[0])).map(param => param[0] + ":" + describeSchema(param[1])).join(",");
|
|
27
|
+
return "{" + inner + "}";
|
|
27
28
|
case "union" :
|
|
28
29
|
let nonNull = schema.anyOf.filter(v => {
|
|
29
30
|
switch (v.type) {
|
|
@@ -34,9 +35,9 @@ function describeSchema(schema) {
|
|
|
34
35
|
return true;
|
|
35
36
|
}
|
|
36
37
|
});
|
|
37
|
-
let inner = nonNull[0];
|
|
38
|
-
if (inner !== undefined) {
|
|
39
|
-
return "option<" + describeSchema(inner) + ">";
|
|
38
|
+
let inner$1 = nonNull[0];
|
|
39
|
+
if (inner$1 !== undefined) {
|
|
40
|
+
return "option<" + describeSchema(inner$1) + ">";
|
|
40
41
|
} else {
|
|
41
42
|
return "unknown";
|
|
42
43
|
}
|
|
@@ -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,
|
|
@@ -63,7 +63,12 @@ module Make = (Spec: Spec, Config: Config) => {
|
|
|
63
63
|
~comp="CommandPublisher",
|
|
64
64
|
`send: bufferSize: ${bufferSizeStr}, chunk: ${chunkCountStr}, size: ${sizeStr}`,
|
|
65
65
|
)->Effect.runSync
|
|
66
|
-
|
|
66
|
+
// Take the first `size` commands and remove them from the buffer.
|
|
67
|
+
// (`toSpliced` is non-mutating and returns the *remainder*, so the buffer
|
|
68
|
+
// never drained: send() recursed on the same buffer and published the
|
|
69
|
+
// wrong slice. `slice` captures the chunk; `splice` drains it in place.)
|
|
70
|
+
let commandsToSend = buffer->Array.slice(~start=0, ~end=size)
|
|
71
|
+
buffer->Array.splice(~start=0, ~remove=size, ~insert=[])
|
|
67
72
|
let promise = Config.publishCommands(Spec.name, commandsToSend->toJsons)
|
|
68
73
|
running := Some(promise)
|
|
69
74
|
switch await promise {
|
|
@@ -82,7 +87,12 @@ module Make = (Spec: Spec, Config: Config) => {
|
|
|
82
87
|
| SendAllInOneChunk =>
|
|
83
88
|
let size = buffer->Array.length
|
|
84
89
|
if size > 0 {
|
|
85
|
-
|
|
90
|
+
// Take the first `size` commands and remove them from the buffer.
|
|
91
|
+
// (`toSpliced` is non-mutating and returns the *remainder*, so the buffer
|
|
92
|
+
// never drained: send() recursed on the same buffer and published the
|
|
93
|
+
// wrong slice. `slice` captures the chunk; `splice` drains it in place.)
|
|
94
|
+
let commandsToSend = buffer->Array.slice(~start=0, ~end=size)
|
|
95
|
+
buffer->Array.splice(~start=0, ~remove=size, ~insert=[])
|
|
86
96
|
let promise = Config.publishCommands(Spec.name, commandsToSend->toJsons)
|
|
87
97
|
running := Some(promise)
|
|
88
98
|
switch await promise {
|
|
@@ -115,19 +125,19 @@ module Make = (Spec: Spec, Config: Config) => {
|
|
|
115
125
|
|
|
116
126
|
let clear = () => {
|
|
117
127
|
EffectLogger.logDebug(~comp="CommandPublisher", "clear")->Effect.runSync
|
|
118
|
-
|
|
128
|
+
// Truncate the whole buffer. `removeInPlace(0)` is `splice(0, 1)` — it drops
|
|
129
|
+
// only the first element, so the rest leaked out on a later publish.
|
|
130
|
+
buffer->Array.splice(~start=0, ~remove=buffer->Array.length, ~insert=[])
|
|
119
131
|
flush := false
|
|
120
132
|
}
|
|
121
133
|
|
|
122
134
|
let flush = async () => {
|
|
123
135
|
EffectLogger.logDebug(~comp="CommandPublisher", "flush")->Effect.runSync
|
|
124
136
|
flush := true
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
}
|
|
137
|
+
// `send` already awaits any in-flight publish (finishRunning) before draining
|
|
138
|
+
// the buffer, and flush:=true forces a partial final chunk. The previous
|
|
139
|
+
// in-flight arm looped on `running.contents->Option.isNone` — dead, since this
|
|
140
|
+
// path only ran when running was Some — so buffered commands were never sent.
|
|
141
|
+
await send()
|
|
132
142
|
}
|
|
133
143
|
}
|
|
@@ -54,7 +54,8 @@ function Make(Spec) {
|
|
|
54
54
|
if (size <= 0) {
|
|
55
55
|
return;
|
|
56
56
|
}
|
|
57
|
-
let commandsToSend = buffer.
|
|
57
|
+
let commandsToSend = buffer.slice(0, size);
|
|
58
|
+
buffer.splice(0, size);
|
|
58
59
|
let promise = Config.publishCommands(Spec.name, toJsons(commandsToSend));
|
|
59
60
|
running.contents = promise;
|
|
60
61
|
let exit = 0;
|
|
@@ -88,7 +89,8 @@ function Make(Spec) {
|
|
|
88
89
|
let bufferSizeStr = buffer.length.toString();
|
|
89
90
|
let chunkCountStr = chunkCount.contents.toString();
|
|
90
91
|
Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, `send: bufferSize: ` + bufferSizeStr + `, chunk: ` + chunkCountStr + `, size: ` + sizeStr));
|
|
91
|
-
let commandsToSend$1 = buffer.
|
|
92
|
+
let commandsToSend$1 = buffer.slice(0, size$1);
|
|
93
|
+
buffer.splice(0, size$1);
|
|
92
94
|
let promise$1 = Config.publishCommands(Spec.name, toJsons(commandsToSend$1));
|
|
93
95
|
running.contents = promise$1;
|
|
94
96
|
let exit$1 = 0;
|
|
@@ -127,19 +129,13 @@ function Make(Spec) {
|
|
|
127
129
|
};
|
|
128
130
|
let clear = () => {
|
|
129
131
|
Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, "clear"));
|
|
130
|
-
buffer.splice(0,
|
|
132
|
+
buffer.splice(0, buffer.length);
|
|
131
133
|
flush.contents = false;
|
|
132
134
|
};
|
|
133
135
|
let flush$1 = async () => {
|
|
134
136
|
Effect.runSync(EffectLogger$ReventlessCore.logDebug("CommandPublisher", undefined, "flush"));
|
|
135
137
|
flush.contents = true;
|
|
136
|
-
|
|
137
|
-
if (match === undefined) {
|
|
138
|
-
return await send();
|
|
139
|
-
}
|
|
140
|
-
while (Stdlib_Option.isNone(running.contents)) {
|
|
141
|
-
await finishRunning();
|
|
142
|
-
};
|
|
138
|
+
return await send();
|
|
143
139
|
};
|
|
144
140
|
return {
|
|
145
141
|
buffer: buffer,
|
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() {
|
package/src/util/Util_Array.res
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
let containsByPredicate: (array<'a>, 'a => bool) => bool = (arr, predicate) =>
|
|
2
|
-
arr->Array.find(predicate)->Option.
|
|
2
|
+
arr->Array.find(predicate)->Option.isSome
|