@reventlessdev/reventless-core 3.0.0-alpha.246 → 3.0.0-alpha.247

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 CHANGED
@@ -3,6 +3,21 @@
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.247 (2026-08-22)
7
+
8
+ * feat(core)!: a slice that has given up says so ([f5559ab](https://github.com/ReventlessDev/reventless-core/commit/f5559abede3a9cf2c9a48aadc69a666c46cc75bf))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * `onExhausted` is required on the Translation and Automation
13
+ module types. ReScript has no optional module-type fields, so the alternative
14
+ was a PPX-injected default — and defaulting this one to silence is the bug.
15
+ A slice now states what abandonment means for its domain, `None` included.
16
+ GeocodeCustomerAddress answers with MarkAddressUnresolvable; the others say
17
+ nothing, on purpose.
18
+
19
+
20
+
6
21
  # 3.0.0-alpha.246 (2026-08-21)
7
22
 
8
23
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.246",
3
+ "version": "3.0.0-alpha.247",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -36,9 +36,9 @@
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
38
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.149",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.150",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.33",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.121"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.122"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
@@ -18,7 +18,12 @@ type todoStatus =
18
18
  | Pending
19
19
  | Processing
20
20
  | Completed
21
+ /** Failed and will be tried again. */
21
22
  | Failed
23
+ /** Failed for the last time — the retry budget is spent and no pass will pick
24
+ this row up again. Distinct from `Failed` because the two are opposite
25
+ instructions to whoever is reading: wait, or intervene. */
26
+ | Abandoned
22
27
 
23
28
  @schema
24
29
  type todoRow = {
@@ -28,6 +33,11 @@ type todoRow = {
28
33
  processedAt?: string,
29
34
  completedAt?: string,
30
35
  retryCount: int,
36
+ /** The ceiling `retryCount` is racing, copied from the Spec when the row is
37
+ created. Optional because rows written before this field existed are
38
+ rehydrated through `S.parseOrThrow`, and a required field would fail that
39
+ decode — silently, since the restore swallows it per row. */
40
+ maxRetries?: int,
31
41
  }
32
42
 
33
43
  /** The item schema seen through the `JSON.t` the row actually stores.
@@ -58,6 +68,7 @@ let todoRowSchemaFor = (itemSchema: S.t<'item>): S.t<todoRow> =>
58
68
  processedAt: ?s.matches(S.option(S.string)),
59
69
  completedAt: ?s.matches(S.option(S.string)),
60
70
  retryCount: s.matches(S.int),
71
+ maxRetries: ?s.matches(S.option(S.int)),
61
72
  })
62
73
 
63
74
  /**
@@ -122,6 +133,39 @@ module Make = (
122
133
  // the slice here makes the target's own events unmatchable, silently.
123
134
  let makeMeta = (): Reventless.Message.meta => Message.generateMeta(~service=Spec.targetName)
124
135
 
136
+ /** Record a failed attempt. The row is marked `Abandoned` at the moment the
137
+ budget runs out rather than being left to fall out of the next pass's
138
+ filter, so a reader never has to compare `retryCount` against a constant
139
+ that lives only in this Spec. */
140
+ let afterFailedAttempt = (row: todoRow): todoRow => {
141
+ let retryCount = row.retryCount + 1
142
+ {...row, status: retryCount >= Spec.maxRetries ? Abandoned : Failed, retryCount}
143
+ }
144
+
145
+ /** The command a slice wants published now that this item will never be
146
+ processed again, if it wants one. The row is `Abandoned` either way. */
147
+ let abandonmentCommand = (~id: string, ~row: todoRow): option<Reventless.Message.commandJson> =>
148
+ switch row.item->Reventless.Util_Sury.fromJson(Spec.todoItemSchema) {
149
+ | item =>
150
+ Automation.onExhausted(id, item)->Option.flatMap(((targetId, command)) =>
151
+ switch command->Reventless.Util_Sury.toJson(Spec.commandSchema) {
152
+ | commandJson => Some({Reventless.Message.id: targetId, meta: makeMeta(), commandJson})
153
+ | exception _ =>
154
+ EffectLogger.logError(
155
+ ~comp=`AutomationSlice(${Spec.name})`,
156
+ `retries exhausted for ${id}, and the abandonment command could not be encoded`,
157
+ )->Effect.runSync
158
+ None
159
+ }
160
+ )
161
+ | exception _ =>
162
+ EffectLogger.logError(
163
+ ~comp=`AutomationSlice(${Spec.name})`,
164
+ `retries exhausted for ${id}, and its item could not be decoded to announce it`,
165
+ )->Effect.runSync
166
+ None
167
+ }
168
+
125
169
  // Per-source erased dispatch — pre-compile decoders once at module init.
126
170
  type dispatch = {
127
171
  sourceName: string,
@@ -146,6 +190,7 @@ module Make = (
146
190
  status: Pending,
147
191
  createdAt: now(),
148
192
  retryCount: 0,
193
+ maxRetries: Spec.maxRetries,
149
194
  }
150
195
  todoItems->Dict.set(id, row)
151
196
  }
@@ -187,6 +232,20 @@ module Make = (
187
232
  }
188
233
 
189
234
  let phase2 = async (publishJsons: ReventlessInfra.CommandTopic.publishJsons) => {
235
+ // A `Failed` row at or past the ceiling is one an older build left stranded,
236
+ // or one whose Spec lowered `maxRetries` under it. Either way it is abandoned
237
+ // in fact, so say so rather than letting it sit in a status that promises a
238
+ // retry no pass will make.
239
+ let newlyAbandoned: array<string> = []
240
+ todoItems
241
+ ->Dict.toArray
242
+ ->Array.forEach(((id, row)) =>
243
+ if row.status == Failed && row.retryCount >= Spec.maxRetries {
244
+ todoItems->Dict.set(id, {...row, status: Abandoned})
245
+ newlyAbandoned->Array.push(id)
246
+ }
247
+ )
248
+
190
249
  let pending =
191
250
  todoItems
192
251
  ->Dict.toArray
@@ -222,10 +281,11 @@ module Make = (
222
281
  ~comp=`AutomationSlice(${Spec.name})`,
223
282
  `failed to encode command: ${errMsg}`,
224
283
  )->Effect.runSync
225
- todoItems->Dict.set(
226
- id,
227
- {...row, status: Failed, retryCount: row.retryCount + 1},
228
- )
284
+ let updated = row->afterFailedAttempt
285
+ todoItems->Dict.set(id, updated)
286
+ if updated.status == Abandoned {
287
+ newlyAbandoned->Array.push(id)
288
+ }
229
289
  None
230
290
  }
231
291
  commandJson->Option.forEach(
@@ -256,14 +316,34 @@ module Make = (
256
316
  pending->Array.forEach(((id, row)) => {
257
317
  switch todoItems->Dict.get(id) {
258
318
  | Some(current) if current.status == Processing =>
259
- todoItems->Dict.set(
260
- id,
261
- {...row, status: Failed, retryCount: row.retryCount + 1},
262
- )
319
+ let updated = row->afterFailedAttempt
320
+ todoItems->Dict.set(id, updated)
321
+ if updated.status == Abandoned {
322
+ newlyAbandoned->Array.push(id)
323
+ }
263
324
  | _ => ()
264
325
  }
265
326
  })
266
327
  }
267
328
  }
329
+
330
+ // Announced after the batch, so a publish failure above has already had its
331
+ // say on which rows ran out of budget. A failure here cannot mark anything for
332
+ // retry — the budget is what ran out — so the rows stay `Abandoned` regardless.
333
+ let abandonmentCommands =
334
+ newlyAbandoned->Array.filterMap(id =>
335
+ todoItems->Dict.get(id)->Option.flatMap(row => abandonmentCommand(~id, ~row))
336
+ )
337
+ if abandonmentCommands->Array.length > 0 {
338
+ try await publishJsons(abandonmentCommands) catch {
339
+ | exn =>
340
+ let errMsg =
341
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
342
+ EffectLogger.logError(
343
+ ~comp=`AutomationSlice(${Spec.name})`,
344
+ `failed to publish abandonment commands: ${errMsg}`,
345
+ )->Effect.runSync
346
+ }
347
+ }
268
348
  }
269
349
  }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Sury from "sury";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
6
  import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
6
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
8
  import * as Effect from "effect/Effect";
@@ -17,7 +18,8 @@ let todoStatusSchema = Sury.union([
17
18
  Sury.literal("Pending"),
18
19
  Sury.literal("Processing"),
19
20
  Sury.literal("Completed"),
20
- Sury.literal("Failed")
21
+ Sury.literal("Failed"),
22
+ Sury.literal("Abandoned")
21
23
  ]);
22
24
 
23
25
  let todoRowSchema = Sury.$schema(s => ({
@@ -26,7 +28,8 @@ let todoRowSchema = Sury.$schema(s => ({
26
28
  createdAt: s.m(Sury.string),
27
29
  processedAt: s.m(Sury.$option(Sury.string)),
28
30
  completedAt: s.m(Sury.$option(Sury.string)),
29
- retryCount: s.m(Sury.int)
31
+ retryCount: s.m(Sury.int),
32
+ maxRetries: s.m(Sury.$option(Sury.int))
30
33
  }));
31
34
 
32
35
  function todoRowSchemaFor(itemSchema) {
@@ -36,7 +39,8 @@ function todoRowSchemaFor(itemSchema) {
36
39
  createdAt: s.m(Sury.string),
37
40
  processedAt: s.m(Sury.$option(Sury.string)),
38
41
  completedAt: s.m(Sury.$option(Sury.string)),
39
- retryCount: s.m(Sury.int)
42
+ retryCount: s.m(Sury.int),
43
+ maxRetries: s.m(Sury.$option(Sury.int))
40
44
  }));
41
45
  }
42
46
 
@@ -44,6 +48,13 @@ function Make(Spec) {
44
48
  return Automation => {
45
49
  let todoItems = {};
46
50
  let makeMeta = () => Message$ReventlessCore.generateMeta(Spec.targetName, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
51
+ let afterFailedAttempt = row => {
52
+ let retryCount = row.retryCount + 1 | 0;
53
+ let newrecord = {...row};
54
+ newrecord.retryCount = retryCount;
55
+ newrecord.status = retryCount >= Spec.maxRetries ? "Abandoned" : "Failed";
56
+ return newrecord;
57
+ };
47
58
  let dispatches = Automation.mappings.map(M => {
48
59
  let decoder = DcbDecode$Reventless.makeDecoder(M.sourceEventSchema);
49
60
  let handle = (json, ctx) => {
@@ -61,11 +72,13 @@ function Make(Spec) {
61
72
  }
62
73
  let row_item = Util_Sury$Reventless.toJson(param[1], Spec.todoItemSchema);
63
74
  let row_createdAt = new Date().toISOString();
75
+ let row_maxRetries = Spec.maxRetries;
64
76
  let row = {
65
77
  item: row_item,
66
78
  status: "Pending",
67
79
  createdAt: row_createdAt,
68
- retryCount: 0
80
+ retryCount: 0,
81
+ maxRetries: row_maxRetries
69
82
  };
70
83
  todoItems[id] = row;
71
84
  });
@@ -98,6 +111,18 @@ function Make(Spec) {
98
111
  });
99
112
  };
100
113
  let phase2 = async publishJsons => {
114
+ let newlyAbandoned = [];
115
+ Object.entries(todoItems).forEach(param => {
116
+ let row = param[1];
117
+ if (!(row.status === "Failed" && row.retryCount >= Spec.maxRetries)) {
118
+ return;
119
+ }
120
+ let id = param[0];
121
+ let newrecord = {...row};
122
+ newrecord.status = "Abandoned";
123
+ todoItems[id] = newrecord;
124
+ newlyAbandoned.push(id);
125
+ });
101
126
  let pending = Object.entries(todoItems).filter(param => {
102
127
  let row = param[1];
103
128
  if (row.status === "Pending") {
@@ -138,10 +163,11 @@ function Make(Spec) {
138
163
  let exn = Primitive_exceptions.internalToException(raw_exn);
139
164
  let errMsg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
140
165
  Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, `failed to encode command: ` + errMsg));
141
- let newrecord$1 = {...row};
142
- newrecord$1.retryCount = row.retryCount + 1 | 0;
143
- newrecord$1.status = "Failed";
144
- todoItems[id] = newrecord$1;
166
+ let updated = afterFailedAttempt(row);
167
+ todoItems[id] = updated;
168
+ if (updated.status === "Abandoned") {
169
+ newlyAbandoned.push(id);
170
+ }
145
171
  commandJson = undefined;
146
172
  }
147
173
  Stdlib_Option.forEach(commandJson, commandJson => {
@@ -155,32 +181,64 @@ function Make(Spec) {
155
181
  });
156
182
  });
157
183
  });
158
- if (commands.length === 0) {
159
- return;
184
+ if (commands.length !== 0) {
185
+ try {
186
+ await publishJsons(commands);
187
+ } catch (raw_exn) {
188
+ let exn = Primitive_exceptions.internalToException(raw_exn);
189
+ let errMsg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
190
+ Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, `failed to publish commands: ` + errMsg));
191
+ pending.forEach(param => {
192
+ let id = param[0];
193
+ let current = todoItems[id];
194
+ if (current === undefined) {
195
+ return;
196
+ }
197
+ if (current.status !== "Processing") {
198
+ return;
199
+ }
200
+ let updated = afterFailedAttempt(param[1]);
201
+ todoItems[id] = updated;
202
+ if (updated.status === "Abandoned") {
203
+ newlyAbandoned.push(id);
204
+ return;
205
+ }
206
+ });
207
+ }
160
208
  }
161
- try {
162
- return await publishJsons(commands);
163
- } catch (raw_exn) {
164
- let exn = Primitive_exceptions.internalToException(raw_exn);
165
- let errMsg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "unknown");
166
- Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, `failed to publish commands: ` + errMsg));
167
- pending.forEach(param => {
168
- let row = param[1];
169
- let id = param[0];
170
- let current = todoItems[id];
171
- if (current === undefined) {
172
- return;
173
- }
174
- if (current.status !== "Processing") {
209
+ let abandonmentCommands = Stdlib_Array.filterMap(newlyAbandoned, id => Stdlib_Option.flatMap(todoItems[id], row => {
210
+ let item;
211
+ try {
212
+ item = Util_Sury$Reventless.fromJson(row.item, Spec.todoItemSchema);
213
+ } catch (exn) {
214
+ Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, `retries exhausted for ` + id + `, and its item could not be decoded to announce it`));
215
+ return;
216
+ }
217
+ return Stdlib_Option.flatMap(Automation.onExhausted(id, item), param => {
218
+ let commandJson;
219
+ try {
220
+ commandJson = Util_Sury$Reventless.toJson(param[1], Spec.commandSchema);
221
+ } catch (exn) {
222
+ Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, `retries exhausted for ` + id + `, and the abandonment command could not be encoded`));
175
223
  return;
176
224
  }
177
- let newrecord = {...row};
178
- newrecord.retryCount = row.retryCount + 1 | 0;
179
- newrecord.status = "Failed";
180
- todoItems[id] = newrecord;
225
+ return {
226
+ id: param[0],
227
+ meta: makeMeta(),
228
+ commandJson: commandJson
229
+ };
181
230
  });
231
+ }));
232
+ if (abandonmentCommands.length === 0) {
182
233
  return;
183
234
  }
