@reventlessdev/reventless-aws 3.0.0-alpha.181 → 3.0.0-alpha.182
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 +7 -0
- package/package.json +4 -4
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +74 -6
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +61 -12
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +140 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +158 -0
- package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res +164 -20
- package/tests/integration/DcbEventLogStorage_DynamoDb_IntegrationTest.res.mjs +149 -2
- package/tests/integration/DcbIntegrationHarness.res +19 -8
- package/tests/integration/DcbIntegrationHarness.res.mjs +5 -6
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
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.182 (2026-07-07)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **reventless-aws:** fence composite-partition DCB slices on one composite key ([e5f2d95](https://github.com/ReventlessDev/reventless-core/commit/e5f2d95652d795e4dea60e28548f96100a997e78))
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
# 3.0.0-alpha.181 (2026-07-07)
|
|
7
14
|
|
|
8
15
|
### 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.182",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
"uuid": "^13.0.0",
|
|
11
11
|
"@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
|
|
12
12
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.6",
|
|
13
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.25",
|
|
14
13
|
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.46",
|
|
14
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.25",
|
|
15
15
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
|
|
16
16
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
|
|
17
17
|
"@reventlessdev/reventless-core": "3.0.0-alpha.144",
|
|
18
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.90",
|
|
18
19
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.24",
|
|
19
20
|
"@reventlessdev/reventless-spec": "3.0.0-alpha.68",
|
|
20
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.8"
|
|
21
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.90"
|
|
21
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.8"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"rescript": "^12.3.0",
|
|
@@ -3,10 +3,33 @@ open AwsSdk.DynamoDb.DocumentClient
|
|
|
3
3
|
|
|
4
4
|
// --- Position Generation ---
|
|
5
5
|
|
|
6
|
+
// Hybrid-logical-clock minimal variant. The module-level refs live for the life of
|
|
7
|
+
// a warm Lambda container (reset on cold start), giving strictly-monotonic positions
|
|
8
|
+
// per call WITHIN a container: same-millisecond calls increment `counter`; a forward
|
|
9
|
+
// tick resets it to 0. No cross-container coordination — two same-ms writers on
|
|
10
|
+
// different containers both start at counter 0 and are ordered by the UUID tiebreaker
|
|
11
|
+
// (best-effort, exactly as the old `<ms>-<uuid>` format was). Format:
|
|
12
|
+
// `<ms>-<6-digit counter>-<uuid>`. Correctness never depends on this — fence
|
|
13
|
+
// comparisons anchor to what a slice observed, and `TransactWriteItems` serialises
|
|
14
|
+
// commits; this only makes reader/replay ordering predictable per container. Old
|
|
15
|
+
// `<ms>-<uuid>` positions remain valid: the ms prefix keeps the same 13-digit width,
|
|
16
|
+
// so cross-format comparison still orders by timestamp. See
|
|
17
|
+
// docs/plans/done/dcb-monotonic-position-generation.md.
|
|
18
|
+
let lastMs = ref(0.0)
|
|
19
|
+
let counter = ref(0)
|
|
20
|
+
|
|
6
21
|
let generatePosition = () => {
|
|
7
|
-
let
|
|
22
|
+
let now = Date.make()->Date.getTime
|
|
23
|
+
if now == lastMs.contents {
|
|
24
|
+
counter := counter.contents + 1
|
|
25
|
+
} else {
|
|
26
|
+
lastMs := now
|
|
27
|
+
counter := 0
|
|
28
|
+
}
|
|
29
|
+
let ms = now->Float.toString
|
|
30
|
+
let counterStr = counter.contents->Int.toString->String.padStart(6, "0")
|
|
8
31
|
let uuid = Uuid.v4()
|
|
9
|
-
`${
|
|
32
|
+
`${ms}-${counterStr}-${uuid}`
|
|
10
33
|
}
|
|
11
34
|
|
|
12
35
|
let generatePositionForBatch = (basePosition, index) => {
|
|
@@ -915,20 +938,39 @@ let appendUnconditional = async (
|
|
|
915
938
|
}
|
|
916
939
|
}
|
|
917
940
|
|
|
941
|
+
// A Composite partition fences on the WHOLE composite value, not on each member.
|
|
942
|
+
// The synthetic fence tag is keyed on `getCompositePartitionKeyValue` — the same
|
|
943
|
+
// value `derivePartitionKey` uses for the base-table `id` — so it is exactly as
|
|
944
|
+
// selective as the entity's storage partition (and the `tag_composite` read
|
|
945
|
+
// scope). Fencing per-member (the historical behaviour) gave every low-cardinality
|
|
946
|
+
// member (e.g. `environment`, `platformName`) its own fence, which a deploy-time
|
|
947
|
+
// fan-out sharing those prefixes turns into a hot partition → `TransactionConflict`
|
|
948
|
+
// → `retries exhausted`; it also over-fenced (two DISTINCT composite entities
|
|
949
|
+
// sharing a member value serialized needlessly). Plan:
|
|
950
|
+
// docs/plans/Backlog/dcb-hot-tag-fence-contention.md § "Root-cause correction".
|
|
951
|
+
let compositeFenceTagKey = "__dcb_composite__"
|
|
952
|
+
|
|
953
|
+
let makeCompositeFenceTag = (
|
|
954
|
+
tags: array<Reventless.DcbTag.tag>,
|
|
955
|
+
spec: Reventless.DcbTag.compositePartitionSpec,
|
|
956
|
+
): Reventless.DcbTag.tag => {
|
|
957
|
+
key: compositeFenceTagKey,
|
|
958
|
+
value: Reventless.DcbTag.getCompositePartitionKeyValue(tags, spec),
|
|
959
|
+
}
|
|
960
|
+
|
|
918
961
|
// The partition tag(s) of a written event — the ONLY fences an append may BUMP.
|
|
919
962
|
// A tag's fence must track exactly the partition-scoped events a single-tag read
|
|
920
963
|
// of that tag observes (events are stored under `id="<partitionKey>:<value>"`),
|
|
921
964
|
// so only the partition tag may advance it. Mirrors `derivePartitionKey`.
|
|
922
965
|
//
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
// tag) rather than risk under-fencing composite-partition slices.
|
|
966
|
+
// A Composite partition collapses to a single synthetic composite fence tag (see
|
|
967
|
+
// `makeCompositeFenceTag`) — one fence per composite entity, not one per member.
|
|
926
968
|
let eventPartitionTags = (
|
|
927
969
|
event: ReventlessCore.DcbEventLog_Adapter.rawStoredEvent,
|
|
928
970
|
~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>,
|
|
929
971
|
): array<Reventless.DcbTag.tag> =>
|
|
930
972
|
switch partitionTag {
|
|
931
|
-
| Some(Composite(
|
|
973
|
+
| Some(Composite(spec)) => [makeCompositeFenceTag(event.tags, spec)]
|
|
932
974
|
| Some(Simple(pt)) =>
|
|
933
975
|
switch event.tags->Array.find(t => t.key == pt.key) {
|
|
934
976
|
| Some(t) => [t]
|
|
@@ -994,6 +1036,32 @@ let buildConditionalTransactItems = (
|
|
|
994
1036
|
~partitionTag: option<Reventless.DcbTag.derivedPartitionTag>=?,
|
|
995
1037
|
~crossPartitionTagKeys: array<string>=[],
|
|
996
1038
|
): array<TransactWriteCommand.transactWriteItem> => {
|
|
1039
|
+
// For a Composite partition, fold the multi-tag composite read clause into a
|
|
1040
|
+
// single synthetic composite fence tag (`makeCompositeFenceTag`) so the rest of
|
|
1041
|
+
// this function fences it exactly like a Simple partition — one fence per
|
|
1042
|
+
// composite entity — instead of once per member (the hot-fence source). Only the
|
|
1043
|
+
// FENCE view of the query is rewritten here; the read path (`readStream`) keeps
|
|
1044
|
+
// the original member tags and its `tag_composite` GSI lookup. Gated on
|
|
1045
|
+
// `Composite`: Simple-partition composite-read slices (e.g. RecordProductDemand's
|
|
1046
|
+
// `{productId, orderId}` pair, where `productId` is a real independent partition)
|
|
1047
|
+
// are left untouched. `@crossPartition` carriers are unaffected — they are
|
|
1048
|
+
// handled by `crossPartitionEventTags` below, not by this composite clause.
|
|
1049
|
+
let cond = switch partitionTag {
|
|
1050
|
+
| Some(Composite(spec)) => {
|
|
1051
|
+
...cond,
|
|
1052
|
+
query: cond.query->Array.map(qi =>
|
|
1053
|
+
switch qi.tags {
|
|
1054
|
+
| Some(clauseTags) if clauseTags->Array.length > 1 => {
|
|
1055
|
+
...qi,
|
|
1056
|
+
tags: [makeCompositeFenceTag(clauseTags, spec)],
|
|
1057
|
+
}
|
|
1058
|
+
| _ => qi
|
|
1059
|
+
}
|
|
1060
|
+
),
|
|
1061
|
+
}
|
|
1062
|
+
| _ => cond
|
|
1063
|
+
}
|
|
1064
|
+
|
|
997
1065
|
// The partitions this append writes into — the only fences it may BUMP
|
|
998
1066
|
// (partition-scoped tags only; cross-partition tags below override this).
|
|
999
1067
|
let partitionTags = collectEventPartitionTags(events, ~partitionTag)
|
|
@@ -20,10 +20,26 @@ import * as DynamoDb_Error$ReventlessAws from "../../errors/DynamoDb_Error.res.m
|
|
|
20
20
|
import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
|
|
21
21
|
import * as Util_DynamoDb_Runtime$ReventlessAws from "../../util/Util_DynamoDb_Runtime.res.mjs";
|
|
22
22
|
|
|
23
|
+
let lastMs = {
|
|
24
|
+
contents: 0.0
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let counter = {
|
|
28
|
+
contents: 0
|
|
29
|
+
};
|
|
30
|
+
|
|
23
31
|
function generatePosition() {
|
|
24
|
-
let
|
|
32
|
+
let now = new Date().getTime();
|
|
33
|
+
if (now === lastMs.contents) {
|
|
34
|
+
counter.contents = counter.contents + 1 | 0;
|
|
35
|
+
} else {
|
|
36
|
+
lastMs.contents = now;
|
|
37
|
+
counter.contents = 0;
|
|
38
|
+
}
|
|
39
|
+
let ms = now.toString();
|
|
40
|
+
let counterStr = counter.contents.toString().padStart(6, "0");
|
|
25
41
|
let uuid = Uuid.v4();
|
|
26
|
-
return
|
|
42
|
+
return ms + `-` + counterStr + `-` + uuid;
|
|
27
43
|
}
|
|
28
44
|
|
|
29
45
|
function generatePositionForBatch(basePosition, index) {
|
|
@@ -695,10 +711,19 @@ async function appendUnconditional(table, events, partitionTag) {
|
|
|
695
711
|
return await runTransactWrite(input, basePosition, "DCB append failed");
|
|
696
712
|
}
|
|
697
713
|
|
|
714
|
+
let compositeFenceTagKey = "__dcb_composite__";
|
|
715
|
+
|
|
716
|
+
function makeCompositeFenceTag(tags, spec) {
|
|
717
|
+
return {
|
|
718
|
+
key: compositeFenceTagKey,
|
|
719
|
+
value: DcbTag$Reventless.getCompositePartitionKeyValue(tags, spec)
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
698
723
|
function eventPartitionTags(event, partitionTag) {
|
|
699
724
|
if (partitionTag !== undefined) {
|
|
700
725
|
if (partitionTag.TAG !== "Simple") {
|
|
701
|
-
return event.tags;
|
|
726
|
+
return [makeCompositeFenceTag(event.tags, partitionTag._0)];
|
|
702
727
|
}
|
|
703
728
|
let pt = partitionTag._0;
|
|
704
729
|
let t = event.tags.find(t => t.key === pt.key);
|
|
@@ -753,6 +778,26 @@ function partitionTypesByTag(events, partitionTag) {
|
|
|
753
778
|
|
|
754
779
|
function buildConditionalTransactItems(table, events, cond, basePosition, partitionTag, crossPartitionTagKeysOpt) {
|
|
755
780
|
let crossPartitionTagKeys = crossPartitionTagKeysOpt !== undefined ? crossPartitionTagKeysOpt : [];
|
|
781
|
+
let cond$1;
|
|
782
|
+
if (partitionTag !== undefined && partitionTag.TAG !== "Simple") {
|
|
783
|
+
let spec = partitionTag._0;
|
|
784
|
+
let newrecord = {...cond};
|
|
785
|
+
newrecord.query = cond.query.map(qi => {
|
|
786
|
+
let clauseTags = qi.tags;
|
|
787
|
+
if (clauseTags === undefined) {
|
|
788
|
+
return qi;
|
|
789
|
+
}
|
|
790
|
+
if (clauseTags.length <= 1) {
|
|
791
|
+
return qi;
|
|
792
|
+
}
|
|
793
|
+
let newrecord = {...qi};
|
|
794
|
+
newrecord.tags = [makeCompositeFenceTag(clauseTags, spec)];
|
|
795
|
+
return newrecord;
|
|
796
|
+
});
|
|
797
|
+
cond$1 = newrecord;
|
|
798
|
+
} else {
|
|
799
|
+
cond$1 = cond;
|
|
800
|
+
}
|
|
756
801
|
let partitionTags = collectEventPartitionTags(events, partitionTag);
|
|
757
802
|
let partitionKeySet = new Set();
|
|
758
803
|
partitionTags.forEach(t => {
|
|
@@ -770,7 +815,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
770
815
|
)[k], []);
|
|
771
816
|
};
|
|
772
817
|
let consumedMap = {};
|
|
773
|
-
cond.query.forEach(qi => {
|
|
818
|
+
cond$1.query.forEach(qi => {
|
|
774
819
|
let match = qi.tags;
|
|
775
820
|
let match$1 = qi.eventTypes;
|
|
776
821
|
if (match !== undefined && match$1 !== undefined) {
|
|
@@ -785,7 +830,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
785
830
|
let consumedTypesFor = t => Stdlib_Option.getOr(consumedMap[t.key + `:` + t.value], []);
|
|
786
831
|
let compositeKeySet = new Set();
|
|
787
832
|
let compositeQueryTags = [];
|
|
788
|
-
cond.query.forEach(qi => {
|
|
833
|
+
cond$1.query.forEach(qi => {
|
|
789
834
|
let tags = qi.tags;
|
|
790
835
|
if (tags !== undefined && tags.length > 1) {
|
|
791
836
|
tags.forEach(tag => {
|
|
@@ -812,7 +857,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
812
857
|
return;
|
|
813
858
|
}
|
|
814
859
|
};
|
|
815
|
-
cond.query.forEach(qi => {
|
|
860
|
+
cond$1.query.forEach(qi => {
|
|
816
861
|
let tags = qi.tags;
|
|
817
862
|
if (tags === undefined) {
|
|
818
863
|
return;
|
|
@@ -821,7 +866,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
821
866
|
if (tags.length <= 1) {
|
|
822
867
|
return;
|
|
823
868
|
}
|
|
824
|
-
let match = cond.after;
|
|
869
|
+
let match = cond$1.after;
|
|
825
870
|
if (match !== undefined) {
|
|
826
871
|
tags.forEach(pushUpdate);
|
|
827
872
|
return;
|
|
@@ -833,7 +878,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
833
878
|
if (crossPartitionTagKeys.includes(tag.key)) {
|
|
834
879
|
return pushUpdate(tag);
|
|
835
880
|
}
|
|
836
|
-
let match$1 = cond.after;
|
|
881
|
+
let match$1 = cond$1.after;
|
|
837
882
|
if (match$1 !== undefined) {
|
|
838
883
|
if (isPartition(tag) || isCompositeQueryTag(tag)) {
|
|
839
884
|
return pushUpdate(tag);
|
|
@@ -853,7 +898,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
853
898
|
return;
|
|
854
899
|
}
|
|
855
900
|
});
|
|
856
|
-
let match = cond.after;
|
|
901
|
+
let match = cond$1.after;
|
|
857
902
|
if (match !== undefined) {
|
|
858
903
|
|
|
859
904
|
} else {
|
|
@@ -864,7 +909,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
864
909
|
bumpSeen.add(t.key + `:` + t.value);
|
|
865
910
|
});
|
|
866
911
|
let bumpTags = [];
|
|
867
|
-
let match$1 = cond.after;
|
|
912
|
+
let match$1 = cond$1.after;
|
|
868
913
|
let candidateBumps = match$1 !== undefined ? partitionTags.concat(crossPartitionEventTags) : partitionTags.concat(compositeQueryTags.concat(crossPartitionEventTags));
|
|
869
914
|
candidateBumps.forEach(tag => {
|
|
870
915
|
let k = tag.key + `:` + tag.value;
|
|
@@ -881,7 +926,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
881
926
|
return;
|
|
882
927
|
} else {
|
|
883
928
|
return {
|
|
884
|
-
Update: buildConditionalFenceUpdate(table.name, tag, consumedTypesFor(tag), producedTypes, basePosition, cond.after)
|
|
929
|
+
Update: buildConditionalFenceUpdate(table.name, tag, consumedTypesFor(tag), producedTypes, basePosition, cond$1.after)
|
|
885
930
|
};
|
|
886
931
|
}
|
|
887
932
|
});
|
|
@@ -891,7 +936,7 @@ function buildConditionalTransactItems(table, events, cond, basePosition, partit
|
|
|
891
936
|
return;
|
|
892
937
|
} else {
|
|
893
938
|
return {
|
|
894
|
-
ConditionCheck: buildFenceConditionCheck(table.name, tag, consumedTypes, cond.after)
|
|
939
|
+
ConditionCheck: buildFenceConditionCheck(table.name, tag, consumedTypes, cond$1.after)
|
|
895
940
|
};
|
|
896
941
|
}
|
|
897
942
|
});
|
|
@@ -1102,6 +1147,8 @@ function readStream(table, $staropt$star) {
|
|
|
1102
1147
|
let transactWriteItemsLimit = 100;
|
|
1103
1148
|
|
|
1104
1149
|
export {
|
|
1150
|
+
lastMs,
|
|
1151
|
+
counter,
|
|
1105
1152
|
generatePosition,
|
|
1106
1153
|
generatePositionForBatch,
|
|
1107
1154
|
tagToAttributeName,
|
|
@@ -1140,6 +1187,8 @@ export {
|
|
|
1140
1187
|
buildEventPuts,
|
|
1141
1188
|
runTransactWrite,
|
|
1142
1189
|
appendUnconditional,
|
|
1190
|
+
compositeFenceTagKey,
|
|
1191
|
+
makeCompositeFenceTag,
|
|
1143
1192
|
eventPartitionTags,
|
|
1144
1193
|
collectEventPartitionTags,
|
|
1145
1194
|
partitionTypesByTag,
|
|
@@ -435,6 +435,97 @@ describe("Runtime.buildConditionalTransactItems — fence-scope = read-scope", (
|
|
|
435
435
|
})
|
|
436
436
|
})
|
|
437
437
|
|
|
438
|
+
describe("Runtime.buildConditionalTransactItems — composite partition fences on ONE composite key", () => {
|
|
439
|
+
// SyncResource-style: @compositePartitionTag over {environment, platformName,
|
|
440
|
+
// pluginName}. The read is one exact `tag_composite` match, so the fence must be
|
|
441
|
+
// a SINGLE composite-key fence — not one per member. Per-member fencing made the
|
|
442
|
+
// low-cardinality prefix (`environment`, `platformName`) hot under a deploy
|
|
443
|
+
// fan-out. Plan: docs/plans/Backlog/dcb-hot-tag-fence-contention.md.
|
|
444
|
+
let event = (eventType, tags): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
|
|
445
|
+
eventType,
|
|
446
|
+
data: JSON.Object(Dict.make()),
|
|
447
|
+
tags,
|
|
448
|
+
meta: testMeta(),
|
|
449
|
+
}
|
|
450
|
+
let findFence = (
|
|
451
|
+
items: array<AwsSdk.DynamoDb.DocumentClient.TransactWriteCommand.transactWriteItem>,
|
|
452
|
+
fenceId,
|
|
453
|
+
) =>
|
|
454
|
+
items->Array.find(it => {
|
|
455
|
+
let idOf = key => key->Dict.get("id") == Some(fenceId->JSON.Encode.string)
|
|
456
|
+
switch (it.update, it.conditionCheck) {
|
|
457
|
+
| (Some(u), _) => idOf(u.key)
|
|
458
|
+
| (_, Some(c)) => idOf(c.key)
|
|
459
|
+
| _ => false
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
let isUpdate = it =>
|
|
463
|
+
it->Option.flatMap(i => i.AwsSdk.DynamoDb.DocumentClient.TransactWriteCommand.update)->Option.isSome
|
|
464
|
+
let fenceIds = (items: array<AwsSdk.DynamoDb.DocumentClient.TransactWriteCommand.transactWriteItem>) =>
|
|
465
|
+
items
|
|
466
|
+
->Array.filterMap(it => {
|
|
467
|
+
let idOf = key => key->Dict.get("id")->Option.flatMap(JSON.Decode.string)
|
|
468
|
+
switch (it.update, it.conditionCheck) {
|
|
469
|
+
| (Some(u), _) => idOf(u.key)
|
|
470
|
+
| (_, Some(c)) => idOf(c.key)
|
|
471
|
+
| _ => None
|
|
472
|
+
}
|
|
473
|
+
})
|
|
474
|
+
->Array.filter(s => s->String.startsWith("fence#"))
|
|
475
|
+
|
|
476
|
+
let spec: Reventless.DcbTag.compositePartitionSpec = {
|
|
477
|
+
keys: ["environment", "platformName", "pluginName"],
|
|
478
|
+
seps: ["/", "/"],
|
|
479
|
+
}
|
|
480
|
+
let partitionTag = Some(Reventless.DcbTag.Composite(spec))
|
|
481
|
+
let members = [tag("environment", "prod"), tag("platformName", "plat"), tag("pluginName", "plug")]
|
|
482
|
+
let compositeFence = "fence#__dcb_composite__:prod/plat/plug"
|
|
483
|
+
|
|
484
|
+
describe("at after=Some (entity exists)", () => {
|
|
485
|
+
let resource = event("ResourceAdded", members)
|
|
486
|
+
let cond: Reventless.DcbTag.appendCondition = {
|
|
487
|
+
query: [{tags: members, eventTypes: ["ResourceAdded"]}],
|
|
488
|
+
after: "50",
|
|
489
|
+
}
|
|
490
|
+
let items = Runtime.buildConditionalTransactItems(table, [resource], cond, "100", ~partitionTag?)
|
|
491
|
+
|
|
492
|
+
testSync("the whole composite key is a single conditional Update", () => {
|
|
493
|
+
expect(isUpdate(findFence(items, compositeFence)))->toBe(true)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
testSync("no per-member fence is emitted (the hot-fence regression)", () => {
|
|
497
|
+
expect(findFence(items, "fence#environment:prod")->Option.isSome)->toBe(false)
|
|
498
|
+
expect(findFence(items, "fence#platformName:plat")->Option.isSome)->toBe(false)
|
|
499
|
+
expect(findFence(items, "fence#pluginName:plug")->Option.isSome)->toBe(false)
|
|
500
|
+
})
|
|
501
|
+
|
|
502
|
+
testSync("exactly one fence item total (the composite fence, no members)", () => {
|
|
503
|
+
expect(fenceIds(items))->toEqual([compositeFence])
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
describe("at after=None (folded create guard)", () => {
|
|
508
|
+
let resource = event("ResourceAdded", members)
|
|
509
|
+
let cond: Reventless.DcbTag.appendCondition = {
|
|
510
|
+
query: [{tags: members, eventTypes: ["ResourceAdded"]}],
|
|
511
|
+
}
|
|
512
|
+
let items = Runtime.buildConditionalTransactItems(table, [resource], cond, "100", ~partitionTag?)
|
|
513
|
+
|
|
514
|
+
testSync("the composite fence is a create-guard Update gated on attribute_not_exists", () => {
|
|
515
|
+
let u =
|
|
516
|
+
findFence(items, compositeFence)
|
|
517
|
+
->Option.flatMap(i => i.AwsSdk.DynamoDb.DocumentClient.TransactWriteCommand.update)
|
|
518
|
+
->Option.getOrThrow
|
|
519
|
+
expect(u.conditionExpression)->toEqual(Some("attribute_not_exists(#c0)"))
|
|
520
|
+
expect(u.expressionAttributeValues->Option.flatMap(v => v->Dict.get(":after")))->toEqual(None)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
testSync("still exactly one composite fence (no per-member create guards)", () => {
|
|
524
|
+
expect(fenceIds(items))->toEqual([compositeFence])
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
})
|
|
528
|
+
|
|
438
529
|
describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
|
|
439
530
|
let event = (eventType, tags): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
|
|
440
531
|
eventType,
|
|
@@ -604,3 +695,52 @@ describe("Runtime.indexKeepsFullProjection", () => {
|
|
|
604
695
|
expect(Runtime.indexKeepsFullProjection("tag_productId"))->toBe(false)
|
|
605
696
|
})
|
|
606
697
|
})
|
|
698
|
+
|
|
699
|
+
describe("Runtime.generatePosition — monotonic HLC positions", () => {
|
|
700
|
+
testSync("a rapid sequence of positions is strictly increasing (lexical == commit order)", () => {
|
|
701
|
+
// Most of these land in the same millisecond (counter increments); the loop
|
|
702
|
+
// also crosses ms ticks (counter resets, ms prefix dominates). Either way the
|
|
703
|
+
// sequence must be strictly increasing under byte-wise (DynamoDB) string order.
|
|
704
|
+
let positions = Array.fromInitializer(~length=50, _ => Runtime.generatePosition())
|
|
705
|
+
let strictlyIncreasing = ref(true)
|
|
706
|
+
for i in 1 to Array.length(positions) - 1 {
|
|
707
|
+
let prev = positions->Array.getUnsafe(i - 1)
|
|
708
|
+
let curr = positions->Array.getUnsafe(i)
|
|
709
|
+
if !(prev < curr) {
|
|
710
|
+
strictlyIncreasing := false
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
expect(strictlyIncreasing.contents)->toBe(true)
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
testSync("format carries a 6-digit counter segment between ms and uuid", () => {
|
|
717
|
+
// `<ms>-<6-digit counter>-<uuid>`. Splitting on '-' yields [ms, counter, …uuid].
|
|
718
|
+
let segments = Runtime.generatePosition()->String.split("-")
|
|
719
|
+
expect(segments->Array.length >= 3)->toBe(true)
|
|
720
|
+
let counterSeg = segments->Array.getUnsafe(1)
|
|
721
|
+
expect(counterSeg->String.length)->toBe(6)
|
|
722
|
+
})
|
|
723
|
+
|
|
724
|
+
testSync("generatePositionForBatch preserves order for same base, ascending index", () => {
|
|
725
|
+
let base = Runtime.generatePosition()
|
|
726
|
+
let p0 = Runtime.generatePositionForBatch(base, 0)
|
|
727
|
+
let p1 = Runtime.generatePositionForBatch(base, 1)
|
|
728
|
+
let p2 = Runtime.generatePositionForBatch(base, 2)
|
|
729
|
+
// index 0 is the base itself; later batch items sort strictly after it, in order.
|
|
730
|
+
expect(p0)->toBe(base)
|
|
731
|
+
expect(base < p1)->toBe(true)
|
|
732
|
+
expect(p1 < p2)->toBe(true)
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
testSync("old <ms>-<uuid> positions still sort by timestamp against new-format ones", () => {
|
|
736
|
+
// Backwards compatibility: the ms prefix keeps the same 13-digit width, so an
|
|
737
|
+
// older position with a smaller ms sorts before a newer-format one regardless of
|
|
738
|
+
// the counter segment. Sorting an unsorted mix must recover timestamp order.
|
|
739
|
+
let older = "1700000000000-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" // old <ms>-<uuid>
|
|
740
|
+
let newer = "1700000000001-000000-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" // new <ms>-<ctr>-<uuid>
|
|
741
|
+
let cmp = (a, b) => a < b ? -1.0 : a > b ? 1.0 : 0.0
|
|
742
|
+
let sorted = [newer, older]->Array.toSorted(cmp)
|
|
743
|
+
expect(sorted->Array.getUnsafe(0))->toBe(older)
|
|
744
|
+
expect(sorted->Array.getUnsafe(1))->toBe(newer)
|
|
745
|
+
})
|
|
746
|
+
})
|
|
@@ -544,6 +544,115 @@ globalThis.describe("Runtime.buildConditionalTransactItems — fence-scope = rea
|
|
|
544
544
|
});
|
|
545
545
|
});
|
|
546
546
|
|
|
547
|
+
globalThis.describe("Runtime.buildConditionalTransactItems — composite partition fences on ONE composite key", () => {
|
|
548
|
+
let event = (eventType, tags) => ({
|
|
549
|
+
eventType: eventType,
|
|
550
|
+
data: {},
|
|
551
|
+
tags: tags,
|
|
552
|
+
meta: testMeta()
|
|
553
|
+
});
|
|
554
|
+
let findFence = (items, fenceId) => items.find(it => {
|
|
555
|
+
let idOf = key => Primitive_object.equal(key["id"], fenceId);
|
|
556
|
+
let match = it.Update;
|
|
557
|
+
let match$1 = it.ConditionCheck;
|
|
558
|
+
if (match !== undefined) {
|
|
559
|
+
return idOf(match.Key);
|
|
560
|
+
} else if (match$1 !== undefined) {
|
|
561
|
+
return idOf(match$1.Key);
|
|
562
|
+
} else {
|
|
563
|
+
return false;
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
let isUpdate = it => Stdlib_Option.isSome(Stdlib_Option.flatMap(it, i => i.Update));
|
|
567
|
+
let fenceIds = items => Stdlib_Array.filterMap(items, it => {
|
|
568
|
+
let idOf = key => Stdlib_Option.flatMap(key["id"], Stdlib_JSON.Decode.string);
|
|
569
|
+
let match = it.Update;
|
|
570
|
+
let match$1 = it.ConditionCheck;
|
|
571
|
+
if (match !== undefined) {
|
|
572
|
+
return idOf(match.Key);
|
|
573
|
+
} else if (match$1 !== undefined) {
|
|
574
|
+
return idOf(match$1.Key);
|
|
575
|
+
} else {
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
}).filter(s => s.startsWith("fence#"));
|
|
579
|
+
let spec_keys = [
|
|
580
|
+
"environment",
|
|
581
|
+
"platformName",
|
|
582
|
+
"pluginName"
|
|
583
|
+
];
|
|
584
|
+
let spec_seps = [
|
|
585
|
+
"/",
|
|
586
|
+
"/"
|
|
587
|
+
];
|
|
588
|
+
let spec = {
|
|
589
|
+
keys: spec_keys,
|
|
590
|
+
seps: spec_seps
|
|
591
|
+
};
|
|
592
|
+
let partitionTag = {
|
|
593
|
+
TAG: "Composite",
|
|
594
|
+
_0: spec
|
|
595
|
+
};
|
|
596
|
+
let members = [
|
|
597
|
+
{
|
|
598
|
+
key: "environment",
|
|
599
|
+
value: "prod"
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
key: "platformName",
|
|
603
|
+
value: "plat"
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
key: "pluginName",
|
|
607
|
+
value: "plug"
|
|
608
|
+
}
|
|
609
|
+
];
|
|
610
|
+
let compositeFence = "fence#__dcb_composite__:prod/plat/plug";
|
|
611
|
+
globalThis.describe("at after=Some (entity exists)", () => {
|
|
612
|
+
let resource = event("ResourceAdded", members);
|
|
613
|
+
let cond_query = [{
|
|
614
|
+
eventTypes: ["ResourceAdded"],
|
|
615
|
+
tags: members
|
|
616
|
+
}];
|
|
617
|
+
let cond_after = "50";
|
|
618
|
+
let cond = {
|
|
619
|
+
query: cond_query,
|
|
620
|
+
after: cond_after
|
|
621
|
+
};
|
|
622
|
+
let items = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.buildConditionalTransactItems(table, [resource], cond, "100", partitionTag, undefined);
|
|
623
|
+
globalThis.test("the whole composite key is a single conditional Update", () => {
|
|
624
|
+
globalThis.expect(isUpdate(findFence(items, compositeFence))).toBe(true);
|
|
625
|
+
});
|
|
626
|
+
globalThis.test("no per-member fence is emitted (the hot-fence regression)", () => {
|
|
627
|
+
globalThis.expect(Stdlib_Option.isSome(findFence(items, "fence#environment:prod"))).toBe(false);
|
|
628
|
+
globalThis.expect(Stdlib_Option.isSome(findFence(items, "fence#platformName:plat"))).toBe(false);
|
|
629
|
+
globalThis.expect(Stdlib_Option.isSome(findFence(items, "fence#pluginName:plug"))).toBe(false);
|
|
630
|
+
});
|
|
631
|
+
globalThis.test("exactly one fence item total (the composite fence, no members)", () => {
|
|
632
|
+
globalThis.expect(fenceIds(items)).toEqual([compositeFence]);
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
globalThis.describe("at after=None (folded create guard)", () => {
|
|
636
|
+
let resource = event("ResourceAdded", members);
|
|
637
|
+
let cond_query = [{
|
|
638
|
+
eventTypes: ["ResourceAdded"],
|
|
639
|
+
tags: members
|
|
640
|
+
}];
|
|
641
|
+
let cond = {
|
|
642
|
+
query: cond_query
|
|
643
|
+
};
|
|
644
|
+
let items = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.buildConditionalTransactItems(table, [resource], cond, "100", partitionTag, undefined);
|
|
645
|
+
globalThis.test("the composite fence is a create-guard Update gated on attribute_not_exists", () => {
|
|
646
|
+
let u = Stdlib_Option.getOrThrow(Stdlib_Option.flatMap(findFence(items, compositeFence), i => i.Update), undefined);
|
|
647
|
+
globalThis.expect(u.ConditionExpression).toEqual("attribute_not_exists(#c0)");
|
|
648
|
+
globalThis.expect(Stdlib_Option.flatMap(u.ExpressionAttributeValues, v => v[":after"])).toEqual(undefined);
|
|
649
|
+
});
|
|
650
|
+
globalThis.test("still exactly one composite fence (no per-member create guards)", () => {
|
|
651
|
+
globalThis.expect(fenceIds(items)).toEqual([compositeFence]);
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
|
|
547
656
|
globalThis.describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
|
|
548
657
|
let event = (eventType, tags) => ({
|
|
549
658
|
eventType: eventType,
|
|
@@ -746,6 +855,55 @@ globalThis.describe("Runtime.indexKeepsFullProjection", () => {
|
|
|
746
855
|
});
|
|
747
856
|
});
|
|
748
857
|
|
|
858
|
+
globalThis.describe("Runtime.generatePosition — monotonic HLC positions", () => {
|
|
859
|
+
globalThis.test("a rapid sequence of positions is strictly increasing (lexical == commit order)", () => {
|
|
860
|
+
let positions = Stdlib_Array.fromInitializer(50, param => DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePosition());
|
|
861
|
+
let strictlyIncreasing = true;
|
|
862
|
+
for (let i = 1, i_finish = positions.length; i < i_finish; ++i) {
|
|
863
|
+
let prev = positions[i - 1 | 0];
|
|
864
|
+
let curr = positions[i];
|
|
865
|
+
if (prev >= curr) {
|
|
866
|
+
strictlyIncreasing = false;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
globalThis.expect(strictlyIncreasing).toBe(true);
|
|
870
|
+
});
|
|
871
|
+
globalThis.test("format carries a 6-digit counter segment between ms and uuid", () => {
|
|
872
|
+
let segments = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePosition().split("-");
|
|
873
|
+
globalThis.expect(segments.length >= 3).toBe(true);
|
|
874
|
+
let counterSeg = segments[1];
|
|
875
|
+
globalThis.expect(counterSeg.length).toBe(6);
|
|
876
|
+
});
|
|
877
|
+
globalThis.test("generatePositionForBatch preserves order for same base, ascending index", () => {
|
|
878
|
+
let base = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePosition();
|
|
879
|
+
let p0 = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePositionForBatch(base, 0);
|
|
880
|
+
let p1 = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePositionForBatch(base, 1);
|
|
881
|
+
let p2 = DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.generatePositionForBatch(base, 2);
|
|
882
|
+
globalThis.expect(p0).toBe(base);
|
|
883
|
+
globalThis.expect(base < p1).toBe(true);
|
|
884
|
+
globalThis.expect(p1 < p2).toBe(true);
|
|
885
|
+
});
|
|
886
|
+
globalThis.test("old <ms>-<uuid> positions still sort by timestamp against new-format ones", () => {
|
|
887
|
+
let older = "1700000000000-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
888
|
+
let newer = "1700000000001-000000-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
|
|
889
|
+
let cmp = (a, b) => {
|
|
890
|
+
if (Primitive_object.lessthan(a, b)) {
|
|
891
|
+
return -1.0;
|
|
892
|
+
} else if (Primitive_object.greaterthan(a, b)) {
|
|
893
|
+
return 1.0;
|
|
894
|
+
} else {
|
|
895
|
+
return 0.0;
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
let sorted = [
|
|
899
|
+
newer,
|
|
900
|
+
older
|
|
901
|
+
].toSorted(cmp);
|
|
902
|
+
globalThis.expect(sorted[0]).toBe(older);
|
|
903
|
+
globalThis.expect(sorted[1]).toBe(newer);
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
|
|
749
907
|
let Runtime;
|
|
750
908
|
|
|
751
909
|
export {
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// docs/plans/dcb-fence-event-type-granularity.md. Not run by the default unit suite
|
|
1
|
+
// Fence model: PER-TYPE `pos#<eventType>` attributes with the create guard folded
|
|
2
|
+
// into the fence Update (dcb-fence-event-type-granularity). Conflict-expecting
|
|
3
|
+
// scenarios name the consumed event type on each query clause (a tag-only clause
|
|
4
|
+
// produces no fence check) and advance the fence via `H.setFence(~eventTypes,
|
|
5
|
+
// ~position=…)`, which writes the matching `pos#<eventType>` attribute — the same
|
|
6
|
+
// attribute a real conditional append checks. Not run by the default unit suite
|
|
8
7
|
// (CI `pnpm test`), so it does not gate the build.
|
|
9
8
|
//
|
|
10
9
|
// DCB DynamoDB integration suite — exercises the real fence path against a
|
|
@@ -105,17 +104,23 @@ describe("DCB DynamoDb integration — fence-scope = read-scope (Issue 1 regress
|
|
|
105
104
|
let table = await H.freshTable()
|
|
106
105
|
let _ = await seed(table, event("CatalogProductSynced", [productTag]), ~partitionTag=simple("productId"))
|
|
107
106
|
|
|
108
|
-
// Order reads its decision model (after = the sync's position)
|
|
109
|
-
|
|
107
|
+
// Order reads its decision model (after = the sync's position). Each clause
|
|
108
|
+
// names the event type it reads — the per-type fence model checks the
|
|
109
|
+
// consumed type's `pos#<type>`, so a tag-only clause would produce no fence
|
|
110
|
+
// check at all.
|
|
111
|
+
let query: Reventless.DcbTag.query = [
|
|
112
|
+
{tags: [tag("orderId", "O1")], eventTypes: ["OrderPlaced"]},
|
|
113
|
+
{tags: [productTag], eventTypes: ["CatalogProductSynced"]},
|
|
114
|
+
]
|
|
110
115
|
let after = (await readAfter(table, query))->Option.getOr("")
|
|
111
116
|
expect(after == "")->toBe(false)
|
|
112
117
|
|
|
113
|
-
// …a concurrent re-sync advances fence#productId:P5
|
|
114
|
-
// fence directly (rather than racing a second
|
|
115
|
-
// advance is deterministically > after —
|
|
116
|
-
// otherwise tie on UUID order (analysis Issue 7).
|
|
117
|
-
// lexically greater than any real position.
|
|
118
|
-
let _ = await H.setFence(table, productTag, ~
|
|
118
|
+
// …a concurrent re-sync advances fence#productId:P5's pos#CatalogProductSynced
|
|
119
|
+
// past `after`. We set the fence directly (rather than racing a second
|
|
120
|
+
// appendUnconditional) so the advance is deterministically > after —
|
|
121
|
+
// same-millisecond writers can otherwise tie on UUID order (analysis Issue 7).
|
|
122
|
+
// Appending "z" stays lexically greater than any real position.
|
|
123
|
+
let _ = await H.setFence(table, productTag, ~eventTypes=["CatalogProductSynced"], ~position=after ++ "z")
|
|
119
124
|
|
|
120
125
|
// …so the now-stale order append must conflict on the productId fence.
|
|
121
126
|
let r = await Runtime.appendConditional(
|
|
@@ -280,9 +285,11 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
|
|
|
280
285
|
|
|
281
286
|
testAsync("a failed fence condition aborts the whole transaction (multi-tag atomicity)", async () => {
|
|
282
287
|
let table = await H.freshTable()
|
|
283
|
-
// Composite slice: one multi-tag clause → both tags get check+bump.
|
|
288
|
+
// Composite slice: one multi-tag clause → both tags get check+bump. The
|
|
289
|
+
// clause names the consumed type so the per-type fence check has a `pos#<type>`
|
|
290
|
+
// to assert against.
|
|
284
291
|
let tags = [tag("productId", "p1"), tag("orderId", "o1")]
|
|
285
|
-
let query: Reventless.DcbTag.query = [{tags: tags}]
|
|
292
|
+
let query: Reventless.DcbTag.query = [{tags: tags, eventTypes: ["ProductDemandRecorded"]}]
|
|
286
293
|
|
|
287
294
|
// Seed one event so the next append rides after=Some.
|
|
288
295
|
let seedRes = await Runtime.appendConditional(
|
|
@@ -296,9 +303,14 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
|
|
|
296
303
|
let after = (await readAfter(table, query))->Option.getOr("")
|
|
297
304
|
expect(after == "")->toBe(false)
|
|
298
305
|
|
|
299
|
-
// A concurrent writer advances ONLY the productId fence
|
|
300
|
-
// Appending "z" keeps the position lexically greater than any real
|
|
301
|
-
let _ = await H.setFence(
|
|
306
|
+
// A concurrent writer advances ONLY the productId fence's pos#ProductDemandRecorded
|
|
307
|
+
// past `after`. Appending "z" keeps the position lexically greater than any real one.
|
|
308
|
+
let _ = await H.setFence(
|
|
309
|
+
table,
|
|
310
|
+
tag("productId", "p1"),
|
|
311
|
+
~eventTypes=["ProductDemandRecorded"],
|
|
312
|
+
~position=after ++ "z",
|
|
313
|
+
)
|
|
302
314
|
|
|
303
315
|
let r = await Runtime.appendConditional(
|
|
304
316
|
table,
|
|
@@ -314,3 +326,135 @@ describe("DCB DynamoDb integration — optimistic concurrency primitives", () =>
|
|
|
314
326
|
expect(readResult.events->Array.length)->toBe(1)
|
|
315
327
|
})
|
|
316
328
|
})
|
|
329
|
+
|
|
330
|
+
// A `@compositePartitionTag` slice (platform-inspector's SyncResource shape) fences
|
|
331
|
+
// on the WHOLE composite key, not on each member. Distinct entities sharing a
|
|
332
|
+
// low-cardinality prefix (environment/platformName/pluginName) must therefore NOT
|
|
333
|
+
// serialize on that prefix — the deploy-fan-out hot-fence regression. Plan:
|
|
334
|
+
// docs/plans/Backlog/dcb-hot-tag-fence-contention.md § "Root-cause correction".
|
|
335
|
+
describe("DCB DynamoDb integration — composite partition hot-fence fix", () => {
|
|
336
|
+
let composite = (keys, seps): Reventless.DcbTag.derivedPartitionTag => Composite({keys, seps})
|
|
337
|
+
let specKeys = ["environment", "platformName", "pluginName", "resourceName"]
|
|
338
|
+
let specSeps = ["/", "/", "/"]
|
|
339
|
+
let pt = composite(specKeys, specSeps)
|
|
340
|
+
let members = resourceName => [
|
|
341
|
+
tag("environment", "prod"),
|
|
342
|
+
tag("platformName", "plat"),
|
|
343
|
+
tag("pluginName", "plug"),
|
|
344
|
+
tag("resourceName", resourceName),
|
|
345
|
+
]
|
|
346
|
+
|
|
347
|
+
testAsync("distinct composite entities sharing a prefix do NOT false-conflict", async () => {
|
|
348
|
+
let table = await H.freshTable()
|
|
349
|
+
let tagsA = members("resA")
|
|
350
|
+
let tagsB = members("resB")
|
|
351
|
+
// Both are first-writers (after=None). They share environment/platformName/
|
|
352
|
+
// pluginName but are DIFFERENT composite entities → different composite fences.
|
|
353
|
+
let rA = await Runtime.appendConditional(
|
|
354
|
+
table,
|
|
355
|
+
[event("ResourceAdded", tagsA)],
|
|
356
|
+
{query: [{tags: tagsA}], after: ?None},
|
|
357
|
+
~partitionTag=pt,
|
|
358
|
+
)
|
|
359
|
+
let rB = await Runtime.appendConditional(
|
|
360
|
+
table,
|
|
361
|
+
[event("ResourceAdded", tagsB)],
|
|
362
|
+
{query: [{tags: tagsB}], after: ?None},
|
|
363
|
+
~partitionTag=pt,
|
|
364
|
+
)
|
|
365
|
+
// Pre-fix: rB conflicts on the shared `fence#environment:prod` create guard.
|
|
366
|
+
expect(isOk(rA))->toBe(true)
|
|
367
|
+
expect(isOk(rB))->toBe(true)
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
testAsync("two first-writers of the SAME composite key still serialize (OCC preserved)", async () => {
|
|
371
|
+
let table = await H.freshTable()
|
|
372
|
+
let tags = members("resA")
|
|
373
|
+
let query: Reventless.DcbTag.query = [{tags: tags}]
|
|
374
|
+
let r1 = await Runtime.appendConditional(
|
|
375
|
+
table,
|
|
376
|
+
[event("ResourceAdded", tags)],
|
|
377
|
+
{query, after: ?None},
|
|
378
|
+
~partitionTag=pt,
|
|
379
|
+
)
|
|
380
|
+
let r2 = await Runtime.appendConditional(
|
|
381
|
+
table,
|
|
382
|
+
[event("ResourceAdded", tags)],
|
|
383
|
+
{query, after: ?None},
|
|
384
|
+
~partitionTag=pt,
|
|
385
|
+
)
|
|
386
|
+
expect(isOk(r1))->toBe(true)
|
|
387
|
+
// The folded create guard (attribute_not_exists on the composite fence) rejects
|
|
388
|
+
// the second first-write of the same entity.
|
|
389
|
+
expect(isConflict(r2))->toBe(true)
|
|
390
|
+
})
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
// The fence is PER event type (`pos#<eventType>` attributes), not a single scalar
|
|
394
|
+
// `lastPosition` per partition. So two slices consuming DIFFERENT types on the same
|
|
395
|
+
// entity (e.g. price vs name changes on one productId) never contend, and an entity
|
|
396
|
+
// never wedges into a permanent Conflict after one attribute changes. This is the
|
|
397
|
+
// live proof of the per-type granularity fix (dcb-fence-event-type-granularity,
|
|
398
|
+
// `a20646f31`); the unit suite only asserts the built transaction's shape.
|
|
399
|
+
describe("DCB DynamoDb integration — per-type fence granularity", () => {
|
|
400
|
+
let productTag = tag("productId", "P")
|
|
401
|
+
|
|
402
|
+
// Change one attribute of P: read only its own event type on the partition, then
|
|
403
|
+
// append that type. Each type carries its own `pos#<type>` fence, so distinct
|
|
404
|
+
// attributes advance independently.
|
|
405
|
+
let change = async (table, ~eventType) => {
|
|
406
|
+
let query: Reventless.DcbTag.query = [{tags: [productTag], eventTypes: [eventType]}]
|
|
407
|
+
let after = await readAfter(table, query)
|
|
408
|
+
await Runtime.appendConditional(
|
|
409
|
+
table,
|
|
410
|
+
[event(eventType, [productTag])],
|
|
411
|
+
{query, after: ?after},
|
|
412
|
+
~partitionTag=simple("productId"),
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
testAsync("interleaved distinct-type changes on one product all succeed (never wedges)", async () => {
|
|
417
|
+
let table = await H.freshTable()
|
|
418
|
+
let _ = await seed(table, event("ProductAdded", [productTag]), ~partitionTag=simple("productId"))
|
|
419
|
+
|
|
420
|
+
// Two rounds of interleaved price/name/description changes. Pre-fix, the first
|
|
421
|
+
// PriceChanged bumped the single partition fence, so the following NameChanged
|
|
422
|
+
// read a stale head and conflicted PERMANENTLY — the entity wedged after one edit.
|
|
423
|
+
let results = [
|
|
424
|
+
await change(table, ~eventType="PriceChanged"),
|
|
425
|
+
await change(table, ~eventType="NameChanged"),
|
|
426
|
+
await change(table, ~eventType="DescriptionChanged"),
|
|
427
|
+
await change(table, ~eventType="PriceChanged"),
|
|
428
|
+
await change(table, ~eventType="NameChanged"),
|
|
429
|
+
]
|
|
430
|
+
expect(results->Array.every(isOk))->toBe(true)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
testAsync("two concurrent SAME-type changes still serialize (per-type OCC preserved)", async () => {
|
|
434
|
+
let table = await H.freshTable()
|
|
435
|
+
let _ = await seed(table, event("ProductAdded", [productTag]), ~partitionTag=simple("productId"))
|
|
436
|
+
// Seed one NameChanged so both racers read the same after=Some head.
|
|
437
|
+
let _ = await change(table, ~eventType="NameChanged")
|
|
438
|
+
|
|
439
|
+
let query: Reventless.DcbTag.query = [{tags: [productTag], eventTypes: ["NameChanged"]}]
|
|
440
|
+
let after = await readAfter(table, query)
|
|
441
|
+
let rename = () =>
|
|
442
|
+
Runtime.appendConditional(
|
|
443
|
+
table,
|
|
444
|
+
[event("NameChanged", [productTag])],
|
|
445
|
+
{query, after: ?after},
|
|
446
|
+
~partitionTag=simple("productId"),
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
// Per-type granularity must NOT weaken same-type OCC: two concurrent NameChanged
|
|
450
|
+
// at the same `pos#NameChanged` head → at most one commits, every non-winner
|
|
451
|
+
// conflicts. (DynamoDB may cancel both with a mutual TransactionConflict; the
|
|
452
|
+
// slice callback's retry loop makes exactly one win in prod.)
|
|
453
|
+
let results = await Promise.all([rename(), rename()])
|
|
454
|
+
let oks = results->Array.filter(isOk)->Array.length
|
|
455
|
+
let conflicts = results->Array.filter(isConflict)->Array.length
|
|
456
|
+
expect(oks <= 1)->toBe(true)
|
|
457
|
+
expect(oks + conflicts)->toBe(2)
|
|
458
|
+
expect(conflicts >= 1)->toBe(true)
|
|
459
|
+
})
|
|
460
|
+
})
|
|
@@ -129,18 +129,20 @@ globalThis.describe("DCB DynamoDb integration — fence-scope = read-scope (Issu
|
|
|
129
129
|
});
|
|
130
130
|
let query = [
|
|
131
131
|
{
|
|
132
|
+
eventTypes: ["OrderPlaced"],
|
|
132
133
|
tags: [{
|
|
133
134
|
key: "orderId",
|
|
134
135
|
value: "O1"
|
|
135
136
|
}]
|
|
136
137
|
},
|
|
137
138
|
{
|
|
139
|
+
eventTypes: ["CatalogProductSynced"],
|
|
138
140
|
tags: [productTag]
|
|
139
141
|
}
|
|
140
142
|
];
|
|
141
143
|
let after = Stdlib_Option.getOr(await readAfter(table, query), "");
|
|
142
144
|
globalThis.expect(after === "").toBe(false);
|
|
143
|
-
await DcbIntegrationHarness$ReventlessAws.setFence(table, productTag, after + "z");
|
|
145
|
+
await DcbIntegrationHarness$ReventlessAws.setFence(table, productTag, ["CatalogProductSynced"], after + "z");
|
|
144
146
|
let r = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("OrderPlaced", [
|
|
145
147
|
{
|
|
146
148
|
key: "orderId",
|
|
@@ -384,6 +386,7 @@ globalThis.describe("DCB DynamoDb integration — optimistic concurrency primiti
|
|
|
384
386
|
}
|
|
385
387
|
];
|
|
386
388
|
let query = [{
|
|
389
|
+
eventTypes: ["ProductDemandRecorded"],
|
|
387
390
|
tags: tags
|
|
388
391
|
}];
|
|
389
392
|
let seedRes = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ProductDemandRecorded", tags)], {
|
|
@@ -400,7 +403,7 @@ globalThis.describe("DCB DynamoDb integration — optimistic concurrency primiti
|
|
|
400
403
|
await DcbIntegrationHarness$ReventlessAws.setFence(table, {
|
|
401
404
|
key: "productId",
|
|
402
405
|
value: "p1"
|
|
403
|
-
}, after + "z");
|
|
406
|
+
}, ["ProductDemandRecorded"], after + "z");
|
|
404
407
|
let r = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ProductDemandRecorded", tags)], {
|
|
405
408
|
query: query,
|
|
406
409
|
after: after
|
|
@@ -416,6 +419,150 @@ globalThis.describe("DCB DynamoDb integration — optimistic concurrency primiti
|
|
|
416
419
|
});
|
|
417
420
|
});
|
|
418
421
|
|
|
422
|
+
globalThis.describe("DCB DynamoDb integration — composite partition hot-fence fix", () => {
|
|
423
|
+
let specKeys = [
|
|
424
|
+
"environment",
|
|
425
|
+
"platformName",
|
|
426
|
+
"pluginName",
|
|
427
|
+
"resourceName"
|
|
428
|
+
];
|
|
429
|
+
let specSeps = [
|
|
430
|
+
"/",
|
|
431
|
+
"/",
|
|
432
|
+
"/"
|
|
433
|
+
];
|
|
434
|
+
let pt = {
|
|
435
|
+
TAG: "Composite",
|
|
436
|
+
_0: {
|
|
437
|
+
keys: specKeys,
|
|
438
|
+
seps: specSeps
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
let members = resourceName => [
|
|
442
|
+
{
|
|
443
|
+
key: "environment",
|
|
444
|
+
value: "prod"
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
key: "platformName",
|
|
448
|
+
value: "plat"
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
key: "pluginName",
|
|
452
|
+
value: "plug"
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
key: "resourceName",
|
|
456
|
+
value: resourceName
|
|
457
|
+
}
|
|
458
|
+
];
|
|
459
|
+
globalThis.test("distinct composite entities sharing a prefix do NOT false-conflict", async () => {
|
|
460
|
+
let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
|
|
461
|
+
let tagsA = members("resA");
|
|
462
|
+
let tagsB = members("resB");
|
|
463
|
+
let rA = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ResourceAdded", tagsA)], {
|
|
464
|
+
query: [{
|
|
465
|
+
tags: tagsA
|
|
466
|
+
}]
|
|
467
|
+
}, pt, undefined);
|
|
468
|
+
let rB = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ResourceAdded", tagsB)], {
|
|
469
|
+
query: [{
|
|
470
|
+
tags: tagsB
|
|
471
|
+
}]
|
|
472
|
+
}, pt, undefined);
|
|
473
|
+
globalThis.expect(isOk(rA)).toBe(true);
|
|
474
|
+
globalThis.expect(isOk(rB)).toBe(true);
|
|
475
|
+
});
|
|
476
|
+
globalThis.test("two first-writers of the SAME composite key still serialize (OCC preserved)", async () => {
|
|
477
|
+
let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
|
|
478
|
+
let tags = members("resA");
|
|
479
|
+
let query = [{
|
|
480
|
+
tags: tags
|
|
481
|
+
}];
|
|
482
|
+
let r1 = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ResourceAdded", tags)], {
|
|
483
|
+
query: query
|
|
484
|
+
}, pt, undefined);
|
|
485
|
+
let r2 = await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("ResourceAdded", tags)], {
|
|
486
|
+
query: query
|
|
487
|
+
}, pt, undefined);
|
|
488
|
+
globalThis.expect(isOk(r1)).toBe(true);
|
|
489
|
+
globalThis.expect(isConflict(r2)).toBe(true);
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
globalThis.describe("DCB DynamoDb integration — per-type fence granularity", () => {
|
|
494
|
+
let productTag = {
|
|
495
|
+
key: "productId",
|
|
496
|
+
value: "P"
|
|
497
|
+
};
|
|
498
|
+
let change = async (table, eventType) => {
|
|
499
|
+
let query = [{
|
|
500
|
+
eventTypes: [eventType],
|
|
501
|
+
tags: [productTag]
|
|
502
|
+
}];
|
|
503
|
+
let after = await readAfter(table, query);
|
|
504
|
+
return await DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event(eventType, [productTag])], {
|
|
505
|
+
query: query,
|
|
506
|
+
after: after
|
|
507
|
+
}, {
|
|
508
|
+
TAG: "Simple",
|
|
509
|
+
_0: {
|
|
510
|
+
key: "productId"
|
|
511
|
+
}
|
|
512
|
+
}, undefined);
|
|
513
|
+
};
|
|
514
|
+
globalThis.test("interleaved distinct-type changes on one product all succeed (never wedges)", async () => {
|
|
515
|
+
let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
|
|
516
|
+
await seed(table, event("ProductAdded", [productTag]), {
|
|
517
|
+
TAG: "Simple",
|
|
518
|
+
_0: {
|
|
519
|
+
key: "productId"
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
let results = [
|
|
523
|
+
await change(table, "PriceChanged"),
|
|
524
|
+
await change(table, "NameChanged"),
|
|
525
|
+
await change(table, "DescriptionChanged"),
|
|
526
|
+
await change(table, "PriceChanged"),
|
|
527
|
+
await change(table, "NameChanged")
|
|
528
|
+
];
|
|
529
|
+
globalThis.expect(results.every(isOk)).toBe(true);
|
|
530
|
+
});
|
|
531
|
+
globalThis.test("two concurrent SAME-type changes still serialize (per-type OCC preserved)", async () => {
|
|
532
|
+
let table = await DcbIntegrationHarness$ReventlessAws.freshTable();
|
|
533
|
+
await seed(table, event("ProductAdded", [productTag]), {
|
|
534
|
+
TAG: "Simple",
|
|
535
|
+
_0: {
|
|
536
|
+
key: "productId"
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
await change(table, "NameChanged");
|
|
540
|
+
let query = [{
|
|
541
|
+
eventTypes: ["NameChanged"],
|
|
542
|
+
tags: [productTag]
|
|
543
|
+
}];
|
|
544
|
+
let after = await readAfter(table, query);
|
|
545
|
+
let rename = () => DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.appendConditional(table, [event("NameChanged", [productTag])], {
|
|
546
|
+
query: query,
|
|
547
|
+
after: after
|
|
548
|
+
}, {
|
|
549
|
+
TAG: "Simple",
|
|
550
|
+
_0: {
|
|
551
|
+
key: "productId"
|
|
552
|
+
}
|
|
553
|
+
}, undefined);
|
|
554
|
+
let results = await Promise.all([
|
|
555
|
+
rename(),
|
|
556
|
+
rename()
|
|
557
|
+
]);
|
|
558
|
+
let oks = results.filter(isOk).length;
|
|
559
|
+
let conflicts = results.filter(isConflict).length;
|
|
560
|
+
globalThis.expect(oks <= 1).toBe(true);
|
|
561
|
+
globalThis.expect(oks + conflicts | 0).toBe(2);
|
|
562
|
+
globalThis.expect(conflicts >= 1).toBe(true);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
419
566
|
let H;
|
|
420
567
|
|
|
421
568
|
let Runtime;
|
|
@@ -85,18 +85,29 @@ let freshTable = async () => {
|
|
|
85
85
|
await createDcbTable(`DcbItTest_${counter.contents->Int.toString}`)
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
// Directly
|
|
89
|
-
//
|
|
88
|
+
// Directly advance a fence sentinel — used to simulate a concurrent writer having
|
|
89
|
+
// bumped a fence between a slice's read and append. The fence model is per event
|
|
90
|
+
// type (`pos#<eventType>` attributes, not a scalar `lastPosition`), so the caller
|
|
91
|
+
// names the event type(s) whose position to set; each `pos#<eventType>` is written
|
|
92
|
+
// to `position`, exactly as a real conditional append would advance them.
|
|
90
93
|
let setFence = async (
|
|
91
94
|
table: Util_DynamoDb_Runtime.resolvedTable,
|
|
92
95
|
tag: Reventless.DcbTag.tag,
|
|
93
|
-
~
|
|
96
|
+
~eventTypes: array<string>,
|
|
97
|
+
~position: string,
|
|
94
98
|
) => {
|
|
95
99
|
let item =
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
100
|
+
Array.concat(
|
|
101
|
+
[
|
|
102
|
+
("id", s(DcbEventLogStorage_DynamoDb_Runtime.fencePartitionKey(tag))),
|
|
103
|
+
("position", s("FENCE")),
|
|
104
|
+
],
|
|
105
|
+
eventTypes->Array.map(et => (
|
|
106
|
+
DcbEventLogStorage_DynamoDb_Runtime.fenceTypeAttr(et),
|
|
107
|
+
s(position),
|
|
108
|
+
)),
|
|
109
|
+
)
|
|
110
|
+
->Dict.fromArray
|
|
111
|
+
->JSON.Encode.object
|
|
101
112
|
await Util_DynamoDb_Runtime.put(table, item)
|
|
102
113
|
}
|
|
@@ -129,7 +129,7 @@ async function freshTable() {
|
|
|
129
129
|
return await createDcbTable(`DcbItTest_` + counter.contents.toString());
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
async function setFence(table, tag,
|
|
132
|
+
async function setFence(table, tag, eventTypes, position) {
|
|
133
133
|
let item = Object.fromEntries([
|
|
134
134
|
[
|
|
135
135
|
"id",
|
|
@@ -138,12 +138,11 @@ async function setFence(table, tag, lastPosition) {
|
|
|
138
138
|
[
|
|
139
139
|
"position",
|
|
140
140
|
"FENCE"
|
|
141
|
-
],
|
|
142
|
-
[
|
|
143
|
-
"lastPosition",
|
|
144
|
-
lastPosition
|
|
145
141
|
]
|
|
146
|
-
]
|
|
142
|
+
].concat(eventTypes.map(et => [
|
|
143
|
+
DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.fenceTypeAttr(et),
|
|
144
|
+
position
|
|
145
|
+
])));
|
|
147
146
|
return await Util_DynamoDb_Runtime$ReventlessAws.put(table, item);
|
|
148
147
|
}
|
|
149
148
|
|