@reventlessdev/reventless-aws 3.0.0-alpha.420 → 3.0.0-alpha.421
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 +11 -0
- package/package.json +10 -10
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res +4 -1
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS.res.mjs +1 -1
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res +4 -1
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Async.res.mjs +1 -1
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res +4 -1
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_FIFO.res.mjs +1 -1
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res +4 -1
- package/src/adapter/CommandTopic/CommandTopicChannel_SQS_Sync.res.mjs +1 -1
- package/src/adapter/EventCollector/EventCollectorChannel_SQS.res +4 -1
- package/src/adapter/EventCollector/EventCollectorChannel_SQS.res.mjs +1 -1
- package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res +4 -1
- package/src/adapter/EventCollector/EventCollectorChannel_SQS_FIFO.res.mjs +1 -1
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +4 -1
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -1
- package/src/adapter/Postgres/PgProjectionFeed.res +4 -1
- package/src/adapter/Postgres/PgProjectionFeed.res.mjs +1 -1
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +12 -0
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +4 -1
- package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res +2 -2
- package/src/adapter/Runtime/EventCollectorEntryPoint_Ops.res.mjs +1 -1
- package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res +18 -1
- package/src/adapter/Runtime/ProjectionEntryPoint_Ops.res.mjs +20 -6
- package/src/util/Util_DeadLetterHandler.res +138 -0
- package/src/util/Util_DeadLetterHandler.res.mjs +113 -0
- package/src/util/Util_DeadLetterQueue.res +10 -53
- package/src/util/Util_DeadLetterQueue.res.mjs +6 -26
- package/tests/Util_DeadLetterHandlerTest.res +78 -0
- package/tests/Util_DeadLetterHandlerTest.res.mjs +101 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
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.421 (2026-11-02)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **aws:** a dead letter's first delivery writes its full line ([3436633](https://github.com/ReventlessDev/reventless-core/commit/3436633809b671fc750fc5f717523f080c084b64))
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* a view row knows its last change, and the local platform keeps every change ([1a1fd71](https://github.com/ReventlessDev/reventless-core/commit/1a1fd7117d9837696a7c0080dcd6f0983e57780c))
|
|
14
|
+
* **local:** the app's API logs each request and subscription it answers ([31a163c](https://github.com/ReventlessDev/reventless-core/commit/31a163c945d8929e9a604cc0dc8a74939320f85e))
|
|
15
|
+
|
|
16
|
+
|
|
6
17
|
# 3.0.0-alpha.420 (2026-11-01)
|
|
7
18
|
|
|
8
19
|
### Features
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-aws",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.421",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"bin": {
|
|
@@ -22,19 +22,19 @@
|
|
|
22
22
|
"sury": "11.0.0",
|
|
23
23
|
"uuid": "^13.0.0",
|
|
24
24
|
"yaml": "^2.8.3",
|
|
25
|
-
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.
|
|
26
|
-
"@reventlessdev/rescript-
|
|
25
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.39",
|
|
26
|
+
"@reventlessdev/rescript-node": "2.0.0-alpha.26",
|
|
27
27
|
"@reventlessdev/rescript-effect": "1.0.0-alpha.1",
|
|
28
|
-
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.
|
|
29
|
-
"@reventlessdev/rescript-
|
|
28
|
+
"@reventlessdev/rescript-pulumi-aws": "3.0.0-alpha.39",
|
|
29
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.13",
|
|
30
30
|
"@reventlessdev/rescript-pulumi-pulumi": "3.0.0-alpha.0",
|
|
31
31
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.1",
|
|
32
32
|
"@reventlessdev/rescript-web": "1.0.0-alpha.8",
|
|
33
|
-
"@reventlessdev/reventless-
|
|
34
|
-
"@reventlessdev/reventless-
|
|
35
|
-
"@reventlessdev/reventless-
|
|
36
|
-
"@reventlessdev/reventless-
|
|
37
|
-
"@reventlessdev/reventless-
|
|
33
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.328",
|
|
34
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.42",
|
|
35
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.193",
|
|
36
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.222",
|
|
37
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.192"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"rescript": "12.3.1",
|
|
@@ -60,7 +60,10 @@ let make: ReventlessCore.CommandTopic_Adapter.channelMaker<
|
|
|
60
60
|
PulumiAws.SQS.Queue.visibilityTimeoutSeconds: (6 * 30)->Pulumi.Input.make, // TODO rethink timeout
|
|
61
61
|
redrivePolicy: Util_DeadLetterQueue.queue.arn
|
|
62
62
|
->Pulumi.Output.apply(dlqArn =>
|
|
63
|
-
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
63
|
+
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
64
|
+
~deadLetterTargetArn=dlqArn,
|
|
65
|
+
~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
|
|
66
|
+
)
|
|
64
67
|
)
|
|
65
68
|
->Pulumi.Output.asInput,
|
|
66
69
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
@@ -43,7 +43,7 @@ function make(name, owner, opts) {
|
|
|
43
43
|
let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
|
|
44
44
|
let tags = AWS_Tags$ReventlessAws.make(name, CommandTopic$ReventlessCore.componentType, "CommandTopic", undefined, undefined, owner, undefined, undefined);
|
|
45
45
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
46
|
-
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn,
|
|
46
|
+
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
|
|
47
47
|
tags: tags,
|
|
48
48
|
visibilityTimeoutSeconds: 180,
|
|
49
49
|
sqsManagedSseEnabled: false
|
|
@@ -37,7 +37,10 @@ let make: ReventlessCore.CommandTopic_Adapter.channelMaker<
|
|
|
37
37
|
visibilityTimeoutSeconds: (6 * 30)->Pulumi.Input.make,
|
|
38
38
|
redrivePolicy: Util_DeadLetterQueue.fifoQueue.arn
|
|
39
39
|
->Pulumi.Output.apply(dlqArn => {
|
|
40
|
-
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
40
|
+
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
41
|
+
~deadLetterTargetArn=dlqArn,
|
|
42
|
+
~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
|
|
43
|
+
)
|
|
41
44
|
})
|
|
42
45
|
->Pulumi.Output.asInput,
|
|
43
46
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
@@ -24,7 +24,7 @@ function make(name, owner, opts) {
|
|
|
24
24
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
25
25
|
contentBasedDeduplication: true,
|
|
26
26
|
fifoQueue: true,
|
|
27
|
-
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn,
|
|
27
|
+
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
|
|
28
28
|
tags: tags,
|
|
29
29
|
visibilityTimeoutSeconds: 180,
|
|
30
30
|
deduplicationScope: "messageGroup",
|
|
@@ -26,7 +26,10 @@ let make: ReventlessCore.CommandTopic_Adapter.channelMaker<
|
|
|
26
26
|
visibilityTimeoutSeconds: (6 * 30)->Pulumi.Input.make,
|
|
27
27
|
redrivePolicy: Util_DeadLetterQueue.fifoQueue.arn
|
|
28
28
|
->Pulumi.Output.apply(dlqArn => {
|
|
29
|
-
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
29
|
+
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
30
|
+
~deadLetterTargetArn=dlqArn,
|
|
31
|
+
~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
|
|
32
|
+
)
|
|
30
33
|
})
|
|
31
34
|
->Pulumi.Output.asInput,
|
|
32
35
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
@@ -24,7 +24,7 @@ function make(name, owner, opts) {
|
|
|
24
24
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
25
25
|
contentBasedDeduplication: true,
|
|
26
26
|
fifoQueue: true,
|
|
27
|
-
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn,
|
|
27
|
+
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
|
|
28
28
|
tags: tags,
|
|
29
29
|
visibilityTimeoutSeconds: 180,
|
|
30
30
|
deduplicationScope: "messageGroup",
|
|
@@ -40,7 +40,10 @@ let make: ReventlessCore.CommandTopic_Adapter.channelMaker<
|
|
|
40
40
|
PulumiAws.SQS.Queue.visibilityTimeoutSeconds: (6 * 30)->Pulumi.Input.make,
|
|
41
41
|
redrivePolicy: Util_DeadLetterQueue.queue.arn
|
|
42
42
|
->Pulumi.Output.apply(dlqArn =>
|
|
43
|
-
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
43
|
+
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
44
|
+
~deadLetterTargetArn=dlqArn,
|
|
45
|
+
~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
|
|
46
|
+
)
|
|
44
47
|
)
|
|
45
48
|
->Pulumi.Output.asInput,
|
|
46
49
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
@@ -23,7 +23,7 @@ function make(name, owner, opts) {
|
|
|
23
23
|
let opts$1 = Stdlib_Option.map(opts, Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions);
|
|
24
24
|
let tags = AWS_Tags$ReventlessAws.make(name, CommandTopic$ReventlessCore.componentType, "CommandTopic", undefined, undefined, owner, undefined, undefined);
|
|
25
25
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
26
|
-
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn,
|
|
26
|
+
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
|
|
27
27
|
tags: tags,
|
|
28
28
|
visibilityTimeoutSeconds: 180,
|
|
29
29
|
sqsManagedSseEnabled: false
|
|
@@ -49,7 +49,10 @@ let make: ReventlessCore.EventCollector_Adapter.channelMaker<
|
|
|
49
49
|
PulumiAws.SQS.Queue.visibilityTimeoutSeconds: 120->Pulumi.Input.make,
|
|
50
50
|
redrivePolicy: Util_DeadLetterQueue.queue.arn
|
|
51
51
|
->Pulumi.Output.apply(dlqArn =>
|
|
52
|
-
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
52
|
+
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
53
|
+
~deadLetterTargetArn=dlqArn,
|
|
54
|
+
~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
|
|
55
|
+
)
|
|
53
56
|
)
|
|
54
57
|
->Pulumi.Output.asInput,
|
|
55
58
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
@@ -29,7 +29,7 @@ function make(name, eventTopics, owner, opts) {
|
|
|
29
29
|
let opts$1 = Util_Pulumi$ReventlessCore.ComponentResourceOptions.toCustomResourceOptions(opts);
|
|
30
30
|
let tags = AWS_Tags$ReventlessAws.make(name, EventCollector$ReventlessCore.componentType, "EventCollector", undefined, undefined, owner, undefined, undefined);
|
|
31
31
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
32
|
-
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn,
|
|
32
|
+
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
|
|
33
33
|
tags: tags,
|
|
34
34
|
visibilityTimeoutSeconds: 120,
|
|
35
35
|
sqsManagedSseEnabled: false
|
|
@@ -27,7 +27,10 @@ let make: ReventlessCore.EventCollector_Adapter.channelMaker<
|
|
|
27
27
|
visibilityTimeoutSeconds: 30->Pulumi.Input.make, // TODO fix timeout
|
|
28
28
|
redrivePolicy: Util_DeadLetterQueue.fifoQueue.arn
|
|
29
29
|
->Pulumi.Output.apply(dlqArn =>
|
|
30
|
-
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
30
|
+
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
31
|
+
~deadLetterTargetArn=dlqArn,
|
|
32
|
+
~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
|
|
33
|
+
)
|
|
31
34
|
)
|
|
32
35
|
->Pulumi.Output.asInput,
|
|
33
36
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
@@ -16,7 +16,7 @@ function make(name, eventTopics, owner, opts) {
|
|
|
16
16
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
17
17
|
contentBasedDeduplication: true,
|
|
18
18
|
fifoQueue: true,
|
|
19
|
-
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn,
|
|
19
|
+
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.fifoQueue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
|
|
20
20
|
tags: tags,
|
|
21
21
|
visibilityTimeoutSeconds: 30,
|
|
22
22
|
deduplicationScope: "messageGroup",
|
|
@@ -39,7 +39,10 @@ let make = (
|
|
|
39
39
|
SQS.Queue.visibilityTimeoutSeconds: 60->Pulumi.Input.make,
|
|
40
40
|
redrivePolicy: Util_DeadLetterQueue.queue.arn
|
|
41
41
|
->Pulumi.Output.apply(dlqArn =>
|
|
42
|
-
SQS.Queue.RedrivePolicy.make(
|
|
42
|
+
SQS.Queue.RedrivePolicy.make(
|
|
43
|
+
~deadLetterTargetArn=dlqArn,
|
|
44
|
+
~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
|
|
45
|
+
)
|
|
43
46
|
)
|
|
44
47
|
->Pulumi.Output.asInput,
|
|
45
48
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
@@ -21,7 +21,7 @@ function channelNameOf(topicName) {
|
|
|
21
21
|
|
|
22
22
|
function make(name, topicName, eventTopicOutputs, eventsApi, opts) {
|
|
23
23
|
let queue = new (Aws.sqs.Queue)(name + "EventLogSubQueue", {
|
|
24
|
-
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn,
|
|
24
|
+
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
|
|
25
25
|
tags: AWS_Tags$ReventlessAws.make(name + "EventLogSubQueue", EventTopic$ReventlessCore.componentType, "EventLogSubscription", undefined, name, undefined, undefined, undefined),
|
|
26
26
|
visibilityTimeoutSeconds: 60,
|
|
27
27
|
sqsManagedSseEnabled: false
|
|
@@ -47,7 +47,10 @@ let makeQueue = (
|
|
|
47
47
|
PulumiAws.SQS.Queue.visibilityTimeoutSeconds: 120->Pulumi.Input.make,
|
|
48
48
|
redrivePolicy: Util_DeadLetterQueue.queue.arn
|
|
49
49
|
->Pulumi.Output.apply(dlqArn =>
|
|
50
|
-
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
50
|
+
PulumiAws.SQS.Queue.RedrivePolicy.make(
|
|
51
|
+
~deadLetterTargetArn=dlqArn,
|
|
52
|
+
~maxReceiveCount=Util_DeadLetterQueue.maxReceiveCount,
|
|
53
|
+
)
|
|
51
54
|
)
|
|
52
55
|
->Pulumi.Output.asInput,
|
|
53
56
|
sqsManagedSseEnabled: false->Pulumi.Input.make,
|
|
@@ -15,7 +15,7 @@ function getFeedQueues() {
|
|
|
15
15
|
|
|
16
16
|
function makeQueue(name, scope, includeClassic, includeDcb, opts) {
|
|
17
17
|
let queue = new (Aws.sqs.Queue)(name, {
|
|
18
|
-
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn,
|
|
18
|
+
redrivePolicy: Util_DeadLetterQueue$ReventlessAws.queue.arn.apply(dlqArn => SQS_Queue$PulumiAws.RedrivePolicy.make(dlqArn, Util_DeadLetterQueue$ReventlessAws.maxReceiveCount)),
|
|
19
19
|
tags: AWS_Tags$ReventlessAws.make(name, "Plugin", "EventCollector", "Plugin", undefined, undefined, undefined, undefined),
|
|
20
20
|
visibilityTimeoutSeconds: 120,
|
|
21
21
|
sqsManagedSseEnabled: false
|
|
@@ -709,12 +709,24 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
|
|
|
709
709
|
// is independent of whether this view has a sub-id door, and adding a third
|
|
710
710
|
// dimension to that match would spell eight cases to say one thing.
|
|
711
711
|
let refsResolvers = resolverRefs->Option.mapOr([], r => [r])
|
|
712
|
+
// The row's last change, for an elevated caller only. A field resolver, so it also covers
|
|
713
|
+
// a row reached through another view's cross-table field.
|
|
714
|
+
let changeResolver = Resolver.makeUnitJsResolver(
|
|
715
|
+
~name=name ++ "RowChange",
|
|
716
|
+
~api,
|
|
717
|
+
~dataSourceName,
|
|
718
|
+
~type_=parentTypeName->Pulumi.Input.make,
|
|
719
|
+
~field=ReventlessCore.RowChange.key->Pulumi.Input.make,
|
|
720
|
+
~code=Resolver.Functions.rowChangeField(~field=ReventlessCore.RowChange.key, ~elevatedGroups),
|
|
721
|
+
~opts,
|
|
722
|
+
)
|
|
712
723
|
Array.flat([
|
|
713
724
|
mainResolvers,
|
|
714
725
|
refsResolvers,
|
|
715
726
|
resolversByIndex,
|
|
716
727
|
idResolvers,
|
|
717
728
|
idsResolvers,
|
|
729
|
+
[changeResolver],
|
|
718
730
|
])->Array.map(Util.AppSync.toResourceNative)
|
|
719
731
|
}
|
|
720
732
|
|
|
@@ -9,6 +9,7 @@ import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/compon
|
|
|
9
9
|
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
10
10
|
import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
|
|
11
11
|
import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
|
|
12
|
+
import * as RowChange$ReventlessCore from "@reventlessdev/reventless-core/src/RowChange.res.mjs";
|
|
12
13
|
import * as AppSync_Function$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Function.res.mjs";
|
|
13
14
|
import * as Util_AppSync$ReventlessAws from "../../util/Util_AppSync.res.mjs";
|
|
14
15
|
import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
|
|
@@ -303,12 +304,14 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
|
|
|
303
304
|
]
|
|
304
305
|
);
|
|
305
306
|
let refsResolvers = Stdlib_Option.mapOr(resolverRefs, [], r => [r]);
|
|
307
|
+
let changeResolver = AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(name$1 + "RowChange", api, dataSourceName, parentTypeName, RowChange$ReventlessCore.key, AppSync_Resolver_Functions$PulumiAws.rowChangeField(RowChange$ReventlessCore.key, elevatedGroups), opts);
|
|
306
308
|
return [
|
|
307
309
|
mainResolvers,
|
|
308
310
|
refsResolvers,
|
|
309
311
|
resolversByIndex,
|
|
310
312
|
idResolvers,
|
|
311
|
-
idsResolvers
|
|
313
|
+
idsResolvers,
|
|
314
|
+
[changeResolver]
|
|
312
315
|
].flat().map(Util_AppSync$ReventlessAws.toResourceNative);
|
|
313
316
|
};
|
|
314
317
|
return {
|
|
@@ -711,8 +711,8 @@ let makeSqsHandlerBundle = (input: bundleInput): bundle => {
|
|
|
711
711
|
),
|
|
712
712
|
// Backs both the admin and every per-plugin EventCollector Lambda, so the
|
|
713
713
|
// comp names the plugin whose events it drains — matching the
|
|
714
|
-
// `
|
|
715
|
-
comp: `
|
|
714
|
+
// `ExtensionRouting(<id>)` shape Plugin_Callback logs under.
|
|
715
|
+
comp: `ExtensionRouting(${input.pluginDefinition.id})`,
|
|
716
716
|
}
|
|
717
717
|
}
|
|
718
718
|
|
|
@@ -531,7 +531,7 @@ function makeSqsHandlerBundle(input) {
|
|
|
531
531
|
};
|
|
532
532
|
return {
|
|
533
533
|
sqsHandler: EventCollectorChannel_SQS_Runtime$ReventlessAws.handleDynamoDbOrSqsEvent(queue, Callback.handleJsonEvents),
|
|
534
|
-
comp: `
|
|
534
|
+
comp: `ExtensionRouting(` + input.pluginDefinition.id + `)`
|
|
535
535
|
};
|
|
536
536
|
}
|
|
537
537
|
|
|
@@ -124,6 +124,23 @@ let withDisplayName = (
|
|
|
124
124
|
}
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
Stamps every saved row with the change being written (`_change`: flow, event, time), as
|
|
129
|
+
`QueryDb_Operations.Make` does on the typed path. Outermost, so the live update publishes
|
|
130
|
+
the stamped row.
|
|
131
|
+
*/
|
|
132
|
+
let withRowChange = (
|
|
133
|
+
base: ReventlessCore.QueryDb_Adapter.operations,
|
|
134
|
+
): ReventlessCore.QueryDb_Adapter.operations => {
|
|
135
|
+
...base,
|
|
136
|
+
save: (id, state, saveMode, ttl) =>
|
|
137
|
+
base.save(id, ReventlessCore.RowChange.stamp(state), saveMode, ttl),
|
|
138
|
+
saveBatch: items =>
|
|
139
|
+
base.saveBatch(
|
|
140
|
+
items->Array.map(((id, state, ttl)) => (id, ReventlessCore.RowChange.stamp(state), ttl)),
|
|
141
|
+
),
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
let makeQueryDbOps = (
|
|
128
145
|
~queryDbTableName: string,
|
|
129
146
|
~pgConnection: option<PgConnection.connectionConfig>,
|
|
@@ -145,4 +162,4 @@ let makeQueryDbOps = (
|
|
|
145
162
|
?subIdField,
|
|
146
163
|
})
|
|
147
164
|
| None => QueryDbEntryPoint_Ops.makeDynamoQueryDbOps(~tableName=queryDbTableName)
|
|
148
|
-
}
|
|
165
|
+
}->withRowChange
|
|
@@ -5,6 +5,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
5
5
|
import * as DisplayName$Reventless from "@reventlessdev/reventless-spec/src/components/DisplayName.res.mjs";
|
|
6
6
|
import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
|
|
7
7
|
import * as StateTopicPublishMjs from "./StateTopicPublish.mjs";
|
|
8
|
+
import * as RowChange$ReventlessCore from "@reventlessdev/reventless-core/src/RowChange.res.mjs";
|
|
8
9
|
import * as QueryDbEntryPoint_Ops$ReventlessAws from "./QueryDbEntryPoint_Ops.res.mjs";
|
|
9
10
|
import * as QueryDbStorage_Postgres_Runtime$ReventlessAws from "../QueryDb/QueryDbStorage_Postgres_Runtime.res.mjs";
|
|
10
11
|
|
|
@@ -70,17 +71,29 @@ function withDisplayName(base, stateSchema) {
|
|
|
70
71
|
};
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
function withRowChange(base) {
|
|
75
|
+
return {
|
|
76
|
+
load: base.load,
|
|
77
|
+
loadStream: base.loadStream,
|
|
78
|
+
save: (id, state, saveMode, ttl) => base.save(id, RowChange$ReventlessCore.stamp(state), saveMode, ttl),
|
|
79
|
+
saveBatch: items => base.saveBatch(items.map(param => [
|
|
80
|
+
param[0],
|
|
81
|
+
RowChange$ReventlessCore.stamp(param[1]),
|
|
82
|
+
param[2]
|
|
83
|
+
])),
|
|
84
|
+
count: base.count,
|
|
85
|
+
delete: base.delete,
|
|
86
|
+
deleteBatch: base.deleteBatch
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
73
90
|
function makeQueryDbOps(queryDbTableName, pgConnection, stateTopicName, indexes, subIdField) {
|
|
74
|
-
|
|
75
|
-
return StateTopicPublishMjs.withLiveUpdates(QueryDbStorage_Postgres_Runtime$ReventlessAws.opsFor(pgConnection, queryDbTableName, indexes, subIdField), {
|
|
91
|
+
return withRowChange(pgConnection !== undefined ? StateTopicPublishMjs.withLiveUpdates(QueryDbStorage_Postgres_Runtime$ReventlessAws.opsFor(pgConnection, queryDbTableName, indexes, subIdField), {
|
|
76
92
|
endpoint: process.env["APPSYNC_ENDPOINT"],
|
|
77
93
|
region: process.env["AWS_REGION"],
|
|
78
94
|
topicName: stateTopicName,
|
|
79
95
|
subIdField: subIdField
|
|
80
|
-
});
|
|
81
|
-
} else {
|
|
82
|
-
return QueryDbEntryPoint_Ops$ReventlessAws.makeDynamoQueryDbOps(queryDbTableName);
|
|
83
|
-
}
|
|
96
|
+
}) : QueryDbEntryPoint_Ops$ReventlessAws.makeDynamoQueryDbOps(queryDbTableName));
|
|
84
97
|
}
|
|
85
98
|
|
|
86
99
|
export {
|
|
@@ -89,6 +102,7 @@ export {
|
|
|
89
102
|
withLiveUpdates,
|
|
90
103
|
withUnionMemberTypes,
|
|
91
104
|
withDisplayName,
|
|
105
|
+
withRowChange,
|
|
92
106
|
makeQueryDbOps,
|
|
93
107
|
}
|
|
94
108
|
/* DisplayName-Reventless Not a pure module */
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The dead-letter function's handler. `Util_DeadLetterQueue` ships this module's compiled file
|
|
2
|
+
// as the function's whole code, so it must compile to a file that imports nothing: it keeps
|
|
3
|
+
// working when the layer or the handlers of a deploy are broken, which is when dead letters
|
|
4
|
+
// arrive. So only the Stdlib's externals (which compile inline) and plain switches here: no
|
|
5
|
+
// Stdlib function such as `Option.getOr`, which compiles to an import, and no `try`, whose
|
|
6
|
+
// compiled form imports the runtime's exception helpers.
|
|
7
|
+
//
|
|
8
|
+
// It logs what arrived, then FAILS the invocation on purpose.
|
|
9
|
+
//
|
|
10
|
+
// Returning success would let SQS delete the message, which is what this handler used to do,
|
|
11
|
+
// and it made a dead letter unobservable twice over: the queue's depth returned to 0, so a
|
|
12
|
+
// depth alarm could never fire, and the function's `Errors` metric stayed at 0, so neither
|
|
13
|
+
// could an error alarm. A plugin whose commands failed every 5 minutes for two days produced
|
|
14
|
+
// 217 dead letters and no signal at all; it was found by a person noticing a stale UI.
|
|
15
|
+
//
|
|
16
|
+
// Failing instead keeps the message on the queue and keeps `Errors` non-zero while the
|
|
17
|
+
// condition lasts. Both are conventional alarm subjects, and a monitoring backend attached
|
|
18
|
+
// through the `DeadLetterSink` seam now has something to attach to.
|
|
19
|
+
//
|
|
20
|
+
// The transport retries by design too: a failed batch returns to the queue and is redelivered
|
|
21
|
+
// until retention expires. A handler that fails forever, on a transport that retries forever,
|
|
22
|
+
// is a loop, and it outlives its cause, because nothing about a fixed bug removes the message
|
|
23
|
+
// that was stranded by it. So what the loop costs is a function of the line, not the count:
|
|
24
|
+
// the first delivery here writes the record's ids, kind, size and attribute names (never its
|
|
25
|
+
// body, which is a command or event and may be about a person), and every redelivery after it
|
|
26
|
+
// writes a single identity line. A stranded message is ~150 KB over a fortnight rather than
|
|
27
|
+
// ~17 MB, small enough that ending the loop early is an operator's job.
|
|
28
|
+
|
|
29
|
+
// How many times a consuming queue delivers a message before it moves it here. Every redrive
|
|
30
|
+
// policy in the framework takes it from here. SQS keeps a message's receive count when it
|
|
31
|
+
// moves it, so a dead letter's first delivery here reports `maxReceiveCount + 1`; a message
|
|
32
|
+
// sent here directly starts at 1.
|
|
33
|
+
let maxReceiveCount = 5
|
|
34
|
+
let firstDeliveryCount = maxReceiveCount + 1
|
|
35
|
+
|
|
36
|
+
type record = {
|
|
37
|
+
messageId?: string,
|
|
38
|
+
body?: string,
|
|
39
|
+
attributes?: dict<string>,
|
|
40
|
+
messageAttributes?: dict<JSON.t>,
|
|
41
|
+
eventSourceARN?: string,
|
|
42
|
+
}
|
|
43
|
+
type event = {@as("Records") records?: array<record>}
|
|
44
|
+
|
|
45
|
+
// The body parsed, or nothing when it is not JSON. A `try` would import the runtime, so the
|
|
46
|
+
// parse is guarded in plain JS.
|
|
47
|
+
let parseOrNull: string => Nullable.t<JSON.t> = %raw(`function (text) {
|
|
48
|
+
try { return JSON.parse(text) } catch (_) { return null }
|
|
49
|
+
}`)
|
|
50
|
+
|
|
51
|
+
// `Option.getOr` would import the Stdlib, so the default is given here.
|
|
52
|
+
let orElse = (value: option<string>, fallback: string) =>
|
|
53
|
+
switch value {
|
|
54
|
+
| Some(v) => v
|
|
55
|
+
| None => fallback
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The command's or event's tag, "sns" for an SNS envelope, else "unknown".
|
|
59
|
+
let kindOf = (body: string): string =>
|
|
60
|
+
switch parseOrNull(body)->Nullable.toOption {
|
|
61
|
+
| Some(JSON.Object(d)) =>
|
|
62
|
+
let tagOf = key =>
|
|
63
|
+
switch d->Dict.get(key) {
|
|
64
|
+
| Some(JSON.Object(inner)) =>
|
|
65
|
+
switch inner->Dict.get("TAG") {
|
|
66
|
+
| Some(JSON.String(tag)) => Some(tag)
|
|
67
|
+
| _ => None
|
|
68
|
+
}
|
|
69
|
+
| _ => None
|
|
70
|
+
}
|
|
71
|
+
switch (tagOf("command"), tagOf("event"), d->Dict.get("Message")) {
|
|
72
|
+
| (Some(tag), _, _) | (None, Some(tag), _) => tag
|
|
73
|
+
| (None, None, Some(_)) => "sns"
|
|
74
|
+
| (None, None, None) => "unknown"
|
|
75
|
+
}
|
|
76
|
+
| _ => "unknown"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let receiveCountOf = (attrs: dict<string>): int =>
|
|
80
|
+
switch attrs->Dict.get("ApproximateReceiveCount") {
|
|
81
|
+
| Some(text) =>
|
|
82
|
+
let n = Float.parseFloat(text)
|
|
83
|
+
Float.isNaN(n) ? 1 : Float.toInt(n)
|
|
84
|
+
| None => 1
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The line a record writes: the full one on its first delivery here, the short one after.
|
|
88
|
+
let lineOf = (record: record): string => {
|
|
89
|
+
let attrs = switch record.attributes {
|
|
90
|
+
| Some(a) => a
|
|
91
|
+
| None => Dict.make()
|
|
92
|
+
}
|
|
93
|
+
let receiveCount = receiveCountOf(attrs)
|
|
94
|
+
let body = record.body->orElse("")
|
|
95
|
+
let source = switch attrs->Dict.get("DeadLetterQueueSourceArn") {
|
|
96
|
+
| Some(arn) => arn
|
|
97
|
+
| None => record.eventSourceARN->orElse("unknown")
|
|
98
|
+
}
|
|
99
|
+
let identity =
|
|
100
|
+
"messageId=" ++
|
|
101
|
+
record.messageId->orElse("unknown") ++
|
|
102
|
+
" source=" ++
|
|
103
|
+
source ++
|
|
104
|
+
" receiveCount=" ++
|
|
105
|
+
Int.toString(receiveCount) ++
|
|
106
|
+
" bodyBytes=" ++
|
|
107
|
+
Int.toString(String.length(body))
|
|
108
|
+
if receiveCount <= firstDeliveryCount {
|
|
109
|
+
let attributeNames = switch record.messageAttributes {
|
|
110
|
+
| Some(m) => m->Dict.keysToArray
|
|
111
|
+
| None => []
|
|
112
|
+
}
|
|
113
|
+
"DEAD LETTER ITEM: " ++
|
|
114
|
+
identity ++
|
|
115
|
+
" kind=" ++
|
|
116
|
+
kindOf(body) ++
|
|
117
|
+
" attributes=" ++
|
|
118
|
+
attributeNames->Array.join(",")
|
|
119
|
+
} else {
|
|
120
|
+
"DEAD LETTER REDELIVERY: " ++ identity
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let handler = async (event: event): unit => {
|
|
125
|
+
let records = switch event.records {
|
|
126
|
+
| Some(r) => r
|
|
127
|
+
| None => []
|
|
128
|
+
}
|
|
129
|
+
records->Array.forEach(record => Console.error(lineOf(record)))
|
|
130
|
+
JsError.throw(
|
|
131
|
+
JsError.make(
|
|
132
|
+
"Dead-lettered " ++
|
|
133
|
+
Int.toString(
|
|
134
|
+
Array.length(records),
|
|
135
|
+
) ++ " message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero.",
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
let firstDeliveryCount = 6;
|
|
5
|
+
|
|
6
|
+
let parseOrNull = (function (text) {
|
|
7
|
+
try { return JSON.parse(text) } catch (_) { return null }
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function orElse(value, fallback) {
|
|
11
|
+
if (value !== undefined) {
|
|
12
|
+
return value;
|
|
13
|
+
} else {
|
|
14
|
+
return fallback;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function kindOf(body) {
|
|
19
|
+
let match = parseOrNull(body);
|
|
20
|
+
if (match == null) {
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
if (typeof match !== "object" || match === null || Array.isArray(match)) {
|
|
24
|
+
return "unknown";
|
|
25
|
+
}
|
|
26
|
+
let tagOf = key => {
|
|
27
|
+
let match$1 = match[key];
|
|
28
|
+
if (match$1 === undefined) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (typeof match$1 !== "object" || match$1 === null || Array.isArray(match$1)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let match$2 = match$1["TAG"];
|
|
35
|
+
if (typeof match$2 === "string") {
|
|
36
|
+
return match$2;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
let match$1 = tagOf("command");
|
|
40
|
+
let match$2 = tagOf("event");
|
|
41
|
+
let match$3 = match["Message"];
|
|
42
|
+
if (match$1 !== undefined) {
|
|
43
|
+
return match$1;
|
|
44
|
+
} else if (match$2 !== undefined) {
|
|
45
|
+
return match$2;
|
|
46
|
+
} else if (match$3 !== undefined) {
|
|
47
|
+
return "sns";
|
|
48
|
+
} else {
|
|
49
|
+
return "unknown";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function receiveCountOf(attrs) {
|
|
54
|
+
let text = attrs["ApproximateReceiveCount"];
|
|
55
|
+
if (text === undefined) {
|
|
56
|
+
return 1;
|
|
57
|
+
}
|
|
58
|
+
let n = parseFloat(text);
|
|
59
|
+
if (Number.isNaN(n)) {
|
|
60
|
+
return 1;
|
|
61
|
+
} else {
|
|
62
|
+
return n | 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function lineOf(record) {
|
|
67
|
+
let a = record.attributes;
|
|
68
|
+
let attrs = a !== undefined ? a : ({});
|
|
69
|
+
let receiveCount = receiveCountOf(attrs);
|
|
70
|
+
let value = record.body;
|
|
71
|
+
let body = value !== undefined ? value : "";
|
|
72
|
+
let arn = attrs["DeadLetterQueueSourceArn"];
|
|
73
|
+
let source;
|
|
74
|
+
if (arn !== undefined) {
|
|
75
|
+
source = arn;
|
|
76
|
+
} else {
|
|
77
|
+
let value$1 = record.eventSourceARN;
|
|
78
|
+
source = value$1 !== undefined ? value$1 : "unknown";
|
|
79
|
+
}
|
|
80
|
+
let value$2 = record.messageId;
|
|
81
|
+
let identity = "messageId=" + (
|
|
82
|
+
value$2 !== undefined ? value$2 : "unknown"
|
|
83
|
+
) + " source=" + source + " receiveCount=" + receiveCount.toString() + " bodyBytes=" + body.length.toString();
|
|
84
|
+
if (receiveCount > firstDeliveryCount) {
|
|
85
|
+
return "DEAD LETTER REDELIVERY: " + identity;
|
|
86
|
+
}
|
|
87
|
+
let m = record.messageAttributes;
|
|
88
|
+
let attributeNames = m !== undefined ? Object.keys(m) : [];
|
|
89
|
+
return "DEAD LETTER ITEM: " + identity + " kind=" + kindOf(body) + " attributes=" + attributeNames.join(",");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function handler(event) {
|
|
93
|
+
let r = event.Records;
|
|
94
|
+
let records = r !== undefined ? r : [];
|
|
95
|
+
records.forEach(record => {
|
|
96
|
+
console.error(lineOf(record));
|
|
97
|
+
});
|
|
98
|
+
throw new Error("Dead-lettered " + records.length.toString() + " message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero.");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let maxReceiveCount = 5;
|
|
102
|
+
|
|
103
|
+
export {
|
|
104
|
+
maxReceiveCount,
|
|
105
|
+
firstDeliveryCount,
|
|
106
|
+
parseOrNull,
|
|
107
|
+
orElse,
|
|
108
|
+
kindOf,
|
|
109
|
+
receiveCountOf,
|
|
110
|
+
lineOf,
|
|
111
|
+
handler,
|
|
112
|
+
}
|
|
113
|
+
/* No side effect */
|
|
@@ -17,6 +17,10 @@ let queueTags = queueName =>
|
|
|
17
17
|
// 4-day default can expire it over a long weekend before anyone reads it.
|
|
18
18
|
let retentionSeconds = 14 * 24 * 60 * 60
|
|
19
19
|
|
|
20
|
+
// How many times a consuming queue delivers a message before it moves it here; every redrive
|
|
21
|
+
// policy in the framework takes it from here. Defined beside the handler, which needs it.
|
|
22
|
+
let maxReceiveCount = Util_DeadLetterHandler.maxReceiveCount
|
|
23
|
+
|
|
20
24
|
// A dead letter has no latency requirement, and the handler below fails on
|
|
21
25
|
// purpose — so a message returns to the queue and is redelivered until retention
|
|
22
26
|
// expires. At 180 s that is ~480 redeliveries per message per day, which outlives
|
|
@@ -67,59 +71,12 @@ let lambdaRole = IAM.Role.makeWithDefaultPolicy(
|
|
|
67
71
|
~opts,
|
|
68
72
|
)
|
|
69
73
|
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// plugin whose commands failed every 5 minutes for two days produced 217 dead
|
|
77
|
-
// letters and no signal at all; it was found by a person noticing a stale UI.
|
|
78
|
-
//
|
|
79
|
-
// Failing instead keeps the message on the queue and keeps `Errors` non-zero
|
|
80
|
-
// while the condition lasts. Both are conventional alarm subjects, and a
|
|
81
|
-
// monitoring backend attached through the `DeadLetterSink` seam below now has
|
|
82
|
-
// something to attach to.
|
|
83
|
-
//
|
|
84
|
-
// What that reasoning did not account for is that the transport retries by
|
|
85
|
-
// design too: a failed batch returns to the queue and is redelivered until
|
|
86
|
-
// retention expires. A handler that fails forever, on a transport that retries
|
|
87
|
-
// forever, is a loop — and it outlives its cause, because nothing about a fixed
|
|
88
|
-
// bug removes the message that was stranded by it. Seven of them were still
|
|
89
|
-
// cycling twelve hours after the fix that made them impossible.
|
|
90
|
-
//
|
|
91
|
-
// What the loop costs is now a function of the line, not the count: the first
|
|
92
|
-
// delivery writes the record's ids, kind, size and attribute names — never its
|
|
93
|
-
// body, which is a command or event and may be about a person — and every
|
|
94
|
-
// redelivery after it costs a single identity line. A stranded
|
|
95
|
-
// message is ~150 KB over a fortnight rather than ~17 MB, which is small enough
|
|
96
|
-
// that ending the loop early is an operator's job rather than the topology's.
|
|
97
|
-
let entryPointCode = `export const handler = async (event) => {
|
|
98
|
-
const records = event?.Records ?? [];
|
|
99
|
-
for (const record of records) {
|
|
100
|
-
const attrs = record?.attributes ?? {};
|
|
101
|
-
const receiveCount = Number(attrs.ApproximateReceiveCount ?? 1);
|
|
102
|
-
const identity =
|
|
103
|
-
"messageId=" + (record?.messageId ?? "unknown") +
|
|
104
|
-
" source=" + (attrs.DeadLetterQueueSourceArn ?? record?.eventSourceARN ?? "unknown") +
|
|
105
|
-
" receiveCount=" + receiveCount +
|
|
106
|
-
" bodyBytes=" + (record?.body?.length ?? 0);
|
|
107
|
-
if (receiveCount <= 1) {
|
|
108
|
-
// Ids, type and size, never the body: a dead letter is a command or event,
|
|
109
|
-
// and its payload may be about a person.
|
|
110
|
-
const body = (() => { try { return JSON.parse(record?.body ?? "null") } catch { return null } })();
|
|
111
|
-
const kind = body?.command?.TAG ?? body?.event?.TAG ?? (body?.Message ? "sns" : "unknown");
|
|
112
|
-
console.error("DEAD LETTER ITEM: " + identity + " kind=" + kind +
|
|
113
|
-
" attributes=" + JSON.stringify(Object.keys(record?.messageAttributes ?? {})));
|
|
114
|
-
} else {
|
|
115
|
-
console.error("DEAD LETTER REDELIVERY: " + identity);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
throw new Error(
|
|
119
|
-
"Dead-lettered " + records.length +
|
|
120
|
-
" message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
|
|
121
|
-
);
|
|
122
|
-
};`
|
|
74
|
+
// The function's whole code is `Util_DeadLetterHandler`'s compiled file, which imports
|
|
75
|
+
// nothing (see there), shipped as `index.mjs`. It sits beside `Util_Bundle`'s compiled file,
|
|
76
|
+
// so it resolves from there.
|
|
77
|
+
let entryPointCode = NodeFs.readFileSync(
|
|
78
|
+
Util_Bundle.localRequire->NodeModule.requireResolve("./Util_DeadLetterHandler.res.mjs"),
|
|
79
|
+
)
|
|
123
80
|
|
|
124
81
|
let archiveContents: dict<Pulumi.Archive.assetOrArchive> = Dict.make()
|
|
125
82
|
archiveContents->Dict.set(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Nodefs from "node:fs";
|
|
3
4
|
import * as Aws from "@pulumi/aws";
|
|
4
5
|
import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.res.mjs";
|
|
5
6
|
import * as Pulumi from "@pulumi/pulumi";
|
|
@@ -11,6 +12,7 @@ import * as Monitoring$ReventlessCore from "@reventlessdev/reventless-core/src/a
|
|
|
11
12
|
import * as Util_Bundle$ReventlessAws from "./Util_Bundle.res.mjs";
|
|
12
13
|
import * as Util_Lambda$ReventlessAws from "./Util_Lambda.res.mjs";
|
|
13
14
|
import * as Util_LambdaLogging$ReventlessAws from "./Util_LambdaLogging.res.mjs";
|
|
15
|
+
import * as Util_DeadLetterHandler$ReventlessAws from "./Util_DeadLetterHandler.res.mjs";
|
|
14
16
|
import * as Util_EventSourceMapping$ReventlessAws from "./Util_EventSourceMapping.res.mjs";
|
|
15
17
|
|
|
16
18
|
let name = "DeadLetterQueue";
|
|
@@ -45,32 +47,7 @@ let opts = {
|
|
|
45
47
|
|
|
46
48
|
let lambdaRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name, Pulumi.output(AWS$ReventlessAws.Lambda.principal), AWS_Tags$ReventlessAws.make(name, "Plugin", "Identity", "Plugin", undefined, undefined, undefined, undefined), opts);
|
|
47
49
|
|
|
48
|
-
let entryPointCode =
|
|
49
|
-
const records = event?.Records ?? [];
|
|
50
|
-
for (const record of records) {
|
|
51
|
-
const attrs = record?.attributes ?? {};
|
|
52
|
-
const receiveCount = Number(attrs.ApproximateReceiveCount ?? 1);
|
|
53
|
-
const identity =
|
|
54
|
-
"messageId=" + (record?.messageId ?? "unknown") +
|
|
55
|
-
" source=" + (attrs.DeadLetterQueueSourceArn ?? record?.eventSourceARN ?? "unknown") +
|
|
56
|
-
" receiveCount=" + receiveCount +
|
|
57
|
-
" bodyBytes=" + (record?.body?.length ?? 0);
|
|
58
|
-
if (receiveCount <= 1) {
|
|
59
|
-
// Ids, type and size, never the body: a dead letter is a command or event,
|
|
60
|
-
// and its payload may be about a person.
|
|
61
|
-
const body = (() => { try { return JSON.parse(record?.body ?? "null") } catch { return null } })();
|
|
62
|
-
const kind = body?.command?.TAG ?? body?.event?.TAG ?? (body?.Message ? "sns" : "unknown");
|
|
63
|
-
console.error("DEAD LETTER ITEM: " + identity + " kind=" + kind +
|
|
64
|
-
" attributes=" + JSON.stringify(Object.keys(record?.messageAttributes ?? {})));
|
|
65
|
-
} else {
|
|
66
|
-
console.error("DEAD LETTER REDELIVERY: " + identity);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
throw new Error(
|
|
70
|
-
"Dead-lettered " + records.length +
|
|
71
|
-
" message(s); see DEAD LETTER ITEM above. Failing so the messages are retained and Errors is non-zero."
|
|
72
|
-
);
|
|
73
|
-
};`;
|
|
50
|
+
let entryPointCode = Nodefs.readFileSync(Util_Bundle$ReventlessAws.localRequire.resolve("./Util_DeadLetterHandler.res.mjs"), "utf8");
|
|
74
51
|
|
|
75
52
|
let archiveContents = {};
|
|
76
53
|
|
|
@@ -185,6 +162,8 @@ Pulumi.all([
|
|
|
185
162
|
|
|
186
163
|
let retentionSeconds = 1209600;
|
|
187
164
|
|
|
165
|
+
let maxReceiveCount = Util_DeadLetterHandler$ReventlessAws.maxReceiveCount;
|
|
166
|
+
|
|
188
167
|
let visibilityTimeoutSeconds = 900;
|
|
189
168
|
|
|
190
169
|
export {
|
|
@@ -192,6 +171,7 @@ export {
|
|
|
192
171
|
nameFifo,
|
|
193
172
|
queueTags,
|
|
194
173
|
retentionSeconds,
|
|
174
|
+
maxReceiveCount,
|
|
195
175
|
visibilityTimeoutSeconds,
|
|
196
176
|
queue,
|
|
197
177
|
fifoQueue,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
module H = Util_DeadLetterHandler
|
|
4
|
+
|
|
5
|
+
// A dead letter writes one full line on its first delivery here and a short one on every
|
|
6
|
+
// redelivery. A message moved here by a redrive policy keeps its receive count, so its first
|
|
7
|
+
// delivery reports `maxReceiveCount + 1`; reading only 1 as first meant the full line was
|
|
8
|
+
// never written for any of them.
|
|
9
|
+
|
|
10
|
+
let recordWith = (~receiveCount: int): H.record => {
|
|
11
|
+
messageId: "m-1",
|
|
12
|
+
body: `{"command":{"TAG":"Heartbeat"}}`,
|
|
13
|
+
attributes: Dict.fromArray([
|
|
14
|
+
("ApproximateReceiveCount", Int.toString(receiveCount)),
|
|
15
|
+
("DeadLetterQueueSourceArn", "arn:aws:sqs:eu-west-1:1:CmdTopic"),
|
|
16
|
+
]),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let isItem = line => line->String.startsWith("DEAD LETTER ITEM:")
|
|
20
|
+
|
|
21
|
+
describe("the dead-letter handler's line", () => {
|
|
22
|
+
testSync("is the full one when a redriven message first arrives", () => {
|
|
23
|
+
let line = H.lineOf(recordWith(~receiveCount=H.maxReceiveCount + 1))
|
|
24
|
+
expect((isItem(line), line->String.includes("kind=Heartbeat")))->toEqual((true, true))
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
testSync("is the full one for a message sent here directly", () =>
|
|
28
|
+
expect(isItem(H.lineOf(recordWith(~receiveCount=1))))->toBe(true)
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
testSync("is the short one on a redelivery", () => {
|
|
32
|
+
let line = H.lineOf(recordWith(~receiveCount=H.maxReceiveCount + 2))
|
|
33
|
+
expect((
|
|
34
|
+
line->String.startsWith("DEAD LETTER REDELIVERY:"),
|
|
35
|
+
line->String.includes("kind="),
|
|
36
|
+
))->toEqual((true, false))
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
testSync("names an unreadable body's kind unknown, and never writes the body", () => {
|
|
40
|
+
let line = H.lineOf({
|
|
41
|
+
messageId: "m-2",
|
|
42
|
+
body: "not json, about a person",
|
|
43
|
+
attributes: Dict.make(),
|
|
44
|
+
})
|
|
45
|
+
expect((
|
|
46
|
+
line->String.includes("kind=unknown"),
|
|
47
|
+
line->String.includes("about a person"),
|
|
48
|
+
))->toEqual((true, false))
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("the dead-letter handler", () => {
|
|
53
|
+
test("fails on purpose, so the messages stay on the queue", async () => {
|
|
54
|
+
let failed = switch await H.handler({records: [recordWith(~receiveCount=6)]}) {
|
|
55
|
+
| () => false
|
|
56
|
+
| exception _ => true
|
|
57
|
+
}
|
|
58
|
+
expect(failed)->toBe(true)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
// The function ships the compiled file alone, so it must import nothing.
|
|
62
|
+
testSync("compiles to a file that imports nothing", () => {
|
|
63
|
+
let text = NodeFs.readFileSync(
|
|
64
|
+
NodePath.join([
|
|
65
|
+
NodePath.dirname(NodeUrl.fileURLToPath(%raw("import.meta.url"))),
|
|
66
|
+
"..",
|
|
67
|
+
"src",
|
|
68
|
+
"util",
|
|
69
|
+
"Util_DeadLetterHandler.res.mjs",
|
|
70
|
+
]),
|
|
71
|
+
)
|
|
72
|
+
expect(
|
|
73
|
+
text
|
|
74
|
+
->String.split("\n")
|
|
75
|
+
->Array.some(l => l->String.startsWith("import ") || l->String.includes("require(")),
|
|
76
|
+
)->toBe(false)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Nodefs from "node:fs";
|
|
4
|
+
import * as Nodeurl from "node:url";
|
|
5
|
+
import * as Nodepath from "node:path";
|
|
6
|
+
import * as Util_DeadLetterHandler$ReventlessAws from "../src/util/Util_DeadLetterHandler.res.mjs";
|
|
7
|
+
|
|
8
|
+
function recordWith(receiveCount) {
|
|
9
|
+
return {
|
|
10
|
+
messageId: "m-1",
|
|
11
|
+
body: `{"command":{"TAG":"Heartbeat"}}`,
|
|
12
|
+
attributes: Object.fromEntries([
|
|
13
|
+
[
|
|
14
|
+
"ApproximateReceiveCount",
|
|
15
|
+
receiveCount.toString()
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
"DeadLetterQueueSourceArn",
|
|
19
|
+
"arn:aws:sqs:eu-west-1:1:CmdTopic"
|
|
20
|
+
]
|
|
21
|
+
])
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isItem(line) {
|
|
26
|
+
return line.startsWith("DEAD LETTER ITEM:");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
globalThis.describe("the dead-letter handler's line", () => {
|
|
30
|
+
globalThis.test("is the full one when a redriven message first arrives", () => {
|
|
31
|
+
let line = Util_DeadLetterHandler$ReventlessAws.lineOf(recordWith(Util_DeadLetterHandler$ReventlessAws.maxReceiveCount + 1 | 0));
|
|
32
|
+
globalThis.expect([
|
|
33
|
+
line.startsWith("DEAD LETTER ITEM:"),
|
|
34
|
+
line.includes("kind=Heartbeat")
|
|
35
|
+
]).toEqual([
|
|
36
|
+
true,
|
|
37
|
+
true
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
globalThis.test("is the full one for a message sent here directly", () => {
|
|
41
|
+
globalThis.expect(Util_DeadLetterHandler$ReventlessAws.lineOf(recordWith(1)).startsWith("DEAD LETTER ITEM:")).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
globalThis.test("is the short one on a redelivery", () => {
|
|
44
|
+
let line = Util_DeadLetterHandler$ReventlessAws.lineOf(recordWith(Util_DeadLetterHandler$ReventlessAws.maxReceiveCount + 2 | 0));
|
|
45
|
+
globalThis.expect([
|
|
46
|
+
line.startsWith("DEAD LETTER REDELIVERY:"),
|
|
47
|
+
line.includes("kind=")
|
|
48
|
+
]).toEqual([
|
|
49
|
+
true,
|
|
50
|
+
false
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
globalThis.test("names an unreadable body's kind unknown, and never writes the body", () => {
|
|
54
|
+
let line = Util_DeadLetterHandler$ReventlessAws.lineOf({
|
|
55
|
+
messageId: "m-2",
|
|
56
|
+
body: "not json, about a person",
|
|
57
|
+
attributes: {}
|
|
58
|
+
});
|
|
59
|
+
globalThis.expect([
|
|
60
|
+
line.includes("kind=unknown"),
|
|
61
|
+
line.includes("about a person")
|
|
62
|
+
]).toEqual([
|
|
63
|
+
true,
|
|
64
|
+
false
|
|
65
|
+
]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
globalThis.describe("the dead-letter handler", () => {
|
|
70
|
+
globalThis.test("fails on purpose, so the messages stay on the queue", async () => {
|
|
71
|
+
let failed;
|
|
72
|
+
try {
|
|
73
|
+
await Util_DeadLetterHandler$ReventlessAws.handler({
|
|
74
|
+
Records: [recordWith(6)]
|
|
75
|
+
});
|
|
76
|
+
failed = false;
|
|
77
|
+
} catch (exn) {
|
|
78
|
+
failed = true;
|
|
79
|
+
}
|
|
80
|
+
globalThis.expect(failed).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
globalThis.test("compiles to a file that imports nothing", () => {
|
|
83
|
+
let text = Nodefs.readFileSync(Nodepath.join(Nodepath.dirname(Nodeurl.fileURLToPath(import.meta.url)), "..", "src", "util", "Util_DeadLetterHandler.res.mjs"), "utf8");
|
|
84
|
+
globalThis.expect(text.split("\n").some(l => {
|
|
85
|
+
if (l.startsWith("import ")) {
|
|
86
|
+
return true;
|
|
87
|
+
} else {
|
|
88
|
+
return l.includes("require(");
|
|
89
|
+
}
|
|
90
|
+
})).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
let H;
|
|
95
|
+
|
|
96
|
+
export {
|
|
97
|
+
H,
|
|
98
|
+
recordWith,
|
|
99
|
+
isItem,
|
|
100
|
+
}
|
|
101
|
+
/* Not a pure module */
|