235
+ try {
236
+ return await publishJsons(abandonmentCommands);
237
+ } catch (raw_exn$1) {
238
+ let exn$1 = Primitive_exceptions.internalToException(raw_exn$1);
239
+ let errMsg$1 = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn$1), Stdlib_JsExn.message), "unknown");
240
+ return Effect.runSync(EffectLogger$ReventlessCore.logError(`AutomationSlice(` + Spec.name + `)`, undefined, `failed to publish abandonment commands: ` + errMsg$1));
241
+ }
184
242
  };
185
243
  return {
186
244
  Spec: Spec,
@@ -8,7 +8,12 @@ type todoStatus =
8
8
  | Pending
9
9
  | Processing
10
10
  | Completed
11
+ /** Failed and will be tried again. */
11
12
  | Failed
13
+ /** Failed for the last time — the retry budget is spent and no pass will pick
14
+ this row up again. Distinct from `Failed` because the two are opposite
15
+ instructions to whoever is reading: wait, or intervene. */
16
+ | Abandoned
12
17
 
13
18
  @schema
14
19
  type todoRow = {
@@ -18,6 +23,11 @@ type todoRow = {
18
23
  processedAt?: string,
19
24
  completedAt?: string,
20
25
  retryCount: int,
26
+ /** The ceiling `retryCount` is racing, copied from the Spec when the row is
27
+ created. Optional because rows written before this field existed are
28
+ rehydrated through `S.parseOrThrow`, and a required field would fail that
29
+ decode — silently, since the restore swallows it per row. */
30
+ maxRetries?: int,
21
31
  lastError?: string,
22
32
  }
23
33
 
@@ -50,6 +60,7 @@ let todoRowSchemaFor = (itemSchema: S.t<'item>): S.t<todoRow> =>
50
60
  processedAt: ?s.matches(S.option(S.string)),
51
61
  completedAt: ?s.matches(S.option(S.string)),
52
62
  retryCount: s.matches(S.int),
63
+ maxRetries: ?s.matches(S.option(S.int)),
53
64
  lastError: ?s.matches(S.option(S.string)),
54
65
  })
55
66
 
@@ -135,6 +146,61 @@ module Make = (
135
146
  ~service=Spec.targetName->Option.getOr(`OutboundTranslationSlice:${Spec.name}`),
136
147
  )
137
148
 
149
+ /** Record a failed attempt. The row is marked `Abandoned` at the moment the
150
+ budget runs out rather than being left to fall out of the next pass's
151
+ filter, so a reader never has to compare `retryCount` against a constant
152
+ that lives only in this Spec. */
153
+ let afterFailedAttempt = (row: todoRow, ~lastError: option<string>=?): todoRow => {
154
+ let retryCount = row.retryCount + 1
155
+ {
156
+ ...row,
157
+ status: retryCount >= Spec.maxRetries ? Abandoned : Failed,
158
+ retryCount,
159
+ lastError: ?switch lastError {
160
+ | Some(_) as given => given
161
+ | None => row.lastError
162
+ },
163
+ }
164
+ }
165
+
166
+ /** Ask the slice whether abandonment is worth telling the domain about, and
167
+ publish what it says. Failing to publish here cannot mark the row for retry
168
+ — the budget is what ran out — so the loss is logged and the row stays
169
+ `Abandoned`. */
170
+ let announceAbandonment = async (
171
+ ~id: string,
172
+ ~row: todoRow,
173
+ ~publishJsons: ReventlessInfra.CommandTopic.publishJsons,
174
+ ) =>
175
+ switch row.item->Reventless.Util_Sury.fromJson(Spec.outboundItemSchema) {
176
+ | item =>
177
+ switch Translation.onExhausted(id, item, ~lastError=row.lastError) {
178
+ | None => ()
179
+ | Some((targetId, cmd)) =>
180
+ try {
181
+ let msg: Reventless.Message.commandJson = {
182
+ id: targetId,
183
+ meta: makeMeta(),
184
+ commandJson: cmd->Reventless.Util_Sury.toJson(Spec.inboundCommandSchema),
185
+ }
186
+ await publishJsons([msg])
187
+ } catch {
188
+ | exn =>
189
+ let errMsg =
190
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
191
+ EffectLogger.logError(
192
+ ~comp=`OutboundTranslationSlice(${Spec.name})`,
193
+ `retries exhausted for ${id}, and the abandonment command could not be published: ${errMsg}`,
194
+ )->Effect.runSync
195
+ }
196
+ }
197
+ | exception _ =>
198
+ EffectLogger.logError(
199
+ ~comp=`OutboundTranslationSlice(${Spec.name})`,
200
+ `retries exhausted for ${id}, and its item could not be decoded to announce it`,
201
+ )->Effect.runSync
202
+ }
203
+
138
204
  let phase1 = (events: array<(string, Spec.consumedEvent)>) => {
139
205
  events->Array.forEach(((sourceId, event)) => {
140
206
  // Collect new outbound items
@@ -147,6 +213,7 @@ module Make = (
147
213
  status: Pending,
148
214
  createdAt: now(),
149
215
  retryCount: 0,
216
+ maxRetries: Spec.maxRetries,
150
217
  }
151
218
  todoItems->Dict.set(id, row)
152
219
  }
@@ -158,6 +225,23 @@ module Make = (
158
225
  publishJsons: ReventlessInfra.CommandTopic.publishJsons,
159
226
  ~capabilities: Reventless.Capabilities.t,
160
227
  ) => {
228
+ // Ids that ran out of budget during this pass, announced once at the end so
229
+ // one item's abandonment cannot interleave with another's attempt.
230
+ let newlyAbandoned: array<string> = []
231
+
232
+ // A `Failed` row at or past the ceiling is one an older build left stranded,
233
+ // or one whose Spec lowered `maxRetries` under it. Either way it is abandoned
234
+ // in fact, so say so rather than letting it sit in a status that promises a
235
+ // retry no pass will make.
236
+ todoItems
237
+ ->Dict.toArray
238
+ ->Array.forEach(((id, row)) =>
239
+ if row.status == Failed && row.retryCount >= Spec.maxRetries {
240
+ todoItems->Dict.set(id, {...row, status: Abandoned})
241
+ newlyAbandoned->Array.push(id)
242
+ }
243
+ )
244
+
161
245
  let pending =
162
246
  todoItems
163
247
  ->Dict.toArray
@@ -229,16 +313,18 @@ module Make = (
229
313
  ~comp=`OutboundTranslationSlice(${Spec.name})`,
230
314
  `failed to publish command: ${errMsg}`,
231
315
  )->Effect.runSync
232
- todoItems->Dict.set(
233
- id,
234
- {...row, status: Failed, retryCount: row.retryCount + 1},
235
- )
316
+ let updated = row->afterFailedAttempt(~lastError=errMsg)
317
+ todoItems->Dict.set(id, updated)
318
+ if updated.status == Abandoned {
319
+ newlyAbandoned->Array.push(id)
320
+ }
236
321
  }
237
322
  | None =>
238
- todoItems->Dict.set(
239
- id,
240
- {...row, status: Failed, retryCount: row.retryCount + 1},
241
- )
323
+ let updated = row->afterFailedAttempt
324
+ todoItems->Dict.set(id, updated)
325
+ if updated.status == Abandoned {
326
+ newlyAbandoned->Array.push(id)
327
+ }
242
328
  }
243
329
 
244
330
  | Ok(None) =>
@@ -246,12 +332,22 @@ module Make = (
246
332
  todoItems->Dict.set(id, {...row, status: Completed, completedAt: now()})
247
333
 
248
334
  | Error(msg) =>
249
- todoItems->Dict.set(
250
- id,
251
- {...row, status: Failed, retryCount: row.retryCount + 1, lastError: msg},
252
- )
335
+ let updated = row->afterFailedAttempt(~lastError=msg)
336
+ todoItems->Dict.set(id, updated)
337
+ if updated.status == Abandoned {
338
+ newlyAbandoned->Array.push(id)
339
+ }
253
340
  }
254
341
  }
255
342
  })
