@reventlessdev/reventless-aws 3.0.0-alpha.181 → 3.0.0-alpha.183
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/Platform.res +71 -34
- package/src/Platform.res.mjs +58 -70
- 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
|
@@ -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
|
+
})
|