@reventlessdev/reventless-core 3.0.0-alpha.283 → 3.0.0-alpha.285
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 +16 -0
- package/package.json +7 -7
- package/src/Message.res.mjs +3 -0
- package/src/components/Aggregate/Aggregate_Callback.res +116 -39
- package/src/components/Aggregate/Aggregate_Callback.res.mjs +96 -37
- package/src/components/CommandGenerator/CommandGenerator_Callback.res +9 -8
- package/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs +10 -8
- package/src/components/CommandTopic/CommandTopic.res.mjs +3 -0
- package/src/components/CommandTopic/CommandTopic_Helpers.res +37 -1
- package/src/components/CommandTopic/CommandTopic_Helpers.res.mjs +29 -0
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +191 -141
- package/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs +66 -12
- package/src/plugin/component/Plugin_Helpers.res +33 -5
- package/src/plugin/component/Plugin_Helpers.res.mjs +45 -4
- package/tests/commandgenerator/OwnerActingTest.res +601 -0
- package/tests/commandgenerator/OwnerActingTest.res.mjs +1018 -0
- package/tests/commandgenerator/OwnerStampingTest.res +90 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +87 -0
- package/tests/commandtopic/CommandOutcomeHookTest.res +1 -0
- package/tests/commandtopic/CommandOutcomeHookTest.res.mjs +11 -1
|
@@ -27,26 +27,25 @@ function clearCommandInterceptor() {
|
|
|
27
27
|
commandInterceptorHook.contents = undefined;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function stampOwnerFields(obj, commandSchema, command,
|
|
30
|
+
function stampOwnerFields(obj, commandSchema, command, scope, serviceName) {
|
|
31
31
|
let ownerFields = Owner$Reventless.variantFieldNames(commandSchema, command);
|
|
32
32
|
if (ownerFields.length === 0) {
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
if (typeof why !== "object") {
|
|
35
|
+
if (typeof scope !== "object") {
|
|
37
36
|
return;
|
|
38
37
|
}
|
|
39
|
-
switch (
|
|
38
|
+
switch (scope.TAG) {
|
|
40
39
|
case "Elevated" :
|
|
41
40
|
return;
|
|
42
41
|
case "Owned" :
|
|
43
|
-
let userId =
|
|
42
|
+
let userId = scope.userId;
|
|
44
43
|
ownerFields.forEach(field => {
|
|
45
44
|
obj[field] = userId;
|
|
46
45
|
});
|
|
47
46
|
return;
|
|
48
47
|
case "Unidentified" :
|
|
49
|
-
return Plugin_ResolverError$ReventlessCore.throwCallerFault(`Forbidden: ` + serviceName + `.` + command + ` records an owner, but the caller could not be identified (` +
|
|
48
|
+
return Plugin_ResolverError$ReventlessCore.throwCallerFault(`Forbidden: ` + serviceName + `.` + command + ` records an owner, but the caller could not be identified (` + scope._0 + `)`);
|
|
50
49
|
}
|
|
51
50
|
}
|
|
52
51
|
|
|
@@ -63,19 +62,22 @@ function makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, com
|
|
|
63
62
|
let stripIdFromParams = stripIdFromParamsOpt !== undefined ? stripIdFromParamsOpt : true;
|
|
64
63
|
return payload => Effect.flatMap(Effect.tap(Effect.sync(() => {
|
|
65
64
|
let msgId = Message$ReventlessCore.uuid();
|
|
65
|
+
let scope = OwnerScope$Reventless.resolve(payload.identity, undefined);
|
|
66
66
|
let meta_time = Message$ReventlessCore.nowAsISOString();
|
|
67
67
|
let meta_ip = Stdlib_Option.getOr(payload.meta.ip.shift(), "");
|
|
68
68
|
let meta_user = payload.meta.user;
|
|
69
|
+
let meta_callerClaim = OwnerScope$Reventless.toClaim(scope);
|
|
69
70
|
let meta = {
|
|
70
71
|
service: serviceName,
|
|
71
72
|
time: meta_time,
|
|
72
73
|
ip: meta_ip,
|
|
73
74
|
user: meta_user,
|
|
74
75
|
msgId: msgId,
|
|
75
|
-
correlationId: msgId
|
|
76
|
+
correlationId: msgId,
|
|
77
|
+
callerClaim: meta_callerClaim
|
|
76
78
|
};
|
|
77
79
|
let obj = Stdlib_Option.flatMap(JSON.stringify(payload.arguments), jsonString => Stdlib_JSON.Decode.object(JSON.parse(jsonString)));
|
|
78
|
-
let params = obj !== undefined ? (stripIdFromParams ? Stdlib_Dict.$$delete(obj, "id") : undefined, dropNullArguments(obj), stampOwnerFields(obj, commandSchema, payload.command,
|
|
80
|
+
let params = obj !== undefined ? (stripIdFromParams ? Stdlib_Dict.$$delete(obj, "id") : undefined, dropNullArguments(obj), stampOwnerFields(obj, commandSchema, payload.command, scope, serviceName), Object.entries(obj)) : Stdlib_JsError.throwWithMessage("Couldn't decode:" + Stdlib_Option.getOr(JSON.stringify(payload.arguments), "<payload.arguments>"));
|
|
79
81
|
let commandStr = payload.command;
|
|
80
82
|
let match = params.length;
|
|
81
83
|
let commandJson = match !== 0 ? Object.fromEntries([[
|
|
@@ -35,6 +35,8 @@ let acceptedResultChannel = CommandTopic_Helpers$ReventlessCore.acceptedResultCh
|
|
|
35
35
|
|
|
36
36
|
let rejectedResultChannel = CommandTopic_Helpers$ReventlessCore.rejectedResultChannel;
|
|
37
37
|
|
|
38
|
+
let ownershipRefusal = CommandTopic_Helpers$ReventlessCore.ownershipRefusal;
|
|
39
|
+
|
|
38
40
|
let reportAccepted = CommandTopic_Helpers$ReventlessCore.reportAccepted;
|
|
39
41
|
|
|
40
42
|
let reportRejected = CommandTopic_Helpers$ReventlessCore.reportRejected;
|
|
@@ -64,6 +66,7 @@ export {
|
|
|
64
66
|
fireCommandOutcome,
|
|
65
67
|
acceptedResultChannel,
|
|
66
68
|
rejectedResultChannel,
|
|
69
|
+
ownershipRefusal,
|
|
67
70
|
reportAccepted,
|
|
68
71
|
reportRejected,
|
|
69
72
|
commandOutcomeToJson,
|
|
@@ -25,8 +25,11 @@ Why a command was refused.
|
|
|
25
25
|
which is the model working. `InfrastructureFailure` is the decision never landing: an event-log
|
|
26
26
|
append that failed outright or exhausted its conflict retries. Both arrive on `reportRejected`,
|
|
27
27
|
so without this a consumer would read a broken event log as a business rejection.
|
|
28
|
+
|
|
29
|
+
`AccessRefusal` is the handler refusing before `decide` runs, because the caller does not
|
|
30
|
+
own what the command acts on (`OwnerScope.decideActing`).
|
|
28
31
|
*/
|
|
29
|
-
type refusalCause = DomainRejection | InfrastructureFailure
|
|
32
|
+
type refusalCause = DomainRejection | InfrastructureFailure | AccessRefusal
|
|
30
33
|
|
|
31
34
|
/**
|
|
32
35
|
What became of one command. Constructors are prefixed to stay clear of `commandOutcome`'s
|
|
@@ -93,6 +96,39 @@ let acceptedResultChannel: ref<option<(string, acceptedResult) => unit>> = ref(N
|
|
|
93
96
|
// `Rejected` outcomes that carry the real error code and detail.
|
|
94
97
|
let rejectedResultChannel: ref<option<(string, rejectedResult) => unit>> = ref(None)
|
|
95
98
|
|
|
99
|
+
/**
|
|
100
|
+
Turn an `acting` verdict into the refusal a command handler reports, or `None` to let
|
|
101
|
+
`decide` run.
|
|
102
|
+
|
|
103
|
+
Shared by both handlers so they refuse in the same words. `errorCode` is `"Forbidden"`,
|
|
104
|
+
the code an `@authorize` refusal carries, so a client handles both alike. `isDefect` marks
|
|
105
|
+
a refusal that is the data's or the slice's fault rather than the caller's.
|
|
106
|
+
*/
|
|
107
|
+
type ownershipRefusal = {rejected: rejectedResult, isDefect: bool}
|
|
108
|
+
|
|
109
|
+
let ownershipRefusal = (
|
|
110
|
+
verdict: Reventless.OwnerScope.acting,
|
|
111
|
+
~component: string,
|
|
112
|
+
~command: string,
|
|
113
|
+
): option<ownershipRefusal> => {
|
|
114
|
+
let refuse = (~isDefect=false, why) => Some({
|
|
115
|
+
rejected: {errorCode: "Forbidden", errorDetail: `${component}.${command}: ${why}`},
|
|
116
|
+
isDefect,
|
|
117
|
+
})
|
|
118
|
+
switch verdict {
|
|
119
|
+
| MayAct => None
|
|
120
|
+
| NotTheOwner(_) => refuse("the caller does not own what this command acts on")
|
|
121
|
+
| CallerUnidentified => refuse("the caller could not be identified")
|
|
122
|
+
| ConflictingOwners(owners) =>
|
|
123
|
+
refuse(
|
|
124
|
+
~isDefect=true,
|
|
125
|
+
`the history records ${owners->Array.length->Int.toString} different owners`,
|
|
126
|
+
)
|
|
127
|
+
| OwnerUnreadable =>
|
|
128
|
+
refuse(~isDefect=true, "the partition this command acts on could not be named")
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
96
132
|
// `~component` is the owning component's `Spec.name`: an outcome isn't interpretable without
|
|
97
133
|
// knowing which component produced it, and the side-channel (keyed per inline dispatch) never
|
|
98
134
|
// had to say. `~cause` is required rather than defaulted so a new rejection site has to state
|
|
@@ -46,6 +46,34 @@ let rejectedResultChannel = {
|
|
|
46
46
|
contents: undefined
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
function ownershipRefusal(verdict, component, command) {
|
|
50
|
+
let refuse = (isDefectOpt, why) => {
|
|
51
|
+
let isDefect = isDefectOpt !== undefined ? isDefectOpt : false;
|
|
52
|
+
return {
|
|
53
|
+
rejected: {
|
|
54
|
+
errorCode: "Forbidden",
|
|
55
|
+
errorDetail: component + `.` + command + `: ` + why
|
|
56
|
+
},
|
|
57
|
+
isDefect: isDefect
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
if (typeof verdict === "object") {
|
|
61
|
+
if (verdict.TAG === "NotTheOwner") {
|
|
62
|
+
return refuse(undefined, "the caller does not own what this command acts on");
|
|
63
|
+
} else {
|
|
64
|
+
return refuse(true, `the history records ` + verdict._0.length.toString() + ` different owners`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
switch (verdict) {
|
|
68
|
+
case "MayAct" :
|
|
69
|
+
return;
|
|
70
|
+
case "CallerUnidentified" :
|
|
71
|
+
return refuse(undefined, "the caller could not be identified");
|
|
72
|
+
case "OwnerUnreadable" :
|
|
73
|
+
return refuse(true, "the partition this command acts on could not be named");
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
49
77
|
function reportAccepted(component, reference, result) {
|
|
50
78
|
Stdlib_Option.forEach(acceptedResultChannel.contents, cb => cb(reference, result));
|
|
51
79
|
fireCommandOutcome(component, reference, {
|
|
@@ -227,6 +255,7 @@ export {
|
|
|
227
255
|
fireCommandOutcome,
|
|
228
256
|
acceptedResultChannel,
|
|
229
257
|
rejectedResultChannel,
|
|
258
|
+
ownershipRefusal,
|
|
230
259
|
reportAccepted,
|
|
231
260
|
reportRejected,
|
|
232
261
|
commandOutcomeToJson,
|
|
@@ -102,6 +102,13 @@ module Make = (
|
|
|
102
102
|
~eventSchema=Spec.eventSchema,
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
+
// The consumed events whose `@owner` field names who owns what a command acts
|
|
106
|
+
// on, read off the slice's own schema. Empty for a slice that marks none.
|
|
107
|
+
let ownerFieldsByEventType = Reventless.Owner.fieldNamesByVariant(
|
|
108
|
+
Spec.consumedEventSchema->S.castToUnknown,
|
|
109
|
+
)
|
|
110
|
+
let actsOnOwned = ownerFieldsByEventType->Dict.keysToArray->Array.length > 0
|
|
111
|
+
|
|
105
112
|
// Extracts the entity id of a read event from its own tags, for logging
|
|
106
113
|
// which events the decision model was built from. Prefers this slice's
|
|
107
114
|
// partition key, but consumed events from other sources are tagged by their
|
|
@@ -153,9 +160,11 @@ module Make = (
|
|
|
153
160
|
// Capacity is fixed at 100 entries; a per-slice knob is a future refinement
|
|
154
161
|
// (see the plan's Step 4).
|
|
155
162
|
let projectionCacheCapacity = 100
|
|
163
|
+
// The owners folded so far ride along with the state: a delta read never
|
|
164
|
+
// sees the event that recorded them.
|
|
156
165
|
let projectionCache: Lru.t<
|
|
157
166
|
string,
|
|
158
|
-
(Behavior.state, option<Reventless.DcbTag.sequencePosition>),
|
|
167
|
+
(Behavior.state, option<Reventless.DcbTag.sequencePosition>, array<string>),
|
|
159
168
|
> = Lru.make(~capacity=projectionCacheCapacity)
|
|
160
169
|
|
|
161
170
|
let resetCache = () => projectionCache->Lru.clear
|
|
@@ -227,6 +236,30 @@ module Make = (
|
|
|
227
236
|
| Some(ByEventType(_)) | None => None
|
|
228
237
|
}
|
|
229
238
|
|
|
239
|
+
// The owner fields of a read event, when it lies in this command's own
|
|
240
|
+
// partition. A cross-partition read (a catalog product) never says who owns
|
|
241
|
+
// the thing the command acts on, even if its event marks an owner.
|
|
242
|
+
let ownerFieldsOf = (raw: ReventlessInfra.DcbEventLog.rawSequencedEvent) =>
|
|
243
|
+
switch (partitionTag, entityId) {
|
|
244
|
+
| (Some(pt), Some(eid))
|
|
245
|
+
if actsOnOwned && raw.tags->Reventless.DcbTag.partitionValueOfTags(pt) == eid =>
|
|
246
|
+
ownerFieldsByEventType->Dict.get(raw.eventType)->Option.getOr([])
|
|
247
|
+
| _ => []
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Before `decide`: an owner-marked slice refuses a caller who does not own
|
|
251
|
+
// the partition. `None` lets `decide` run.
|
|
252
|
+
let ownershipRefusal = (owners: array<string>) =>
|
|
253
|
+
actsOnOwned
|
|
254
|
+
? Reventless.OwnerScope.decideActing(
|
|
255
|
+
~claim=command'.meta.callerClaim,
|
|
256
|
+
~recordedOwners=entityId->Option.map(_ => owners),
|
|
257
|
+
)->CommandTopic_Helpers.ownershipRefusal(
|
|
258
|
+
~component=Spec.name,
|
|
259
|
+
~command=cmdJson.commandJson->Message.variantNameOfJson,
|
|
260
|
+
)
|
|
261
|
+
: None
|
|
262
|
+
|
|
230
263
|
// Fail closed: a query that can't be serialized yields `None`, not the empty
|
|
231
264
|
// string. The old `""` fallback made every unserializable query collide on
|
|
232
265
|
// one shared cache slot, so one slice's folded state could seed another's —
|
|
@@ -268,9 +301,9 @@ module Make = (
|
|
|
268
301
|
)
|
|
269
302
|
|
|
270
303
|
let rec attempt = (~retries) => {
|
|
271
|
-
let (baseState, afterPos) = switch seed.contents {
|
|
272
|
-
| Some((state, head)) => (state, head)
|
|
273
|
-
| None => (Behavior.initialState, None)
|
|
304
|
+
let (baseState, afterPos, baseOwners) = switch seed.contents {
|
|
305
|
+
| Some((state, head, owners)) => (state, head, owners)
|
|
306
|
+
| None => (Behavior.initialState, None, [])
|
|
274
307
|
}
|
|
275
308
|
let cacheHit = seed.contents->Option.isSome
|
|
276
309
|
|
|
@@ -291,15 +324,14 @@ module Make = (
|
|
|
291
324
|
|
|
292
325
|
dcbEventLog.readStream(~query, ~after=?afterPos, ~strongConsistency)
|
|
293
326
|
->Stream.map(raw => {
|
|
294
|
-
let
|
|
295
|
-
|
|
296
|
-
~data=raw.data->JSON.Decode.object->Option.getOr(Dict.make()),
|
|
297
|
-
)
|
|
327
|
+
let data = raw.data->JSON.Decode.object->Option.getOr(Dict.make())
|
|
328
|
+
let decoded = decoder.decode(~eventType=raw.eventType, ~data)
|
|
298
329
|
decoded->Option.map(event => (
|
|
299
330
|
event,
|
|
300
331
|
raw.position,
|
|
301
332
|
raw.eventType,
|
|
302
333
|
readEventId(~partitionTag, raw.tags),
|
|
334
|
+
(ownerFieldsOf(raw), data),
|
|
303
335
|
))
|
|
304
336
|
})
|
|
305
337
|
->Stream.flatMap(opt =>
|
|
@@ -311,9 +343,9 @@ module Make = (
|
|
|
311
343
|
// The head accumulator starts at `afterPos` (the seeded head), not `None`,
|
|
312
344
|
// so an empty delta read keeps the cached head — the append must condition
|
|
313
345
|
// on the head that actually exists, not assert "no events exist yet".
|
|
314
|
-
->Stream.runFold((baseState, afterPos, []), (
|
|
315
|
-
(dm, _pos, reads),
|
|
316
|
-
(event, position, eventType, eventId),
|
|
346
|
+
->Stream.runFold((baseState, afterPos, [], baseOwners), (
|
|
347
|
+
(dm, _pos, reads, owners),
|
|
348
|
+
(event, position, eventType, eventId, (ownerFields, data)),
|
|
317
349
|
) => (
|
|
318
350
|
Behavior.evolve(dm, event),
|
|
319
351
|
Some(position),
|
|
@@ -323,8 +355,9 @@ module Make = (
|
|
|
323
355
|
| None => eventType->LogFormat.bold
|
|
324
356
|
},
|
|
325
357
|
]),
|
|
358
|
+
owners->Reventless.OwnerScope.recordOwners(~fields=ownerFields, data),
|
|
326
359
|
))
|
|
327
|
-
->Effect.tap(((_, _, reads)) => {
|
|
360
|
+
->Effect.tap(((_, _, reads, _)) => {
|
|
328
361
|
// On a warm first attempt, record how many events the delta read
|
|
329
362
|
// returned — near-zero on a healthy hit; a large value means the entity
|
|
330
363
|
// churned between commands, so the cache saved little. Gated to the first
|
|
@@ -347,7 +380,7 @@ module Make = (
|
|
|
347
380
|
: ` [${reads->Array.join(", ")}]`}`,
|
|
348
381
|
)
|
|
349
382
|
})
|
|
350
|
-
->Effect.flatMap(((state, headPosition, _)) =>
|
|
383
|
+
->Effect.flatMap(((state, headPosition, _, owners)) =>
|
|
351
384
|
// Identity, not content: the decision model folds the whole matched
|
|
352
385
|
// history, so serialising it here makes the line's size a function of
|
|
353
386
|
// how long the entity has been in use.
|
|
@@ -357,143 +390,160 @@ module Make = (
|
|
|
357
390
|
"-",
|
|
358
391
|
)} cmd=${cmdJson->LogFormat.cmdName}`,
|
|
359
392
|
)->Effect.flatMap(_ =>
|
|
360
|
-
switch
|
|
361
|
-
|
|
|
362
|
-
//
|
|
363
|
-
cachePut((state, headPosition))
|
|
364
|
-
CommandTopic_Helpers.
|
|
393
|
+
switch ownershipRefusal(owners) {
|
|
394
|
+
| Some({rejected, isDefect}) =>
|
|
395
|
+
// Refused before `decide`; the read snapshot is still valid.
|
|
396
|
+
cachePut((state, headPosition, owners))
|
|
397
|
+
CommandTopic_Helpers.reportRejected(
|
|
365
398
|
~component=Spec.name,
|
|
399
|
+
~cause=AccessRefusal,
|
|
366
400
|
cmdJson.meta.msgId,
|
|
367
|
-
|
|
368
|
-
| Some(eid) => {entityId: eid, eventCount: 0}
|
|
369
|
-
| None => {eventCount: 0}
|
|
370
|
-
},
|
|
401
|
+
rejected,
|
|
371
402
|
)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
->Array.map(((k, v)) => `${k}:${v->JSON.stringify}`)
|
|
389
|
-
->Array.join(",")
|
|
390
|
-
f == "" ? "" : `({${f}})`
|
|
391
|
-
| _ => ""
|
|
392
|
-
}
|
|
393
|
-
`${LogFormat.bold(e.eventType)}${fields}`
|
|
403
|
+
let line = `refused: ${rejected.errorDetail} id=${entityId->Option.getOr("-")}`
|
|
404
|
+
|
|
405
|
+
(
|
|
406
|
+
isDefect ? EffectLogger.logError(~comp, line) : EffectLogger.logWarn(~comp, line)
|
|
407
|
+
)->Effect.map(_ => Ok("rejected"))
|
|
408
|
+
| None =>
|
|
409
|
+
switch Behavior.decide(state, command'.command) {
|
|
410
|
+
| Ok(newEvents) if newEvents->Array.length == 0 =>
|
|
411
|
+
// No append, but the read snapshot is valid — cache it for the next command.
|
|
412
|
+
cachePut((state, headPosition, owners))
|
|
413
|
+
CommandTopic_Helpers.reportAccepted(
|
|
414
|
+
~component=Spec.name,
|
|
415
|
+
cmdJson.meta.msgId,
|
|
416
|
+
switch entityId {
|
|
417
|
+
| Some(eid) => {entityId: eid, eventCount: 0}
|
|
418
|
+
| None => {eventCount: 0}
|
|
394
419
|
},
|
|
395
420
|
)
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
CommandTopic_Helpers.reportAccepted(
|
|
417
|
-
~component=Spec.name,
|
|
418
|
-
cmdJson.meta.msgId,
|
|
419
|
-
switch entityId {
|
|
420
|
-
| Some(eid) => {entityId: eid, eventCount: rawEvents->Array.length}
|
|
421
|
-
| None => {eventCount: rawEvents->Array.length}
|
|
422
|
-
},
|
|
423
|
-
)
|
|
424
|
-
EffectLogger.logInfo(~comp, `append: ${eventCount} event(s)`)->Effect.map(
|
|
425
|
-
_ => Ok("ok"),
|
|
426
|
-
)
|
|
427
|
-
| Error(err) =>
|
|
428
|
-
// Typed classification (was substring-matching the error string,
|
|
429
|
-
// which disagreed with the backends' casing).
|
|
430
|
-
let (errorCode, errDetail) = switch err {
|
|
431
|
-
| ReventlessInfra.DcbEventLog.Conflict => (
|
|
432
|
-
"Conflict",
|
|
433
|
-
"conflict: condition check failed",
|
|
434
|
-
)
|
|
435
|
-
| StorageFailure(msg) => ("AppendFailed", msg)
|
|
436
|
-
}
|
|
437
|
-
if retries > 0 {
|
|
438
|
-
// Re-seed from the just-read snapshot so the retry reads only
|
|
439
|
-
// the delta the conflicting writer added, not full history.
|
|
440
|
-
seed := Some((state, headPosition))
|
|
441
|
-
// Per-slice CloudWatch counter: rising AppendRetry signals a
|
|
442
|
-
// slice feeling contention (the metric to watch the eventual
|
|
443
|
-
// default with — see dcb-high-contention-handling.md).
|
|
444
|
-
Metrics.emitCount(~metric="AppendRetry", ~slice=Spec.name)
|
|
445
|
-
EffectLogger.logWarn(
|
|
446
|
-
~comp,
|
|
447
|
-
`append failed (retrying ${(maxRetries - retries + 1)
|
|
448
|
-
->Int.toString}/${maxRetries->Int.toString}): ${errDetail}`,
|
|
449
|
-
)->Effect.flatMap(_ => attempt(~retries=retries - 1))
|
|
450
|
-
} else {
|
|
451
|
-
// Retries exhausted — drop any cached snapshot so the next
|
|
452
|
-
// command for this entity takes the cold full-read path.
|
|
453
|
-
cacheInvalidate()
|
|
454
|
-
|
|
455
|
-
// A surfaced Conflict means the 3-retry loop could not absorb
|
|
456
|
-
// the contention — the operator signal for "this slice is hot"
|
|
457
|
-
// (consider sharding / async — Issue 10, §4 of the analysis).
|
|
458
|
-
if errorCode == "Conflict" {
|
|
459
|
-
Metrics.emitCount(~metric="AppendConflict", ~slice=Spec.name)
|
|
421
|
+
EffectLogger.logInfo(~comp, "no events produced")->Effect.map(_ => Ok("ok"))
|
|
422
|
+
| Ok(newEvents) =>
|
|
423
|
+
let rawEvents =
|
|
424
|
+
newEvents->Array.map(
|
|
425
|
+
e => encodeEvent(~parentMeta=command'.meta, ~tagKeysByEventType, e),
|
|
426
|
+
)
|
|
427
|
+
let eventCount = rawEvents->Array.length->Int.toString
|
|
428
|
+
let eventDetails =
|
|
429
|
+
rawEvents
|
|
430
|
+
->Array.map(
|
|
431
|
+
e => {
|
|
432
|
+
let fields = switch e.data {
|
|
433
|
+
| Object(dict) =>
|
|
434
|
+
let f =
|
|
435
|
+
dict
|
|
436
|
+
->Dict.toArray
|
|
437
|
+
->Array.map(((k, v)) => `${k}:${v->JSON.stringify}`)
|
|
438
|
+
->Array.join(",")
|
|
439
|
+
f == "" ? "" : `({${f}})`
|
|
440
|
+
| _ => ""
|
|
460
441
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
442
|
+
`${LogFormat.bold(e.eventType)}${fields}`
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
->Array.join(", ")
|
|
446
|
+
let eventJsons = rawEvents->Array.map(e => e.data)->JSON.Encode.array
|
|
447
|
+
let condition: Reventless.DcbTag.appendCondition = {
|
|
448
|
+
query,
|
|
449
|
+
after: ?headPosition,
|
|
450
|
+
}
|
|
451
|
+
EffectLogger.logInfo(
|
|
452
|
+
~comp,
|
|
453
|
+
~detail=eventJsons,
|
|
454
|
+
`produced ${eventCount} event(s): [${eventDetails}]`,
|
|
455
|
+
)
|
|
456
|
+
->Effect.flatMap(_ => Effect.promise(() => dcbEventLog.append(rawEvents, ~condition)))
|
|
457
|
+
->Effect.flatMap(
|
|
458
|
+
appendResult =>
|
|
459
|
+
switch appendResult {
|
|
460
|
+
| Ok(_position) =>
|
|
461
|
+
// Cache the decided-on state at the read head (NOT including the
|
|
462
|
+
// events we just appended, and NOT the returned position): the
|
|
463
|
+
// next command's delta read picks our events up from `headPosition`.
|
|
464
|
+
cachePut((state, headPosition, owners))
|
|
465
|
+
CommandTopic_Helpers.reportAccepted(
|
|
465
466
|
~component=Spec.name,
|
|
466
|
-
~cause=InfrastructureFailure,
|
|
467
467
|
cmdJson.meta.msgId,
|
|
468
|
-
|
|
468
|
+
switch entityId {
|
|
469
|
+
| Some(eid) => {entityId: eid, eventCount: rawEvents->Array.length}
|
|
470
|
+
| None => {eventCount: rawEvents->Array.length}
|
|
471
|
+
},
|
|
469
472
|
)
|
|
470
|
-
EffectLogger.
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
473
|
+
EffectLogger.logInfo(~comp, `append: ${eventCount} event(s)`)->Effect.map(
|
|
474
|
+
_ => Ok("ok"),
|
|
475
|
+
)
|
|
476
|
+
| Error(err) =>
|
|
477
|
+
// Typed classification (was substring-matching the error string,
|
|
478
|
+
// which disagreed with the backends' casing).
|
|
479
|
+
let (errorCode, errDetail) = switch err {
|
|
480
|
+
| ReventlessInfra.DcbEventLog.Conflict => (
|
|
481
|
+
"Conflict",
|
|
482
|
+
"conflict: condition check failed",
|
|
483
|
+
)
|
|
484
|
+
| StorageFailure(msg) => ("AppendFailed", msg)
|
|
485
|
+
}
|
|
486
|
+
if retries > 0 {
|
|
487
|
+
// Re-seed from the just-read snapshot so the retry reads only
|
|
488
|
+
// the delta the conflicting writer added, not full history.
|
|
489
|
+
seed := Some((state, headPosition, owners))
|
|
490
|
+
// Per-slice CloudWatch counter: rising AppendRetry signals a
|
|
491
|
+
// slice feeling contention (the metric to watch the eventual
|
|
492
|
+
// default with — see dcb-high-contention-handling.md).
|
|
493
|
+
Metrics.emitCount(~metric="AppendRetry", ~slice=Spec.name)
|
|
494
|
+
EffectLogger.logWarn(
|
|
495
|
+
~comp,
|
|
496
|
+
`append failed (retrying ${(maxRetries - retries + 1)
|
|
497
|
+
->Int.toString}/${maxRetries->Int.toString}): ${errDetail}`,
|
|
498
|
+
)->Effect.flatMap(_ => attempt(~retries=retries - 1))
|
|
499
|
+
} else {
|
|
500
|
+
// Retries exhausted — drop any cached snapshot so the next
|
|
501
|
+
// command for this entity takes the cold full-read path.
|
|
502
|
+
cacheInvalidate()
|
|
503
|
+
|
|
504
|
+
// A surfaced Conflict means the 3-retry loop could not absorb
|
|
505
|
+
// the contention — the operator signal for "this slice is hot"
|
|
506
|
+
// (consider sharding / async — Issue 10, §4 of the analysis).
|
|
507
|
+
if errorCode == "Conflict" {
|
|
508
|
+
Metrics.emitCount(~metric="AppendConflict", ~slice=Spec.name)
|
|
509
|
+
}
|
|
510
|
+
// Both arms here are storage, not the model: a surfaced Conflict is
|
|
511
|
+
// contention the retry loop couldn't absorb, and AppendFailed is the
|
|
512
|
+
// store failing. `decide` had already said Ok.
|
|
513
|
+
CommandTopic_Helpers.reportRejected(
|
|
514
|
+
~component=Spec.name,
|
|
515
|
+
~cause=InfrastructureFailure,
|
|
516
|
+
cmdJson.meta.msgId,
|
|
517
|
+
{errorCode, errorDetail: errDetail},
|
|
518
|
+
)
|
|
519
|
+
EffectLogger.logError(
|
|
520
|
+
~comp,
|
|
521
|
+
`append failed, retries exhausted: ${errDetail}`,
|
|
522
|
+
)->Effect.map(_ => Error(errDetail))
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
)
|
|
526
|
+
| Error(error) =>
|
|
527
|
+
// Business-rule rejection — no append, but the read snapshot is valid; cache it.
|
|
528
|
+
cachePut((state, headPosition, owners))
|
|
529
|
+
let errorJson = error->Reventless.Util_Sury.toJson(Spec.errorSchema)
|
|
530
|
+
let errorCode = errorJson->Message.variantNameOfJson
|
|
531
|
+
let (_, payloadDict) = errorJson->Message.splitMessage
|
|
532
|
+
let errorDetail =
|
|
533
|
+
payloadDict->Dict.toArray->Array.length == 0
|
|
534
|
+
? ""
|
|
535
|
+
: payloadDict->JSON.Encode.object->JSON.stringify
|
|
536
|
+
CommandTopic_Helpers.reportRejected(
|
|
537
|
+
~component=Spec.name,
|
|
538
|
+
~cause=DomainRejection,
|
|
539
|
+
cmdJson.meta.msgId,
|
|
540
|
+
{errorCode, errorDetail},
|
|
541
|
+
)
|
|
542
|
+
EffectLogger.logError(
|
|
543
|
+
~comp,
|
|
544
|
+
`decide rejected: ${errorCode} ${errorDetail}`,
|
|
545
|
+
)->Effect.map(_ => Ok("rejected"))
|
|
546
|
+
}
|
|
497
547
|
}
|
|
498
548
|
)
|
|
499
549
|
)
|