343
+
344
+ // The row is already `Abandoned`; this only asks whether the domain hears.
345
+ let _ = await newlyAbandoned->Array.reduce(Promise.resolve(), async (prev, id) => {
346
+ let _ = await prev
347
+ switch todoItems->Dict.get(id) {
348
+ | Some(row) => await announceAbandonment(~id, ~row, ~publishJsons)
349
+ | None => ()
350
+ }
351
+ })
256
352
  }
257
353
  }
@@ -15,7 +15,8 @@ let todoStatusSchema = Sury.union([
15
15
  Sury.literal("Pending"),
16
16
  Sury.literal("Processing"),
17
17
  Sury.literal("Completed"),
18
- Sury.literal("Failed")
18
+ Sury.literal("Failed"),
19
+ Sury.literal("Abandoned")
19
20
  ]);
20
21
 
21
22
  let todoRowSchema = Sury.$schema(s => ({
@@ -25,6 +26,7 @@ let todoRowSchema = Sury.$schema(s => ({
25
26
  processedAt: s.m(Sury.$option(Sury.string)),
26
27
  completedAt: s.m(Sury.$option(Sury.string)),
27
28
  retryCount: s.m(Sury.int),
29
+ maxRetries: s.m(Sury.$option(Sury.int)),
28
30
  lastError: s.m(Sury.$option(Sury.string))
29
31
  }));
30
32
 
@@ -36,6 +38,7 @@ function todoRowSchemaFor(itemSchema) {
36
38
  processedAt: s.m(Sury.$option(Sury.string)),
37
39
  completedAt: s.m(Sury.$option(Sury.string)),
38
40
  retryCount: s.m(Sury.int),
41
+ maxRetries: s.m(Sury.$option(Sury.int)),
39
42
  lastError: s.m(Sury.$option(Sury.string))
40
43
  }));
41
44
  }
