@reventlessdev/reventless-aws 3.0.0-alpha.184 → 3.0.0-alpha.186
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 +14 -0
- package/package.json +6 -6
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +6 -1
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +2 -4
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -2
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +67 -31
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +33 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +37 -0
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +158 -0
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res.mjs +122 -0
- package/tests/integration/DcbIntegrationHarness.res +63 -0
- package/tests/integration/DcbIntegrationHarness.res.mjs +68 -1
- package/tests/integration/EpCompositeSlice.res +61 -0
- package/tests/integration/EpCompositeSlice.res.mjs +70 -0
- package/tests/integration/EpCompositeSliceBehavior.res +35 -0
- package/tests/integration/EpCompositeSliceBehavior.res.mjs +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
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.186 (2026-07-08)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **reventless-core:** composite DCB slices can read back their own events ([4604a91](https://github.com/ReventlessDev/reventless-core/commit/4604a9159fb8bf59b2191ad69fee6613c7f75cd9))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.185 (2026-07-08)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **reventless-aws:** thread partitionTag into deployed DCB command Lambda append ([68f0859](https://github.com/ReventlessDev/reventless-core/commit/68f0859d3d5e44f6217a5dc0d8537cfec3d21194))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.184 (2026-07-08)
|
|
7
21
|
|
|
8
22
|
### Bug Fixes
|
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.186",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
"sury": "11.0.0-alpha.4",
|
|
10
10
|
"uuid": "^13.0.0",
|
|
11
11
|
"@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
|
|
12
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.25",
|
|
13
12
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.6",
|
|
14
13
|
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.47",
|
|
15
14
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
|
|
15
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.25",
|
|
16
16
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
|
|
17
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
18
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.
|
|
17
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.147",
|
|
18
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.91",
|
|
19
19
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.24",
|
|
20
|
-
"@reventlessdev/reventless-
|
|
21
|
-
"@reventlessdev/reventless-
|
|
20
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.69",
|
|
21
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.11"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"rescript": "^12.3.0",
|
|
@@ -124,7 +124,12 @@ let toItem = (
|
|
|
124
124
|
item->Dict.set(attrName, tag.value->JSON.Encode.string)
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
-
// Add composite tag attribute if multiple tags
|
|
127
|
+
// Add composite tag attribute if multiple tags. The tag set here is exactly the
|
|
128
|
+
// event's *entity* tags (no framework provenance tag is smuggled in — see
|
|
129
|
+
// docs/plans/done/dcb-composite-query-clause-fence-contention.md), so the stored
|
|
130
|
+
// `tag_composite` key is byte-identical to the key a composite decision read
|
|
131
|
+
// builds from the command's entity tags — the write/read match a composite
|
|
132
|
+
// slice's OCC depends on.
|
|
128
133
|
if event.tags->Array.length > 1 {
|
|
129
134
|
let composite = compositeTagKey(event.tags)
|
|
130
135
|
item->Dict.set("tag_composite", composite->JSON.Encode.string)
|
|
@@ -18,8 +18,7 @@ open PulumiAws
|
|
|
18
18
|
// ── Handler code ─────────────────────────────────────────────────────────────
|
|
19
19
|
//
|
|
20
20
|
// Receives SNS-via-SQS records (rawMessageDelivery=true → body IS the event JSON).
|
|
21
|
-
// Publishes {position, eventType, payload
|
|
22
|
-
// The "originatorSlice" tag is injected by StateChangeSlice_Callback.encodeEvent.
|
|
21
|
+
// Publishes {position, eventType, payload} to AppSync Events channel.
|
|
23
22
|
|
|
24
23
|
let makeHandlerCode = (~topicName: string): string => {
|
|
25
24
|
// Mirrors StateTopic_AppSync.pathSegment: AppSync Events channel segments
|
|
@@ -72,8 +71,7 @@ export async function handler(event) {
|
|
|
72
71
|
console.error("EventLogSubscription: failed to parse record body", record.body, e);
|
|
73
72
|
continue;
|
|
74
73
|
}
|
|
75
|
-
const
|
|
76
|
-
const payload = { position: body.position, eventType: body.eventType, payload: body.data, originatorSlice: originatorSlice ?? null };
|
|
74
|
+
const payload = { position: body.position, eventType: body.eventType, payload: body.data };
|
|
77
75
|
const reqBody = JSON.stringify({ id: record.messageId, channel: CHANNEL, events: [JSON.stringify(payload)] });
|
|
78
76
|
const auth = await signedHeaders(url.hostname, "/event", reqBody);
|
|
79
77
|
const res = await fetch(APPSYNC_ENDPOINT + "/event", {
|
|
@@ -61,8 +61,7 @@ export async function handler(event) {
|
|
|
61
61
|
console.error("EventLogSubscription: failed to parse record body", record.body, e);
|
|
62
62
|
continue;
|
|
63
63
|
}
|
|
64
|
-
const
|
|
65
|
-
const payload = { position: body.position, eventType: body.eventType, payload: body.data, originatorSlice: originatorSlice ?? null };
|
|
64
|
+
const payload = { position: body.position, eventType: body.eventType, payload: body.data };
|
|
66
65
|
const reqBody = JSON.stringify({ id: record.messageId, channel: CHANNEL, events: [JSON.stringify(payload)] });
|
|
67
66
|
const auth = await signedHeaders(url.hostname, "/event", reqBody);
|
|
68
67
|
const res = await fetch(APPSYNC_ENDPOINT + "/event", {
|
|
@@ -12,6 +12,7 @@ import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/Req
|
|
|
12
12
|
import {
|
|
13
13
|
extractVariantNames,
|
|
14
14
|
deriveEffectiveScope,
|
|
15
|
+
derivePartitionTag,
|
|
15
16
|
} from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
16
17
|
import { $$String as IdString } from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
17
18
|
import { Make as dcbEventLogOperationsMake } from "@reventlessdev/reventless-core/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs";
|
|
@@ -89,6 +90,61 @@ function getIdStringSchema() {
|
|
|
89
90
|
// can drive the real `cmdGenHandler` end-to-end against DynamoDB Local.
|
|
90
91
|
export async function buildHandlersForConfig(config, opts = {}) {
|
|
91
92
|
const loadModule = opts.loadModule ?? dynamicImport;
|
|
93
|
+
|
|
94
|
+
// Load all spec/behavior pairs first. Both the decision-query scope
|
|
95
|
+
// (crossPartitionTagKeys / tagKeysByEventType) AND the storage `partitionTag`
|
|
96
|
+
// are derived from the produced event schemas, and the DynamoDB storage ops
|
|
97
|
+
// below must be built with them — so slices load before anything else.
|
|
98
|
+
const loadedSlices = await Promise.all(
|
|
99
|
+
config.stateChangeSliceModules.map(async ({ spec, behavior }) => {
|
|
100
|
+
const specModule = await loadModule(spec);
|
|
101
|
+
const behaviorModule = await loadModule(behavior);
|
|
102
|
+
const patchedSpec = patchSpecId(specModule);
|
|
103
|
+
return { patchedSpec, behaviorModule };
|
|
104
|
+
})
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// Single source of truth with Dcb_Builder.res: prefer slice-graph inference
|
|
108
|
+
// (which classifies cross-partition `@ref` reference reads), falling back to
|
|
109
|
+
// annotations only on ambiguity. Re-deriving from annotations alone here
|
|
110
|
+
// silently dropped inferred cross-partition reads, so every reference-guarded
|
|
111
|
+
// command was rejected on the deployed path — see
|
|
112
|
+
// docs/analysis/dcb-runtime-scope-annotation-drift.md.
|
|
113
|
+
const { crossPartitionTagKeys, tagKeysByEventType } = deriveEffectiveScope(
|
|
114
|
+
loadedSlices.map(({ patchedSpec }) => ({
|
|
115
|
+
name: patchedSpec.name,
|
|
116
|
+
commandSchema: patchedSpec.commandSchema,
|
|
117
|
+
consumedEventSchema: patchedSpec.consumedEventSchema,
|
|
118
|
+
eventSchema: patchedSpec.eventSchema,
|
|
119
|
+
}))
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
// Derive the storage partition tag the SAME way Dcb_Builder.res does at
|
|
123
|
+
// deploy time (over the produced event schemas). This MUST reach the DynamoDB
|
|
124
|
+
// `append` — without it `partitionTag` defaults to `None`, the composite-fence
|
|
125
|
+
// collapse (gated on `Composite`) never fires, and a `@compositePartitionTag`
|
|
126
|
+
// slice writes one fence PER MEMBER instead of one synthetic composite fence.
|
|
127
|
+
// A deploy-time fan-out sharing low-cardinality members (environment, plugin,
|
|
128
|
+
// …) then turns those member fences hot → TransactionConflict → retries
|
|
129
|
+
// exhausted. The deploy-time adapter (DcbEventLogStorage_DynamoDb.make) already
|
|
130
|
+
// threads it; the deployed Lambda dropped it. See
|
|
131
|
+
// docs/plans/dcb-composite-fence-residual-burst-contention.md.
|
|
132
|
+
// `derivePartitionTag` throws only on a misconfigured spec, which the deploy
|
|
133
|
+
// would already have rejected; guard defensively so a runtime edge case
|
|
134
|
+
// degrades to the (old) untagged behaviour rather than crashing cold start.
|
|
135
|
+
let partitionTag = undefined;
|
|
136
|
+
try {
|
|
137
|
+
partitionTag = derivePartitionTag(
|
|
138
|
+
loadedSlices.map(({ patchedSpec }) => [
|
|
139
|
+
patchedSpec.name,
|
|
140
|
+
patchedSpec.moduleUrl ?? patchedSpec.name,
|
|
141
|
+
patchedSpec.eventSchema,
|
|
142
|
+
])
|
|
143
|
+
);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
log.warn("could not derive partitionTag; falling back to untagged fences: " + (err && err.message), { comp: "DcbCommandTopicRuntime" });
|
|
146
|
+
}
|
|
147
|
+
|
|
92
148
|
// `pgConnection`, when present in HANDLER_CONFIG, selects the Postgres DCB
|
|
93
149
|
// runtime (dcbEventLogTableName doubles as the `dcb_event.log_name`); absence
|
|
94
150
|
// keeps the DynamoDB path byte-identical. NB: this swaps only the *storage*
|
|
@@ -108,10 +164,18 @@ export async function buildHandlersForConfig(config, opts = {}) {
|
|
|
108
164
|
})()
|
|
109
165
|
: (() => {
|
|
110
166
|
const resolvedTable = { name: config.dcbEventLogTableName };
|
|
167
|
+
// Positional args match the ReScript-compiled signatures:
|
|
168
|
+
// read(table, crossPartitionTagKeys)
|
|
169
|
+
// append(table, partitionTag, crossPartitionTagKeys)
|
|
170
|
+
// readStream(table, crossPartitionTagKeys)
|
|
171
|
+
// Threading partitionTag activates the composite-fence collapse; threading
|
|
172
|
+
// crossPartitionTagKeys routes single-tag `@crossPartition` reads to the
|
|
173
|
+
// per-tag GSI and bumps their fences on every carrier — matching the
|
|
174
|
+
// deploy-time adapter's storage ops exactly.
|
|
111
175
|
return {
|
|
112
|
-
read: read(resolvedTable),
|
|
113
|
-
append: append(resolvedTable),
|
|
114
|
-
readStream: readStream(resolvedTable),
|
|
176
|
+
read: read(resolvedTable, crossPartitionTagKeys),
|
|
177
|
+
append: append(resolvedTable, partitionTag, crossPartitionTagKeys),
|
|
178
|
+
readStream: readStream(resolvedTable, crossPartitionTagKeys),
|
|
115
179
|
};
|
|
116
180
|
})();
|
|
117
181
|
|
|
@@ -126,34 +190,6 @@ export async function buildHandlersForConfig(config, opts = {}) {
|
|
|
126
190
|
publishJson: async (_name, _meta, _json) => {},
|
|
127
191
|
});
|
|
128
192
|
|
|
129
|
-
// Load all spec/behavior pairs first, then derive the per-plugin
|
|
130
|
-
// tagKeysByEventType / crossPartitionTagKeys across the produced event
|
|
131
|
-
// schemas — mirroring Dcb_Builder.res's build-time derivation — so the
|
|
132
|
-
// runtime slice callback receives the same values it would in-process.
|
|
133
|
-
const loadedSlices = await Promise.all(
|
|
134
|
-
config.stateChangeSliceModules.map(async ({ spec, behavior }) => {
|
|
135
|
-
const specModule = await loadModule(spec);
|
|
136
|
-
const behaviorModule = await loadModule(behavior);
|
|
137
|
-
const patchedSpec = patchSpecId(specModule);
|
|
138
|
-
return { patchedSpec, behaviorModule };
|
|
139
|
-
})
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
// Single source of truth with Dcb_Builder.res: prefer slice-graph inference
|
|
143
|
-
// (which classifies cross-partition `@ref` reference reads), falling back to
|
|
144
|
-
// annotations only on ambiguity. Re-deriving from annotations alone here
|
|
145
|
-
// silently dropped inferred cross-partition reads, so every reference-guarded
|
|
146
|
-
// command was rejected on the deployed path — see
|
|
147
|
-
// docs/analysis/dcb-runtime-scope-annotation-drift.md.
|
|
148
|
-
const { crossPartitionTagKeys, tagKeysByEventType } = deriveEffectiveScope(
|
|
149
|
-
loadedSlices.map(({ patchedSpec }) => ({
|
|
150
|
-
name: patchedSpec.name,
|
|
151
|
-
commandSchema: patchedSpec.commandSchema,
|
|
152
|
-
consumedEventSchema: patchedSpec.consumedEventSchema,
|
|
153
|
-
eventSchema: patchedSpec.eventSchema,
|
|
154
|
-
}))
|
|
155
|
-
);
|
|
156
|
-
|
|
157
193
|
loadedSlices.forEach(({ patchedSpec, behaviorModule }) => {
|
|
158
194
|
const sliceCallback = stateChangeSliceCallbackMake(patchedSpec)(behaviorModule);
|
|
159
195
|
const commandSchema = patchedSpec.commandSchema;
|
|
@@ -526,6 +526,39 @@ describe("Runtime.buildConditionalTransactItems — composite partition fences o
|
|
|
526
526
|
})
|
|
527
527
|
})
|
|
528
528
|
|
|
529
|
+
describe("Runtime.toItem — tag_composite is keyed on the event's entity tags", () => {
|
|
530
|
+
// Guards the composite write/read key alignment a composite-partition slice's OCC
|
|
531
|
+
// depends on: the stored `tag_composite` must equal the key a composite decision
|
|
532
|
+
// read builds from the command's entity tags (`compositeTagKey`). Historically an
|
|
533
|
+
// `originatorSlice` provenance tag was smuggled into the tag set and polluted this
|
|
534
|
+
// key, so a composite slice could never read back its own events. See
|
|
535
|
+
// docs/analysis/dcb-event-provenance-and-metadata.md and
|
|
536
|
+
// docs/plans/done/dcb-composite-query-clause-fence-contention.md.
|
|
537
|
+
let tagOf = (key, value): Reventless.DcbTag.tag => {key, value}
|
|
538
|
+
let entityTags = [
|
|
539
|
+
tagOf("environment", "prod"),
|
|
540
|
+
tagOf("platformName", "plat"),
|
|
541
|
+
tagOf("pluginName", "plug"),
|
|
542
|
+
tagOf("componentName", "compA"),
|
|
543
|
+
tagOf("resourceName", "r1"),
|
|
544
|
+
]
|
|
545
|
+
let storedEvent: ReventlessCore.DcbEventLog_Adapter.rawStoredEvent = {
|
|
546
|
+
eventType: "ResourceAdded",
|
|
547
|
+
data: JSON.Object(Dict.make()),
|
|
548
|
+
tags: entityTags,
|
|
549
|
+
meta: testMeta(),
|
|
550
|
+
}
|
|
551
|
+
let storedComposite =
|
|
552
|
+
Runtime.toItem("100", storedEvent, ~recordedAt="2026-07-08T00:00:00Z")
|
|
553
|
+
->JSON.Decode.object
|
|
554
|
+
->Option.flatMap(o => o->Dict.get("tag_composite"))
|
|
555
|
+
->Option.flatMap(JSON.Decode.string)
|
|
556
|
+
|
|
557
|
+
testSync("stored tag_composite equals the key a composite read would query", () => {
|
|
558
|
+
expect(storedComposite)->toEqual(Some(Runtime.compositeTagKey(entityTags)))
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
529
562
|
describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
|
|
530
563
|
let event = (eventType, tags): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
|
|
531
564
|
eventType,
|
|
@@ -653,6 +653,43 @@ globalThis.describe("Runtime.buildConditionalTransactItems — composite partiti
|
|
|
653
653
|
});
|
|
654
654
|
});
|
|
655
655
|
|
|
656
|
+
globalThis.describe("Runtime.toItem — tag_composite is keyed on the event's entity tags", () => {
|
|
657
|
+
let entityTags = [
|
|
658
|
+
{
|
|
659
|
+
key: "environment",
|
|
660
|
+
value: "prod"
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
key: "platformName",
|
|
664
|
+
value: "plat"
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
key: "pluginName",
|
|
668
|
+
value: "plug"
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
key: "componentName",
|
|
672
|
+
value: "compA"
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
key: "resourceName",
|
|
676
|
+
value: "r1"
|
|
677
|
+
}
|
|
678
|
+
];
|
|
679
|
+
let storedEvent_data = {};
|
|
680
|
+
let storedEvent_meta = testMeta();
|
|
681
|
+
let storedEvent = {
|
|
682
|
+
eventType: "ResourceAdded",
|
|
683
|
+
data: storedEvent_data,
|
|
684
|
+
tags: entityTags,
|
|
685
|
+
meta: storedEvent_meta
|
|
686
|
+
};
|
|
687
|
+
let storedComposite = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.toItem("100", storedEvent, undefined, "2026-07-08T00:00:00Z")), o => o["tag_composite"]), Stdlib_JSON.Decode.string);
|
|
688
|
+
globalThis.test("stored tag_composite equals the key a composite read would query", () => {
|
|
689
|
+
globalThis.expect(storedComposite).toEqual(DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.compositeTagKey(entityTags));
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
656
693
|
globalThis.describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
|
|
657
694
|
let event = (eventType, tags) => ({
|
|
658
695
|
eventType: eventType,
|
|
@@ -59,6 +59,67 @@ let runOneAppSyncEvent: (string, JSON.t) => promise<JSON.t> = %raw(`
|
|
|
59
59
|
}
|
|
60
60
|
`)
|
|
61
61
|
|
|
62
|
+
// Same as `runOneAppSyncEvent` but wires the composite-partition fixture
|
|
63
|
+
// (EpCompositeSlice) through the real entry point. Proves the deployed path
|
|
64
|
+
// derives and threads `partitionTag = Composite(...)` into the DynamoDB
|
|
65
|
+
// `append` — the fix for the residual burst-contention bug.
|
|
66
|
+
let runOneCompositeEvent: (string, JSON.t) => promise<JSON.t> = %raw(`
|
|
67
|
+
async (tableName, event) => {
|
|
68
|
+
const { buildHandlersForConfig } = await import(
|
|
69
|
+
"@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs"
|
|
70
|
+
);
|
|
71
|
+
const Effect = await import("effect/Effect");
|
|
72
|
+
const { tag: requestContextTag } = await import(
|
|
73
|
+
"@reventlessdev/reventless-core/src/RequestContext.res.mjs"
|
|
74
|
+
);
|
|
75
|
+
const { commandOutcomeToJson } = await import(
|
|
76
|
+
"@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs"
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
const loadModule = async (specifier) => {
|
|
80
|
+
if (specifier === "ep-composite://spec") return await import("./EpCompositeSlice.res.mjs");
|
|
81
|
+
if (specifier === "ep-composite://behavior") return await import("./EpCompositeSliceBehavior.res.mjs");
|
|
82
|
+
throw new Error("unknown test specifier: " + specifier);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const config = {
|
|
86
|
+
pluginName: "EpCompositePlugin",
|
|
87
|
+
dcbEventLogTableName: tableName,
|
|
88
|
+
stateChangeSliceModules: [{ spec: "ep-composite://spec", behavior: "ep-composite://behavior" }],
|
|
89
|
+
queueUrl: "https://sqs.eu-west-1.amazonaws.com/000000000000/ep-composite-queue",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const [, cmdGenHandler] = await buildHandlersForConfig(config, { loadModule });
|
|
93
|
+
|
|
94
|
+
const effect = cmdGenHandler(event)
|
|
95
|
+
.pipe(Effect.provideService(requestContextTag, { correlationId: "ep-composite" }));
|
|
96
|
+
const outcome = await Effect.runPromise(effect);
|
|
97
|
+
return commandOutcomeToJson(outcome);
|
|
98
|
+
}
|
|
99
|
+
`)
|
|
100
|
+
|
|
101
|
+
let buildCompositeCommandEvent = (~command, ~environment, ~resourceName): JSON.t => {
|
|
102
|
+
// `command` is the command type name as a plain string — the shape AppSync's
|
|
103
|
+
// direct-invoke resolver sends and `CommandGenerator.payload.command: string`
|
|
104
|
+
// expects.
|
|
105
|
+
let arguments = Dict.fromArray([
|
|
106
|
+
("environment", environment->JSON.Encode.string),
|
|
107
|
+
("resourceName", resourceName->JSON.Encode.string),
|
|
108
|
+
])
|
|
109
|
+
let meta = Dict.fromArray([
|
|
110
|
+
("user", "ep-composite"->JSON.Encode.string),
|
|
111
|
+
("ip", JSON.Encode.null),
|
|
112
|
+
])
|
|
113
|
+
Dict.fromArray([
|
|
114
|
+
("command", command->JSON.Encode.string),
|
|
115
|
+
("arguments", arguments->JSON.Encode.object),
|
|
116
|
+
("meta", meta->JSON.Encode.object),
|
|
117
|
+
])->JSON.Encode.object
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let buildAddResourceEvent = (~environment, ~resourceName): JSON.t =>
|
|
121
|
+
buildCompositeCommandEvent(~command="AddResource", ~environment, ~resourceName)
|
|
122
|
+
|
|
62
123
|
let buildAppSyncEvent = (widgetId): JSON.t => {
|
|
63
124
|
// CommandGenerator payload: `{command, arguments, meta, identity?}` — the
|
|
64
125
|
// exact shape AppSync's direct-invoke resolver sends (matches the resolver
|
|
@@ -98,4 +159,101 @@ describe("DcbCommandTopicEntryPoint integration", () => {
|
|
|
98
159
|
expect(s->String.includes("CommandAccepted"))->toBe(true)
|
|
99
160
|
},
|
|
100
161
|
)
|
|
162
|
+
|
|
163
|
+
// Residual composite-fence burst contention
|
|
164
|
+
// (docs/plans/dcb-composite-fence-residual-burst-contention.md).
|
|
165
|
+
//
|
|
166
|
+
// The entry point must derive `partitionTag = Composite(...)` and thread it
|
|
167
|
+
// into the DynamoDB `append`, so a `@compositePartitionTag` slice collapses to
|
|
168
|
+
// ONE synthetic composite fence per entity. Without the thread (the bug),
|
|
169
|
+
// `partitionTag` defaults to None, the collapse never fires, and each append
|
|
170
|
+
// fences on its individual members — the shared low-cardinality `environment`
|
|
171
|
+
// member then serialises/rejects distinct resources.
|
|
172
|
+
testAsync(
|
|
173
|
+
"composite-partition burst: distinct resources sharing a prefix all commit, only composite fences written",
|
|
174
|
+
async () => {
|
|
175
|
+
let table = await H.createDcbTableWithTagKeys(
|
|
176
|
+
"EpComposite_" ++ Date.now()->Float.toString,
|
|
177
|
+
["environment", "resourceName"],
|
|
178
|
+
)
|
|
179
|
+
let env = "prod"
|
|
180
|
+
let resourceNames = ["res-a", "res-b", "res-c", "res-d", "res-e"]
|
|
181
|
+
|
|
182
|
+
// Concurrent burst — simulates the deploy-sync fan-out that surfaced the
|
|
183
|
+
// `retries exhausted` cascade on alpha.
|
|
184
|
+
let outcomes =
|
|
185
|
+
await resourceNames
|
|
186
|
+
->Array.map(resourceName =>
|
|
187
|
+
runOneCompositeEvent(table.name, buildAddResourceEvent(~environment=env, ~resourceName))
|
|
188
|
+
)
|
|
189
|
+
->Promise.all
|
|
190
|
+
|
|
191
|
+
// Every distinct composite entity must commit — none may conflict on the
|
|
192
|
+
// shared `environment` prefix.
|
|
193
|
+
outcomes->Array.forEach(outcomeJson => {
|
|
194
|
+
let s = outcomeJson->JSON.stringifyAny->Option.getOr("<unserializable>")
|
|
195
|
+
expect(s->String.includes("CommandAccepted"))->toBe(true)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
// Mechanism-level proof: the only fence rows are the synthetic composite
|
|
199
|
+
// fences (`fence#__dcb_composite__:…`) — one per entity — with NO
|
|
200
|
+
// per-member `fence#environment:…` / `fence#resourceName:…` rows.
|
|
201
|
+
let fenceIds = await H.scanFenceIds(table)
|
|
202
|
+
let compositeFencePrefix = "fence#" ++ DcbEventLogStorage_DynamoDb_Runtime.compositeFenceTagKey
|
|
203
|
+
expect(fenceIds->Array.length)->toBe(resourceNames->Array.length)
|
|
204
|
+
fenceIds->Array.forEach(id => expect(id->String.startsWith(compositeFencePrefix))->toBe(true))
|
|
205
|
+
|
|
206
|
+
await H.deleteTable(table)
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
// Composite read-back invariant
|
|
211
|
+
// (docs/plans/done/dcb-composite-query-clause-fence-contention.md).
|
|
212
|
+
//
|
|
213
|
+
// A composite-partition slice must be able to READ BACK its own events. The
|
|
214
|
+
// stored `tag_composite` key is computed from the event's tags; if the
|
|
215
|
+
// framework `originatorSlice` provenance tag (appended by
|
|
216
|
+
// StateChangeSlice_Callback.encodeEvent) leaks into that key, it diverges from
|
|
217
|
+
// the read key (built from the command's entity tags only) and the composite
|
|
218
|
+
// GSI read never matches — so a follow-up command reads empty state.
|
|
219
|
+
//
|
|
220
|
+
// `TouchResource` requires state `Added` (i.e. it must observe the prior
|
|
221
|
+
// `ResourceAdded`) to succeed. With the bug the read misses → state `Absent` →
|
|
222
|
+
// `NotFound` → CommandRejected. With the fix the read hits → `ResourceTouched`
|
|
223
|
+
// is produced → CommandAccepted and a second event persists.
|
|
224
|
+
testAsync(
|
|
225
|
+
"composite read-back: a follow-up command sees the slice's own prior event",
|
|
226
|
+
async () => {
|
|
227
|
+
let table = await H.createDcbTableWithTagKeys(
|
|
228
|
+
"EpCompositeRB_" ++ Date.now()->Float.toString,
|
|
229
|
+
["environment", "resourceName"],
|
|
230
|
+
)
|
|
231
|
+
let env = "prod"
|
|
232
|
+
let res = "res-rb"
|
|
233
|
+
|
|
234
|
+
let addOutcome = await runOneCompositeEvent(
|
|
235
|
+
table.name,
|
|
236
|
+
buildCompositeCommandEvent(~command="AddResource", ~environment=env, ~resourceName=res),
|
|
237
|
+
)
|
|
238
|
+
expect(
|
|
239
|
+
addOutcome->JSON.stringifyAny->Option.getOr("")->String.includes("CommandAccepted"),
|
|
240
|
+
)->toBe(true)
|
|
241
|
+
|
|
242
|
+
let touchOutcome = await runOneCompositeEvent(
|
|
243
|
+
table.name,
|
|
244
|
+
buildCompositeCommandEvent(~command="TouchResource", ~environment=env, ~resourceName=res),
|
|
245
|
+
)
|
|
246
|
+
// The read must have observed the ResourceAdded — otherwise TouchResource
|
|
247
|
+
// rejects NotFound (the originatorSlice-in-tag_composite regression).
|
|
248
|
+
expect(
|
|
249
|
+
touchOutcome->JSON.stringifyAny->Option.getOr("")->String.includes("CommandAccepted"),
|
|
250
|
+
)->toBe(true)
|
|
251
|
+
|
|
252
|
+
// Both events persisted: ResourceAdded + ResourceTouched.
|
|
253
|
+
let events = await H.scanEventTypes(table)
|
|
254
|
+
expect(events->Array.toSorted(String.compare))->toEqual(["ResourceAdded", "ResourceTouched"])
|
|
255
|
+
|
|
256
|
+
await H.deleteTable(table)
|
|
257
|
+
},
|
|
258
|
+
)
|
|
101
259
|
})
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
4
5
|
import * as DcbIntegrationHarness$ReventlessAws from "./DcbIntegrationHarness.res.mjs";
|
|
6
|
+
import * as DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws from "../../src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs";
|
|
5
7
|
|
|
6
8
|
let runOneAppSyncEvent = (async (tableName, event) => {
|
|
7
9
|
const { buildHandlersForConfig } = await import(
|
|
@@ -36,6 +38,80 @@ let runOneAppSyncEvent = (async (tableName, event) => {
|
|
|
36
38
|
return commandOutcomeToJson(outcome);
|
|
37
39
|
});
|
|
38
40
|
|
|
41
|
+
let runOneCompositeEvent = (async (tableName, event) => {
|
|
42
|
+
const { buildHandlersForConfig } = await import(
|
|
43
|
+
"@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs"
|
|
44
|
+
);
|
|
45
|
+
const Effect = await import("effect/Effect");
|
|
46
|
+
const { tag: requestContextTag } = await import(
|
|
47
|
+
"@reventlessdev/reventless-core/src/RequestContext.res.mjs"
|
|
48
|
+
);
|
|
49
|
+
const { commandOutcomeToJson } = await import(
|
|
50
|
+
"@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs"
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const loadModule = async (specifier) => {
|
|
54
|
+
if (specifier === "ep-composite://spec") return await import("./EpCompositeSlice.res.mjs");
|
|
55
|
+
if (specifier === "ep-composite://behavior") return await import("./EpCompositeSliceBehavior.res.mjs");
|
|
56
|
+
throw new Error("unknown test specifier: " + specifier);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const config = {
|
|
60
|
+
pluginName: "EpCompositePlugin",
|
|
61
|
+
dcbEventLogTableName: tableName,
|
|
62
|
+
stateChangeSliceModules: [{ spec: "ep-composite://spec", behavior: "ep-composite://behavior" }],
|
|
63
|
+
queueUrl: "https://sqs.eu-west-1.amazonaws.com/000000000000/ep-composite-queue",
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const [, cmdGenHandler] = await buildHandlersForConfig(config, { loadModule });
|
|
67
|
+
|
|
68
|
+
const effect = cmdGenHandler(event)
|
|
69
|
+
.pipe(Effect.provideService(requestContextTag, { correlationId: "ep-composite" }));
|
|
70
|
+
const outcome = await Effect.runPromise(effect);
|
|
71
|
+
return commandOutcomeToJson(outcome);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
function buildCompositeCommandEvent(command, environment, resourceName) {
|
|
75
|
+
let $$arguments = Object.fromEntries([
|
|
76
|
+
[
|
|
77
|
+
"environment",
|
|
78
|
+
environment
|
|
79
|
+
],
|
|
80
|
+
[
|
|
81
|
+
"resourceName",
|
|
82
|
+
resourceName
|
|
83
|
+
]
|
|
84
|
+
]);
|
|
85
|
+
let meta = Object.fromEntries([
|
|
86
|
+
[
|
|
87
|
+
"user",
|
|
88
|
+
"ep-composite"
|
|
89
|
+
],
|
|
90
|
+
[
|
|
91
|
+
"ip",
|
|
92
|
+
null
|
|
93
|
+
]
|
|
94
|
+
]);
|
|
95
|
+
return Object.fromEntries([
|
|
96
|
+
[
|
|
97
|
+
"command",
|
|
98
|
+
command
|
|
99
|
+
],
|
|
100
|
+
[
|
|
101
|
+
"arguments",
|
|
102
|
+
$$arguments
|
|
103
|
+
],
|
|
104
|
+
[
|
|
105
|
+
"meta",
|
|
106
|
+
meta
|
|
107
|
+
]
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildAddResourceEvent(environment, resourceName) {
|
|
112
|
+
return buildCompositeCommandEvent("AddResource", environment, resourceName);
|
|
113
|
+
}
|
|
114
|
+
|
|
39
115
|
function buildAppSyncEvent(widgetId) {
|
|
40
116
|
let command = Object.fromEntries([[
|
|
41
117
|
"AddWidget",
|
|
@@ -79,6 +155,49 @@ globalThis.describe("DcbCommandTopicEntryPoint integration", () => {
|
|
|
79
155
|
let s = Stdlib_Option.getOr(JSON.stringify(outcomeJson), "<unserializable>");
|
|
80
156
|
globalThis.expect(s.includes("CommandAccepted")).toBe(true);
|
|
81
157
|
});
|
|
158
|
+
globalThis.test("composite-partition burst: distinct resources sharing a prefix all commit, only composite fences written", async () => {
|
|
159
|
+
let table = await DcbIntegrationHarness$ReventlessAws.createDcbTableWithTagKeys("EpComposite_" + Date.now().toString(), [
|
|
160
|
+
"environment",
|
|
161
|
+
"resourceName"
|
|
162
|
+
]);
|
|
163
|
+
let resourceNames = [
|
|
164
|
+
"res-a",
|
|
165
|
+
"res-b",
|
|
166
|
+
"res-c",
|
|
167
|
+
"res-d",
|
|
168
|
+
"res-e"
|
|
169
|
+
];
|
|
170
|
+
let outcomes = await Promise.all(resourceNames.map(resourceName => runOneCompositeEvent(table.name, buildCompositeCommandEvent("AddResource", "prod", resourceName))));
|
|
171
|
+
outcomes.forEach(outcomeJson => {
|
|
172
|
+
let s = Stdlib_Option.getOr(JSON.stringify(outcomeJson), "<unserializable>");
|
|
173
|
+
globalThis.expect(s.includes("CommandAccepted")).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
let fenceIds = await DcbIntegrationHarness$ReventlessAws.scanFenceIds(table);
|
|
176
|
+
let compositeFencePrefix = "fence#" + DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.compositeFenceTagKey;
|
|
177
|
+
globalThis.expect(fenceIds.length).toBe(resourceNames.length);
|
|
178
|
+
fenceIds.forEach(id => {
|
|
179
|
+
globalThis.expect(id.startsWith(compositeFencePrefix)).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
|
|
182
|
+
});
|
|
183
|
+
globalThis.test("composite read-back: a follow-up command sees the slice's own prior event", async () => {
|
|
184
|
+
let table = await DcbIntegrationHarness$ReventlessAws.createDcbTableWithTagKeys("EpCompositeRB_" + Date.now().toString(), [
|
|
185
|
+
"environment",
|
|
186
|
+
"resourceName"
|
|
187
|
+
]);
|
|
188
|
+
let env = "prod";
|
|
189
|
+
let res = "res-rb";
|
|
190
|
+
let addOutcome = await runOneCompositeEvent(table.name, buildCompositeCommandEvent("AddResource", env, res));
|
|
191
|
+
globalThis.expect(Stdlib_Option.getOr(JSON.stringify(addOutcome), "").includes("CommandAccepted")).toBe(true);
|
|
192
|
+
let touchOutcome = await runOneCompositeEvent(table.name, buildCompositeCommandEvent("TouchResource", env, res));
|
|
193
|
+
globalThis.expect(Stdlib_Option.getOr(JSON.stringify(touchOutcome), "").includes("CommandAccepted")).toBe(true);
|
|
194
|
+
let events = await DcbIntegrationHarness$ReventlessAws.scanEventTypes(table);
|
|
195
|
+
globalThis.expect(events.toSorted(Primitive_string.compare)).toEqual([
|
|
196
|
+
"ResourceAdded",
|
|
197
|
+
"ResourceTouched"
|
|
198
|
+
]);
|
|
199
|
+
return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
|
|
200
|
+
});
|
|
82
201
|
});
|
|
83
202
|
|
|
84
203
|
let H;
|
|
@@ -86,6 +205,9 @@ let H;
|
|
|
86
205
|
export {
|
|
87
206
|
H,
|
|
88
207
|
runOneAppSyncEvent,
|
|
208
|
+
runOneCompositeEvent,
|
|
209
|
+
buildCompositeCommandEvent,
|
|
210
|
+
buildAddResourceEvent,
|
|
89
211
|
buildAppSyncEvent,
|
|
90
212
|
}
|
|
91
213
|
/* runOneAppSyncEvent Not a pure module */
|
|
@@ -73,6 +73,69 @@ let createDcbTable = async (tableName): Util_DynamoDb_Runtime.resolvedTable => {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Like `createDcbTable` but with a caller-supplied set of `tag_<key>` GSI names
|
|
77
|
+
// plus `tag_composite`. Used by the composite-partition scenarios whose member
|
|
78
|
+
// keys (e.g. `environment`, `resourceName`) are not in the default online-shop
|
|
79
|
+
// `gsiNames` superset.
|
|
80
|
+
let createDcbTableWithTagKeys = async (
|
|
81
|
+
tableName,
|
|
82
|
+
tagKeys: array<string>,
|
|
83
|
+
): Util_DynamoDb_Runtime.resolvedTable => {
|
|
84
|
+
let indexNames =
|
|
85
|
+
tagKeys->Array.map(k => `tag_${k}`)->Array.concat(["tag_composite"])
|
|
86
|
+
let attributeDefinitions = Array.concat(
|
|
87
|
+
[attrDef("id"), attrDef("position")],
|
|
88
|
+
indexNames->Array.map(attrDef),
|
|
89
|
+
)
|
|
90
|
+
let input =
|
|
91
|
+
Dict.fromArray([
|
|
92
|
+
("TableName", s(tableName)),
|
|
93
|
+
("AttributeDefinitions", attributeDefinitions->JSON.Encode.array),
|
|
94
|
+
("KeySchema", [keyEl("id", "HASH"), keyEl("position", "RANGE")]->JSON.Encode.array),
|
|
95
|
+
("GlobalSecondaryIndexes", indexNames->Array.map(gsi)->JSON.Encode.array),
|
|
96
|
+
("BillingMode", s("PAY_PER_REQUEST")),
|
|
97
|
+
])->JSON.Encode.object
|
|
98
|
+
let _ = await send(createTableCommand(input))
|
|
99
|
+
{
|
|
100
|
+
Util_DynamoDb_Runtime.id: tableName,
|
|
101
|
+
name: tableName,
|
|
102
|
+
arn: `arn:aws:dynamodb:local:000000000000:table/${tableName}`,
|
|
103
|
+
hashKey: "id",
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Returns the `id`s of every fence sentinel row in the table (`id` begins with
|
|
108
|
+
// `fence#`). Lets a test assert the fence *shape* directly — the mechanism-level
|
|
109
|
+
// signal that the composite collapse fired (one `fence#__dcb_composite__:…` per
|
|
110
|
+
// entity) rather than per-member `fence#<member>:…` rows.
|
|
111
|
+
let scanFenceIds = async (table: Util_DynamoDb_Runtime.resolvedTable): array<string> => {
|
|
112
|
+
let items = await Util_DynamoDb_Runtime.scanStream({tableName: table.name})
|
|
113
|
+
->Stream.runCollect
|
|
114
|
+
->Effect.runPromise
|
|
115
|
+
items->Array.filterMap(item =>
|
|
116
|
+
item
|
|
117
|
+
->JSON.Decode.object
|
|
118
|
+
->Option.flatMap(obj => obj->Dict.get("id"))
|
|
119
|
+
->Option.flatMap(JSON.Decode.string)
|
|
120
|
+
->Option.flatMap(id => id->String.startsWith("fence#") ? Some(id) : None)
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Returns the `event` attribute of every stored event row (fence sentinels carry
|
|
125
|
+
// no `event` attribute and are skipped). Lets a test assert which events actually
|
|
126
|
+
// persisted — the signal that a composite slice could read back its own state.
|
|
127
|
+
let scanEventTypes = async (table: Util_DynamoDb_Runtime.resolvedTable): array<string> => {
|
|
128
|
+
let items = await Util_DynamoDb_Runtime.scanStream({tableName: table.name})
|
|
129
|
+
->Stream.runCollect
|
|
130
|
+
->Effect.runPromise
|
|
131
|
+
items->Array.filterMap(item =>
|
|
132
|
+
item
|
|
133
|
+
->JSON.Decode.object
|
|
134
|
+
->Option.flatMap(obj => obj->Dict.get("event"))
|
|
135
|
+
->Option.flatMap(JSON.Decode.string)
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
76
139
|
let deleteTable = async (table: Util_DynamoDb_Runtime.resolvedTable) => {
|
|
77
140
|
let input = Dict.fromArray([("TableName", s(table.name))])->JSON.Encode.object
|
|
78
141
|
let _ = await send(deleteTableCommand(input))
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
|
+
import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
|
|
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";
|
|
6
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
7
|
+
import * as Effect from "effect/Effect";
|
|
3
8
|
import * as ClientDynamodb from "@aws-sdk/client-dynamodb";
|
|
4
9
|
import * as DynamoDb_DynamoDb$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DynamoDb.res.mjs";
|
|
5
10
|
import * as Util_DynamoDb_Runtime$ReventlessAws from "../../src/util/Util_DynamoDb_Runtime.res.mjs";
|
|
@@ -111,6 +116,65 @@ async function createDcbTable(tableName) {
|
|
|
111
116
|
};
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
async function createDcbTableWithTagKeys(tableName, tagKeys) {
|
|
120
|
+
let indexNames = tagKeys.map(k => `tag_` + k).concat(["tag_composite"]);
|
|
121
|
+
let attributeDefinitions = [
|
|
122
|
+
attrDef("id"),
|
|
123
|
+
attrDef("position")
|
|
124
|
+
].concat(indexNames.map(attrDef));
|
|
125
|
+
let input = Object.fromEntries([
|
|
126
|
+
[
|
|
127
|
+
"TableName",
|
|
128
|
+
tableName
|
|
129
|
+
],
|
|
130
|
+
[
|
|
131
|
+
"AttributeDefinitions",
|
|
132
|
+
attributeDefinitions
|
|
133
|
+
],
|
|
134
|
+
[
|
|
135
|
+
"KeySchema",
|
|
136
|
+
[
|
|
137
|
+
keyEl("id", "HASH"),
|
|
138
|
+
keyEl("position", "RANGE")
|
|
139
|
+
]
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
"GlobalSecondaryIndexes",
|
|
143
|
+
indexNames.map(gsi)
|
|
144
|
+
],
|
|
145
|
+
[
|
|
146
|
+
"BillingMode",
|
|
147
|
+
"PAY_PER_REQUEST"
|
|
148
|
+
]
|
|
149
|
+
]);
|
|
150
|
+
let cmd = new ClientDynamodb.CreateTableCommand(input);
|
|
151
|
+
await DynamoDb_DynamoDb$AwsSdk.client().send(cmd);
|
|
152
|
+
return {
|
|
153
|
+
id: tableName,
|
|
154
|
+
name: tableName,
|
|
155
|
+
arn: `arn:aws:dynamodb:local:000000000000:table/` + tableName,
|
|
156
|
+
hashKey: "id"
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function scanFenceIds(table) {
|
|
161
|
+
let items = await Effect.runPromise(Stream.runCollect(Util_DynamoDb_Runtime$ReventlessAws.scanStream({
|
|
162
|
+
TableName: table.name
|
|
163
|
+
})));
|
|
164
|
+
return Stdlib_Array.filterMap(items, item => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), obj => obj["id"]), Stdlib_JSON.Decode.string), id => {
|
|
165
|
+
if (id.startsWith("fence#")) {
|
|
166
|
+
return id;
|
|
167
|
+
}
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function scanEventTypes(table) {
|
|
172
|
+
let items = await Effect.runPromise(Stream.runCollect(Util_DynamoDb_Runtime$ReventlessAws.scanStream({
|
|
173
|
+
TableName: table.name
|
|
174
|
+
})));
|
|
175
|
+
return Stdlib_Array.filterMap(items, item => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), obj => obj["event"]), Stdlib_JSON.Decode.string));
|
|
176
|
+
}
|
|
177
|
+
|
|
114
178
|
async function deleteTable(table) {
|
|
115
179
|
let input = Object.fromEntries([[
|
|
116
180
|
"TableName",
|
|
@@ -154,9 +218,12 @@ export {
|
|
|
154
218
|
gsi,
|
|
155
219
|
gsiNames,
|
|
156
220
|
createDcbTable,
|
|
221
|
+
createDcbTableWithTagKeys,
|
|
222
|
+
scanFenceIds,
|
|
223
|
+
scanEventTypes,
|
|
157
224
|
deleteTable,
|
|
158
225
|
counter,
|
|
159
226
|
freshTable,
|
|
160
227
|
setFence,
|
|
161
228
|
}
|
|
162
|
-
/*
|
|
229
|
+
/* Stream Not a pure module */
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Composite-partition slice fixture for `DcbCommandTopicEntryPoint_IntegrationTest`.
|
|
2
|
+
//
|
|
3
|
+
// Reproduces the deploy-sync workload shape from
|
|
4
|
+
// docs/plans/done/dcb-composite-fence-residual-burst-contention.md: a
|
|
5
|
+
// `@compositePartitionTag` over a low-cardinality prefix (`environment`) plus a
|
|
6
|
+
// high-cardinality tail (`resourceName`). Distinct resources sharing the same
|
|
7
|
+
// `environment` must NOT contend — but only if the entry point threads
|
|
8
|
+
// `partitionTag = Composite(...)` into the DynamoDB `append`, activating the
|
|
9
|
+
// single-composite-fence collapse. Without that thread the slice writes one
|
|
10
|
+
// fence per member and the shared `environment` member goes hot.
|
|
11
|
+
//
|
|
12
|
+
// It ALSO guards the composite read-back invariant from
|
|
13
|
+
// docs/plans/done/dcb-composite-query-clause-fence-contention.md: `TouchResource`
|
|
14
|
+
// requires the slice to READ its own prior `ResourceAdded` (state must be
|
|
15
|
+
// `Added`), which only works if the stored `tag_composite` key matches the read
|
|
16
|
+
// key — i.e. the framework `originatorSlice` provenance tag is excluded from the
|
|
17
|
+
// composite key. With that bug present the read misses, state stays `Absent`, and
|
|
18
|
+
// `TouchResource` is rejected `NotFound`.
|
|
19
|
+
//
|
|
20
|
+
// Explicit `@s.matches(Reventless.DcbTag.compositePartitionMember(...))` form
|
|
21
|
+
// (not the `@compositePartitionTag` PPX shorthand) because reventless-ppx is not
|
|
22
|
+
// wired into reventless-aws's rescript.json — same reason as EpTestSlice.
|
|
23
|
+
|
|
24
|
+
@schema
|
|
25
|
+
type consumedEvent =
|
|
26
|
+
| ResourceAdded({environment: string, resourceName: string})
|
|
27
|
+
| ResourceTouched({environment: string, resourceName: string})
|
|
28
|
+
|
|
29
|
+
@schema
|
|
30
|
+
type command =
|
|
31
|
+
| AddResource({
|
|
32
|
+
environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
|
|
33
|
+
resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
|
|
34
|
+
})
|
|
35
|
+
| TouchResource({
|
|
36
|
+
environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
|
|
37
|
+
resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
@schema
|
|
41
|
+
type error =
|
|
42
|
+
| AlreadyAdded
|
|
43
|
+
| NotFound
|
|
44
|
+
|
|
45
|
+
@schema
|
|
46
|
+
type event =
|
|
47
|
+
| ResourceAdded({
|
|
48
|
+
environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
|
|
49
|
+
resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
|
|
50
|
+
})
|
|
51
|
+
| ResourceTouched({
|
|
52
|
+
environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
|
|
53
|
+
resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
let name = "EpCompositeSlice"
|
|
57
|
+
let moduleUrl = "ep-test://EpCompositeSlice"
|
|
58
|
+
let commandAuthorization = (_: command): Reventless.Authorization.permission => AllowAnonymous
|
|
59
|
+
let readConsistency = Reventless.ReadConsistency.EscalateOnRetry
|
|
60
|
+
|
|
61
|
+
// `module Id = Reventless.Id.String` — patched in by `patchSpecId` at runtime.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
5
|
+
|
|
6
|
+
let consumedEventSchema = S.union([
|
|
7
|
+
S.schema(s => ({
|
|
8
|
+
TAG: "ResourceAdded",
|
|
9
|
+
environment: s.m(S.string),
|
|
10
|
+
resourceName: s.m(S.string)
|
|
11
|
+
})),
|
|
12
|
+
S.schema(s => ({
|
|
13
|
+
TAG: "ResourceTouched",
|
|
14
|
+
environment: s.m(S.string),
|
|
15
|
+
resourceName: s.m(S.string)
|
|
16
|
+
}))
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
let commandSchema = S.union([
|
|
20
|
+
S.schema(s => ({
|
|
21
|
+
TAG: "AddResource",
|
|
22
|
+
environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
|
|
23
|
+
resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
|
|
24
|
+
})),
|
|
25
|
+
S.schema(s => ({
|
|
26
|
+
TAG: "TouchResource",
|
|
27
|
+
environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
|
|
28
|
+
resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
|
|
29
|
+
}))
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
let errorSchema = S.union([
|
|
33
|
+
S.literal("AlreadyAdded"),
|
|
34
|
+
S.literal("NotFound")
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
let eventSchema = S.union([
|
|
38
|
+
S.schema(s => ({
|
|
39
|
+
TAG: "ResourceAdded",
|
|
40
|
+
environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
|
|
41
|
+
resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
|
|
42
|
+
})),
|
|
43
|
+
S.schema(s => ({
|
|
44
|
+
TAG: "ResourceTouched",
|
|
45
|
+
environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
|
|
46
|
+
resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
|
|
47
|
+
}))
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
function commandAuthorization(param) {
|
|
51
|
+
return "AllowAnonymous";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let name = "EpCompositeSlice";
|
|
55
|
+
|
|
56
|
+
let moduleUrl = "ep-test://EpCompositeSlice";
|
|
57
|
+
|
|
58
|
+
let readConsistency = "EscalateOnRetry";
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
consumedEventSchema,
|
|
62
|
+
commandSchema,
|
|
63
|
+
errorSchema,
|
|
64
|
+
eventSchema,
|
|
65
|
+
name,
|
|
66
|
+
moduleUrl,
|
|
67
|
+
commandAuthorization,
|
|
68
|
+
readConsistency,
|
|
69
|
+
}
|
|
70
|
+
/* consumedEventSchema Not a pure module */
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Behavior pair for `EpCompositeSlice`. Append-on-empty / reject-on-seen, per
|
|
2
|
+
// composite entity: the decision read is scoped to one `{environment,
|
|
3
|
+
// resourceName}` composite, so a fresh resource sees `Absent` and appends, while
|
|
4
|
+
// a duplicate of the same composite key sees `Added` and is rejected.
|
|
5
|
+
//
|
|
6
|
+
// `TouchResource` is the read-back probe: it requires the slice to have already
|
|
7
|
+
// observed its own `ResourceAdded` (state `Added`) to succeed, so it exercises
|
|
8
|
+
// the composite read-matching-its-own-write invariant.
|
|
9
|
+
|
|
10
|
+
type state =
|
|
11
|
+
| Absent
|
|
12
|
+
| Added
|
|
13
|
+
|
|
14
|
+
let initialState = Absent
|
|
15
|
+
|
|
16
|
+
let evolve = (_state, event: EpCompositeSlice.consumedEvent) =>
|
|
17
|
+
switch event {
|
|
18
|
+
| ResourceAdded(_) => Added
|
|
19
|
+
| ResourceTouched(_) => Added
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let decide = (state, command: EpCompositeSlice.command): result<
|
|
23
|
+
array<EpCompositeSlice.event>,
|
|
24
|
+
EpCompositeSlice.error,
|
|
25
|
+
> =>
|
|
26
|
+
switch (state, command) {
|
|
27
|
+
| (Added, AddResource(_)) => Error(AlreadyAdded)
|
|
28
|
+
| (Absent, AddResource({environment, resourceName})) =>
|
|
29
|
+
Ok([ResourceAdded({environment, resourceName})])
|
|
30
|
+
| (Added, TouchResource({environment, resourceName})) =>
|
|
31
|
+
Ok([ResourceTouched({environment, resourceName})])
|
|
32
|
+
| (Absent, TouchResource(_)) => Error(NotFound)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let moduleUrl = "ep-test://EpCompositeSliceBehavior"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function evolve(_state, event) {
|
|
5
|
+
return "Added";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function decide(state, command) {
|
|
9
|
+
if (state === "Absent") {
|
|
10
|
+
if (command.TAG === "AddResource") {
|
|
11
|
+
return {
|
|
12
|
+
TAG: "Ok",
|
|
13
|
+
_0: [{
|
|
14
|
+
TAG: "ResourceAdded",
|
|
15
|
+
environment: command.environment,
|
|
16
|
+
resourceName: command.resourceName
|
|
17
|
+
}]
|
|
18
|
+
};
|
|
19
|
+
} else {
|
|
20
|
+
return {
|
|
21
|
+
TAG: "Error",
|
|
22
|
+
_0: "NotFound"
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
} else if (command.TAG === "AddResource") {
|
|
26
|
+
return {
|
|
27
|
+
TAG: "Error",
|
|
28
|
+
_0: "AlreadyAdded"
|
|
29
|
+
};
|
|
30
|
+
} else {
|
|
31
|
+
return {
|
|
32
|
+
TAG: "Ok",
|
|
33
|
+
_0: [{
|
|
34
|
+
TAG: "ResourceTouched",
|
|
35
|
+
environment: command.environment,
|
|
36
|
+
resourceName: command.resourceName
|
|
37
|
+
}]
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let initialState = "Absent";
|
|
43
|
+
|
|
44
|
+
let moduleUrl = "ep-test://EpCompositeSliceBehavior";
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
initialState,
|
|
48
|
+
evolve,
|
|
49
|
+
decide,
|
|
50
|
+
moduleUrl,
|
|
51
|
+
}
|
|
52
|
+
/* No side effect */
|