@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.
@@ -27,26 +27,25 @@ function clearCommandInterceptor() {
27
27
  commandInterceptorHook.contents = undefined;
28
28
  }
29
29
 
30
- function stampOwnerFields(obj, commandSchema, command, identity, serviceName) {
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
- let why = OwnerScope$Reventless.resolve(identity, undefined);
36
- if (typeof why !== "object") {
35
+ if (typeof scope !== "object") {
37
36
  return;
38
37
  }
39
- switch (why.TAG) {
38
+ switch (scope.TAG) {
40
39
  case "Elevated" :
41
40
  return;
42
41
  case "Owned" :
43
- let userId = why.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 (` + why._0 + `)`);
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, payload.identity, serviceName), Object.entries(obj)) : Stdlib_JsError.throwWithMessage("Couldn't decode:" + Stdlib_Option.getOr(JSON.stringify(payload.arguments), "<payload.arguments>"));
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 decoded = decoder.decode(
295
- ~eventType=raw.eventType,
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 Behavior.decide(state, command'.command) {
361
- | Ok(newEvents) if newEvents->Array.length == 0 =>
362
- // No append, but the read snapshot is valid — cache it for the next command.
363
- cachePut((state, headPosition))
364
- CommandTopic_Helpers.reportAccepted(
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
- switch entityId {
368
- | Some(eid) => {entityId: eid, eventCount: 0}
369
- | None => {eventCount: 0}
370
- },
401
+ rejected,
371
402
  )
372
- EffectLogger.logInfo(~comp, "no events produced")->Effect.map(_ => Ok("ok"))
373
- | Ok(newEvents) =>
374
- let rawEvents =
375
- newEvents->Array.map(
376
- e => encodeEvent(~parentMeta=command'.meta, ~tagKeysByEventType, e),
377
- )
378
- let eventCount = rawEvents->Array.length->Int.toString
379
- let eventDetails =
380
- rawEvents
381
- ->Array.map(
382
- e => {
383
- let fields = switch e.data {
384
- | Object(dict) =>
385
- let f =
386
- dict
387
- ->Dict.toArray
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
- ->Array.join(", ")
397
- let eventJsons = rawEvents->Array.map(e => e.data)->JSON.Encode.array
398
- let condition: Reventless.DcbTag.appendCondition = {
399
- query,
400
- after: ?headPosition,
401
- }
402
- EffectLogger.logInfo(
403
- ~comp,
404
- ~detail=eventJsons,
405
- `produced ${eventCount} event(s): [${eventDetails}]`,
406
- )
407
- ->Effect.flatMap(_ => Effect.promise(() => dcbEventLog.append(rawEvents, ~condition)))
408
- ->Effect.flatMap(
409
- appendResult =>
410
- switch appendResult {
411
- | Ok(_position) =>
412
- // Cache the decided-on state at the read head (NOT including the
413
- // events we just appended, and NOT the returned position): the
414
- // next command's delta read picks our events up from `headPosition`.
415
- cachePut((state, headPosition))
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
- // Both arms here are storage, not the model: a surfaced Conflict is
462
- // contention the retry loop couldn't absorb, and AppendFailed is the
463
- // store failing. `decide` had already said Ok.
464
- CommandTopic_Helpers.reportRejected(
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
- {errorCode, errorDetail: errDetail},
468
+ switch entityId {
469
+ | Some(eid) => {entityId: eid, eventCount: rawEvents->Array.length}
470
+ | None => {eventCount: rawEvents->Array.length}
471
+ },
469
472
  )
470
- EffectLogger.logError(
471
- ~comp,
472
- `append failed, retries exhausted: ${errDetail}`,
473
- )->Effect.map(_ => Error(errDetail))
474
- }
475
- },
476
- )
477
- | Error(error) =>
478
- // Business-rule rejection — no append, but the read snapshot is valid; cache it.
479
- cachePut((state, headPosition))
480
- let errorJson = error->Reventless.Util_Sury.toJson(Spec.errorSchema)
481
- let errorCode = errorJson->Message.variantNameOfJson
482
- let (_, payloadDict) = errorJson->Message.splitMessage
483
- let errorDetail =
484
- payloadDict->Dict.toArray->Array.length == 0
485
- ? ""
486
- : payloadDict->JSON.Encode.object->JSON.stringify
487
- CommandTopic_Helpers.reportRejected(
488
- ~component=Spec.name,
489
- ~cause=DomainRejection,
490
- cmdJson.meta.msgId,
491
- {errorCode, errorDetail},
492
- )
493
- EffectLogger.logError(
494
- ~comp,
495
- `decide rejected: ${errorCode} ${errorDetail}`,
496
- )->Effect.map(_ => Ok("rejected"))
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
  )