@reventlessdev/reventless-core 3.0.0-alpha.246 → 3.0.0-alpha.248
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 +29 -0
- package/package.json +5 -5
- package/src/adapter/Auth/Auth_ActiveRole.res +15 -0
- package/src/components/AutomationSlice/AutomationSlice_Callback.res +88 -8
- package/src/components/AutomationSlice/AutomationSlice_Callback.res.mjs +86 -28
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res +108 -12
- package/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs +89 -22
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,35 @@
|
|
|
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.248 (2026-08-23)
|
|
7
|
+
|
|
8
|
+
* feat(aws)!: scope the active-role store to the identity provider ([08e287d](https://github.com/ReventlessDev/reventless-core/commit/08e287db9721977b818bad76671ec4c62be6f9af))
|
|
9
|
+
|
|
10
|
+
### BREAKING CHANGES
|
|
11
|
+
|
|
12
|
+
* platform:cognitoUserPoolId is now
|
|
13
|
+
platform:identityProviderId (REVENTLESS_IDENTITY_PROVIDER_ID). The old
|
|
14
|
+
spelling is still read and warns — do not drop it until the new secret
|
|
15
|
+
is in place, because an unset provider id is auto mode, and auto mode
|
|
16
|
+
creates a new user pool.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# 3.0.0-alpha.247 (2026-08-22)
|
|
21
|
+
|
|
22
|
+
* feat(core)!: a slice that has given up says so ([f5559ab](https://github.com/ReventlessDev/reventless-core/commit/f5559abede3a9cf2c9a48aadc69a666c46cc75bf))
|
|
23
|
+
|
|
24
|
+
### BREAKING CHANGES
|
|
25
|
+
|
|
26
|
+
* `onExhausted` is required on the Translation and Automation
|
|
27
|
+
module types. ReScript has no optional module-type fields, so the alternative
|
|
28
|
+
was a PPX-injected default — and defaulting this one to silence is the bug.
|
|
29
|
+
A slice now states what abandonment means for its domain, `None` included.
|
|
30
|
+
GeocodeCustomerAddress answers with MarkAddressUnresolvable; the others say
|
|
31
|
+
nothing, on purpose.
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
6
35
|
# 3.0.0-alpha.246 (2026-08-21)
|
|
7
36
|
|
|
8
37
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.248",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -29,16 +29,16 @@
|
|
|
29
29
|
"sury": "11.0.0-rc.2",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
31
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
32
|
-
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
33
32
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
|
|
33
|
+
"@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
|
|
34
34
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
35
35
|
"@reventlessdev/rescript-node": "2.0.0-alpha.8",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
37
|
-
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
38
37
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
39
|
-
"@reventlessdev/
|
|
38
|
+
"@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
|
|
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.
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.122"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "12.3.0",
|
|
@@ -43,6 +43,21 @@ they picked stopped taking effect.
|
|
|
43
43
|
*/
|
|
44
44
|
let staleRoleClaim = "activeRoleStale"
|
|
45
45
|
|
|
46
|
+
// 🚨 **State that narrows an identity is scoped to the identity provider, not to
|
|
47
|
+
// the platform.**
|
|
48
|
+
//
|
|
49
|
+
// A provider has one minting point, and narrowing works by driving it. Keep the
|
|
50
|
+
// state a platform-scoped resource and two deployments sharing one provider each
|
|
51
|
+
// drive their own copy — whichever holds the minting point reads state the other
|
|
52
|
+
// never writes, so a role switch reports success and changes nothing. Each half
|
|
53
|
+
// is individually correct, so nothing short of two deployments can observe it.
|
|
54
|
+
//
|
|
55
|
+
// On AWS that is a pool's single `PreTokenGeneration` slot and the
|
|
56
|
+
// `platform:activeRoleStore` that follows it; elsewhere the shape differs and the
|
|
57
|
+
// rule does not. It cannot be a `conformanceCases` entry — those are
|
|
58
|
+
// `(membership, requested) → expected`, and this is about *where two deployments
|
|
59
|
+
// keep state*. See [docs/plans/active-role-store-scoped-to-the-pool.md].
|
|
60
|
+
|
|
46
61
|
/**
|
|
47
62
|
The conformance table §6 of the plan requires: the same (membership, requested,
|
|
48
63
|
expected) cases run against both minting paths, so the two implementations cannot
|
|
@@ -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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
let
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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$
|
|
155
|
-
newrecord$
|
|
156
|
-
newrecord$
|
|
157
|
-
todoItems[id] = newrecord$
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
};
|