@@ -44,6 +47,41 @@ function Make(Spec) {
44
47
  return Translation => {
45
48
  let todoItems = {};
46
49
  let makeMeta = () => Message$ReventlessCore.generateMeta(Stdlib_Option.getOr(Spec.targetName, `OutboundTranslationSlice:` + Spec.name), undefined, undefined, undefined, undefined, undefined, undefined, undefined);
50
+ let afterFailedAttempt = (row, lastError) => {
51
+ let retryCount = row.retryCount + 1 | 0;
52
+ let newrecord = {...row};
53
+ newrecord.lastError = lastError !== undefined ? lastError : row.lastError;
54
+ newrecord.retryCount = retryCount;
55
+ newrecord.status = retryCount >= Spec.maxRetries ? "Abandoned" : "Failed";
56
+ return newrecord;
57
+ };
58
+ let announceAbandonment = async (id, row, publishJsons) => {
59
+ let item;
60
+ try {
61
+ item = Util_Sury$Reventless.fromJson(row.item, Spec.outboundItemSchema);
62
+ } catch (exn) {
63
+ return Effect.runSync(EffectLogger$ReventlessCore.logError(`OutboundTranslationSlice(` + Spec.name + `)`, undefined, `retries exhausted for ` + id + `, and its item could not be decoded to announce it`));
64
+ }
65
+ let match = Translation.onExhausted(id, item, row.lastError);
66
+ if (match === undefined) {
67
+ return;
68
+ }
69
+ try {
70
+ let msg_id = match[0];
71
+ let msg_meta = makeMeta();
72
+ let msg_commandJson = Util_Sury$Reventless.toJson(match[1], Spec.inboundCommandSchema);
73
+ let msg = {
74
+ id: msg_id,
75
+ meta: msg_meta,
76
+ commandJson: msg_commandJson
77
+ };
78
+ return await publishJsons([msg]);
79
+ } catch (raw_exn) {
80
+ let exn$1 = Primitive_exceptions.internalToException(raw_exn);
81
+ let errMsg = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn$1), Stdlib_JsExn.message), "unknown");
82
+ return Effect.runSync(EffectLogger$ReventlessCore.logError(`OutboundTranslationSlice(` + Spec.name + `)`, undefined, `retries exhausted for ` + id + `, and the abandonment command could not be published: ` + errMsg));
83
+ }
84
+ };
47
85
  let phase1 = events => {
48
86
  events.forEach(param => {
49
87
  Translation.collect(param[1], param[0]).forEach(param => {
@@ -54,17 +92,31 @@ function Make(Spec) {
54
92
  }
55
93
  let row_item = Util_Sury$Reventless.toJson(param[1], Spec.outboundItemSchema);
56
94
  let row_createdAt = new Date().toISOString();
95
+ let row_maxRetries = Spec.maxRetries;
57
96
  let row = {
58
97
  item: row_item,
59
98
  status: "Pending",
60
99
  createdAt: row_createdAt,
61
- retryCount: 0
100
+ retryCount: 0,
101
+ maxRetries: row_maxRetries
62
102
  };
63
103
  todoItems[id] = row;
64
104
  });
65
105
  });
66
106
  };
67
107
  let phase2 = async (publishJsons, capabilities) => {
108
+ let newlyAbandoned = [];
109
+ Object.entries(todoItems).forEach(param => {
110
+ let row = param[1];
111
+ if (!(row.status === "Failed" && row.retryCount >= Spec.maxRetries)) {
112
+ return;
113
+ }
114
+ let id = param[0];
115
+ let newrecord = {...row};
116
+ newrecord.status = "Abandoned";
117
+ todoItems[id] = newrecord;
118
+ newlyAbandoned.push(id);
119
+ });
68
120
  let pending = Object.entries(todoItems).filter(param => {
69
121
  let row = param[1];
70
122
  if (row.status === "Pending") {
@@ -137,33 +189,48 @@ function Make(Spec) {
137
189
  let exn$3 = Primitive_exceptions.internalToException(raw_exn$3);
138
190
  let errMsg$2 = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn$3), Stdlib_JsExn.message), "unknown");
139
191
  Effect.runSync(EffectLogger$ReventlessCore.logError(`OutboundTranslationSlice(` + Spec.name + `)`, undefined, `failed to publish command: ` + errMsg$2));
140
- let newrecord$2 = {...row};
141
- newrecord$2.retryCount = row.retryCount + 1 | 0;
142
- newrecord$2.status = "Failed";
143
- todoItems[id] = newrecord$2;
144
- return;
192
+ let updated = afterFailedAttempt(row, errMsg$2);
193
+ todoItems[id] = updated;
194
+ if (updated.status === "Abandoned") {
195
+ newlyAbandoned.push(id);
196
+ return;
197
+ } else {
198
+ return;
199
+ }
145
200
  }
146
201
  } else {
147
- let newrecord$3 = {...row};
148
- newrecord$3.retryCount = row.retryCount + 1 | 0;
149
- newrecord$3.status = "Failed";
150
- todoItems[id] = newrecord$3;
151
- return;
202
+ let updated$1 = afterFailedAttempt(row, undefined);
203
+ todoItems[id] = updated$1;
204
+ if (updated$1.status === "Abandoned") {
205
+ newlyAbandoned.push(id);
206
+ return;
207
+ } else {
208
+ return;
209
+ }
152
210
  }
153
211
  } else {
154
- let newrecord$4 = {...row};
155
- newrecord$4.completedAt = new Date().toISOString();
156
- newrecord$4.status = "Completed";
157
- todoItems[id] = newrecord$4;
212
+ let newrecord$2 = {...row};
213
+ newrecord$2.completedAt = new Date().toISOString();
214
+ newrecord$2.status = "Completed";
215
+ todoItems[id] = newrecord$2;
158
216
  return;
159
217
  }
160
218
  } else {
161
- let newrecord$5 = {...row};
162
- newrecord$5.lastError = result._0;
163
- newrecord$5.retryCount = row.retryCount + 1 | 0;
164
- newrecord$5.status = "Failed";
165
- todoItems[id] = newrecord$5;
166
- return;
219
+ let updated$2 = afterFailedAttempt(row, result._0);
220
+ todoItems[id] = updated$2;
221
+ if (updated$2.status === "Abandoned") {
222
+ newlyAbandoned.push(id);
223
+ return;
224
+ } else {
225
+ return;
226
+ }
227
+ }
228
+ });
229
+ await Stdlib_Array.reduce(newlyAbandoned, Promise.resolve(), async (prev, id) => {
230
+ await prev;
231
+ let row = todoItems[id];
232
+ if (row !== undefined) {
233
+ return await announceAbandonment(id, row, publishJsons);
167
234
  }
168
235
  });
169
236
  };