@peerbit/shared-log 13.2.10 → 13.2.12

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/src/ranges.ts CHANGED
@@ -141,7 +141,10 @@ export class EntryReplicatedU32 implements EntryReplicated<"u32"> {
141
141
  if (!properties.meta && !properties.metaBytes) {
142
142
  throw new Error("Expected meta or metaBytes");
143
143
  }
144
- if (!properties.meta && (properties.gid == null || properties.wallTime == null)) {
144
+ if (
145
+ !properties.meta &&
146
+ (properties.gid == null || properties.wallTime == null)
147
+ ) {
145
148
  throw new Error("Expected gid and wallTime with metaBytes");
146
149
  }
147
150
  this.coordinates = properties.coordinates;
@@ -211,7 +214,10 @@ export class EntryReplicatedU64 implements EntryReplicated<"u64"> {
211
214
  if (!properties.meta && !properties.metaBytes) {
212
215
  throw new Error("Expected meta or metaBytes");
213
216
  }
214
- if (!properties.meta && (properties.gid == null || properties.wallTime == null)) {
217
+ if (
218
+ !properties.meta &&
219
+ (properties.gid == null || properties.wallTime == null)
220
+ ) {
215
221
  throw new Error("Expected gid and wallTime with metaBytes");
216
222
  }
217
223
  this.coordinates = properties.coordinates;
@@ -841,6 +847,7 @@ export class ReplicationRangeIndexableU32
841
847
  equalRange(other: ReplicationRangeIndexableU32) {
842
848
  return (
843
849
  this.hash === other.hash &&
850
+ this.mode === other.mode &&
844
851
  this.start1 === other.start1 &&
845
852
  this.end1 === other.end1 &&
846
853
  this.start2 === other.start2 &&
@@ -1020,6 +1027,7 @@ export class ReplicationRangeIndexableU64
1020
1027
  equalRange(other: ReplicationRangeIndexableU64) {
1021
1028
  return (
1022
1029
  this.hash === other.hash &&
1030
+ this.mode === other.mode &&
1023
1031
  this.start1 === other.start1 &&
1024
1032
  this.end1 === other.end1 &&
1025
1033
  this.start2 === other.start2 &&
@@ -2538,33 +2546,49 @@ export const createAssignedRangesQuery = (
2538
2546
  end2: number | bigint;
2539
2547
  mode: ReplicationIntent;
2540
2548
  };
2549
+ /** Internal query-only marker. This wrapper field is never serialized. */
2550
+ rebalanceBoundaryOnly?: boolean;
2541
2551
  }[],
2542
- options?: { strict?: boolean },
2552
+ options?: { strict?: boolean; includeBoundaryWhenEmpty?: boolean },
2543
2553
  ) => {
2544
2554
  let ors: Query[] = [];
2545
- let onlyStrict = true;
2555
+ let includeBoundary =
2556
+ changes.length === 0 && options?.includeBoundaryWhenEmpty !== false;
2546
2557
  // TODO what if the ranges are many many?
2547
2558
  for (const change of changes) {
2559
+ if (change.rebalanceBoundaryOnly) {
2560
+ includeBoundary = true;
2561
+ continue;
2562
+ }
2548
2563
  const matchRange = matchEntriesInRangeQuery(change.range);
2549
2564
  ors.push(matchRange);
2550
2565
  if (change.range.mode === ReplicationIntent.NonStrict) {
2551
- onlyStrict = false;
2566
+ includeBoundary = true;
2552
2567
  }
2553
2568
  }
2554
2569
 
2555
2570
  // entry is assigned to a range boundary, meaning it is due to be inspected
2556
- if (!options?.strict) {
2557
- if (!onlyStrict || changes.length === 0) {
2558
- ors.push(
2559
- new BoolQuery({
2560
- key: "assignedToRangeBoundary",
2561
- value: true,
2562
- }),
2563
- );
2564
- }
2571
+ if (!options?.strict && includeBoundary) {
2572
+ ors.push(
2573
+ new BoolQuery({
2574
+ key: "assignedToRangeBoundary",
2575
+ value: true,
2576
+ }),
2577
+ );
2565
2578
  }
2566
2579
 
2567
2580
  // entry is not sufficiently replicated, and we are to still keep it
2581
+ if (ors.length === 0) {
2582
+ // The index query language rejects Or([]). Use an explicit contradiction
2583
+ // for a non-empty source batch that mergeReplicationChanges proved is a no-op.
2584
+ // Keep the exported helper's historical Or return shape for source consumers.
2585
+ return new Or([
2586
+ new And([
2587
+ new BoolQuery({ key: "assignedToRangeBoundary", value: true }),
2588
+ new BoolQuery({ key: "assignedToRangeBoundary", value: false }),
2589
+ ]),
2590
+ ]);
2591
+ }
2568
2592
  return new Or(ors);
2569
2593
  };
2570
2594
 
@@ -2587,7 +2611,15 @@ export type ReplicationChange<
2587
2611
  type: "replaced";
2588
2612
  range: T;
2589
2613
  }
2590
- ) & { timestamp: bigint };
2614
+ ) & {
2615
+ timestamp: bigint;
2616
+ /**
2617
+ * Internal, non-wire query intent emitted by mergeReplicationChanges.
2618
+ * When set, createAssignedRangesQuery performs only the global boundary
2619
+ * re-evaluation and deliberately ignores this wrapper's range geometry.
2620
+ */
2621
+ rebalanceBoundaryOnly?: boolean;
2622
+ };
2591
2623
 
2592
2624
  export const debounceAggregationChanges = <
2593
2625
  T extends ReplicationRangeIndexable<any>,
@@ -2610,17 +2642,27 @@ export const debounceAggregationChanges = <
2610
2642
  // updates produce a `replaced` + `added` pair; collapsing by id would drop the
2611
2643
  // "removed" portion and prevent correct rebalancing/pruning.
2612
2644
  //
2613
- // Preserve each distinct replaced range as well. Adaptive replication can
2645
+ // Preserve each distinct retired range as well. Adaptive replication can
2614
2646
  // shrink a segment several times before the debounced rebalance runs; if we
2615
2647
  // keep only the newest `replaced` range, entries from earlier wider ranges are
2616
- // never revisited and can stay resident after they become prunable.
2648
+ // never revisited and can stay resident after they become prunable. Full reset
2649
+ // announcements have the same requirement: a queued A -> B -> C chain emits
2650
+ // removed(A), added(B), removed(B), added(C), so collapsing removals by id
2651
+ // would omit A-only entries.
2652
+ // Segment ids are peer-supplied and only identify state within an owner, so
2653
+ // live additions must include the owner hash to avoid cross-peer collisions.
2617
2654
  const key =
2618
- change.type === "replaced"
2655
+ change.type === "replaced" || change.type === "removed"
2619
2656
  ? `${change.type}:${change.range.idString}:${change.range.rangeHash}`
2620
- : `${change.type}:${change.range.idString}`;
2657
+ : `${change.type}:${change.range.hash}:${change.range.idString}`;
2621
2658
  const prev = aggregated.get(key);
2622
2659
  if (prev) {
2623
- if (prev.range.timestamp < change.range.timestamp) {
2660
+ // Replication ingress rejects strictly stale observations, while equal
2661
+ // timestamps are valid and ordered by arrival. Replace equal/newer keyed
2662
+ // values and move them to the Map tail so a rapid A -> B -> C update
2663
+ // emits replaced(A), replaced(B), added(C) in observed order.
2664
+ if (prev.range.timestamp <= change.range.timestamp) {
2665
+ aggregated.delete(key);
2624
2666
  aggregated.set(key, change);
2625
2667
  }
2626
2668
  } else {
@@ -2639,115 +2681,537 @@ export const debounceAggregationChanges = <
2639
2681
  );
2640
2682
  };
2641
2683
 
2684
+ type IndexedReplicationChange<R extends NumericType> = {
2685
+ change: ReplicationChange<ReplicationRangeIndexable<R>>;
2686
+ index: number;
2687
+ wasInHistory: boolean;
2688
+ };
2689
+
2690
+ const flattenReplicationChanges = <R extends NumericType>(
2691
+ changesOrChangesArr:
2692
+ | ReplicationChanges<ReplicationRangeIndexable<R>>
2693
+ | ReplicationChanges<ReplicationRangeIndexable<R>>[],
2694
+ ): ReplicationChange<ReplicationRangeIndexable<R>>[] => {
2695
+ const first = changesOrChangesArr[0];
2696
+ return Array.isArray(first)
2697
+ ? (
2698
+ changesOrChangesArr as ReplicationChanges<
2699
+ ReplicationRangeIndexable<R>
2700
+ >[]
2701
+ ).flat()
2702
+ : [
2703
+ ...(changesOrChangesArr as ReplicationChanges<
2704
+ ReplicationRangeIndexable<R>
2705
+ >),
2706
+ ];
2707
+ };
2708
+
2709
+ type ReplicationProfileDelta = {
2710
+ before: number;
2711
+ beforeNonStrict: number;
2712
+ after: number;
2713
+ afterNonStrict: number;
2714
+ };
2715
+
2716
+ const diffReplicationProfiles = <R extends NumericType>(
2717
+ before: ReplicationRangeIndexable<R>[],
2718
+ after: ReplicationRangeIndexable<R>[],
2719
+ ): { geometry: Array<[bigint, bigint]>; boundary: boolean } => {
2720
+ const events = new Map<bigint, ReplicationProfileDelta>();
2721
+ const updateEvent = (
2722
+ point: bigint,
2723
+ property: keyof ReplicationProfileDelta,
2724
+ delta: number,
2725
+ ) => {
2726
+ let event = events.get(point);
2727
+ if (!event) {
2728
+ event = {
2729
+ before: 0,
2730
+ beforeNonStrict: 0,
2731
+ after: 0,
2732
+ afterNonStrict: 0,
2733
+ };
2734
+ events.set(point, event);
2735
+ }
2736
+ event[property] += delta;
2737
+ };
2738
+ const addRange = (range: ReplicationRangeIndexable<R>, isBefore: boolean) => {
2739
+ const coverageProperty = isBefore ? "before" : "after";
2740
+ const nonStrictProperty = isBefore ? "beforeNonStrict" : "afterNonStrict";
2741
+ for (const [start, end] of toSegmentsBigInt(range)) {
2742
+ if (start >= end) {
2743
+ continue;
2744
+ }
2745
+ updateEvent(start, coverageProperty, 1);
2746
+ updateEvent(end, coverageProperty, -1);
2747
+ if (range.mode === ReplicationIntent.NonStrict) {
2748
+ updateEvent(start, nonStrictProperty, 1);
2749
+ updateEvent(end, nonStrictProperty, -1);
2750
+ }
2751
+ }
2752
+ };
2753
+ for (const range of before) {
2754
+ addRange(range, true);
2755
+ }
2756
+ for (const range of after) {
2757
+ addRange(range, false);
2758
+ }
2759
+
2760
+ const points = [...events.keys()].sort((a, b) =>
2761
+ a < b ? -1 : a > b ? 1 : 0,
2762
+ );
2763
+ const geometry: Array<[bigint, bigint]> = [];
2764
+ let boundary = false;
2765
+ let beforeCoverage = 0;
2766
+ let beforeNonStrict = 0;
2767
+ let afterCoverage = 0;
2768
+ let afterNonStrict = 0;
2769
+ for (let i = 0; i + 1 < points.length; i++) {
2770
+ const point = points[i];
2771
+ const event = events.get(point)!;
2772
+ beforeCoverage += event.before;
2773
+ beforeNonStrict += event.beforeNonStrict;
2774
+ afterCoverage += event.after;
2775
+ afterNonStrict += event.afterNonStrict;
2776
+ const next = points[i + 1];
2777
+ if (point >= next) {
2778
+ continue;
2779
+ }
2780
+ if (beforeCoverage > 0 !== afterCoverage > 0) {
2781
+ geometry.push([point, next]);
2782
+ }
2783
+ if (beforeNonStrict > 0 !== afterNonStrict > 0) {
2784
+ boundary = true;
2785
+ }
2786
+ }
2787
+ return { geometry: mergeBigIntSegments(geometry), boundary };
2788
+ };
2789
+
2790
+ const buildStrictQueryRanges = <R extends NumericType>(
2791
+ segments: Array<[bigint, bigint]>,
2792
+ template: ReplicationRangeIndexable<R>,
2793
+ ): ReplicationRangeIndexable<R>[] => {
2794
+ const merged = mergeBigIntSegments(
2795
+ segments
2796
+ .filter(([start, end]) => start < end)
2797
+ .map(([start, end]) => [start, end]),
2798
+ );
2799
+ if (merged.length === 0) {
2800
+ return [];
2801
+ }
2802
+
2803
+ const max = isU32Range(template) ? BigInt(MAX_U32) : MAX_U64;
2804
+ const arcs: Array<{
2805
+ first: [bigint, bigint];
2806
+ second?: [bigint, bigint];
2807
+ }> = [];
2808
+ if (
2809
+ merged.length > 1 &&
2810
+ merged[0][0] === 0n &&
2811
+ merged[merged.length - 1][1] === max
2812
+ ) {
2813
+ arcs.push({
2814
+ first: merged[merged.length - 1],
2815
+ second: merged[0],
2816
+ });
2817
+ for (let i = 1; i + 1 < merged.length; i++) {
2818
+ arcs.push({ first: merged[i] });
2819
+ }
2820
+ } else {
2821
+ for (const segment of merged) {
2822
+ arcs.push({ first: segment });
2823
+ }
2824
+ }
2825
+
2826
+ const proto = Object.getPrototypeOf(template);
2827
+ return arcs.map(({ first, second }) => {
2828
+ const [start1, end1] = toOriginalType(first, template);
2829
+ const [start2, end2] = second
2830
+ ? toOriginalType(second, template)
2831
+ : ([start1, end1] as [NumberFromType<R>, NumberFromType<R>]);
2832
+ const widthBigInt =
2833
+ first[1] - first[0] + (second ? second[1] - second[0] : 0n);
2834
+ const width = (
2835
+ isU32Range(template) ? Number(widthBigInt) : widthBigInt
2836
+ ) as NumberFromType<R>;
2837
+ return Object.assign(Object.create(proto), {
2838
+ ...template,
2839
+ start1,
2840
+ end1,
2841
+ start2,
2842
+ end2,
2843
+ width,
2844
+ mode: ReplicationIntent.Strict,
2845
+ }) as ReplicationRangeIndexable<R>;
2846
+ });
2847
+ };
2848
+
2849
+ const replicationRangeGeometryKey = <R extends NumericType>(
2850
+ range: ReplicationRangeIndexable<R>,
2851
+ ) =>
2852
+ toSegmentsBigInt(range)
2853
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
2854
+ .map(([start, end]) => `${start}:${end}`)
2855
+ .join("|");
2856
+
2857
+ // SQLite's query builder produces a left-deep OR expression. Keep a wide safety
2858
+ // margin below SQLite's default expression-depth limit (1000), including the
2859
+ // nested comparisons needed for wrapped ranges.
2860
+ const MAX_REBALANCE_QUERY_RANGES = 128;
2861
+
2862
+ type RebalanceOwnedInterval = {
2863
+ start: bigint;
2864
+ end: bigint;
2865
+ batch: number;
2866
+ };
2867
+
2868
+ type RebalanceQueryPlan<R extends NumericType> = {
2869
+ boundaryChanges?: ReplicationChange<ReplicationRangeIndexable<R>>[];
2870
+ geometryBatches: ReplicationChange<ReplicationRangeIndexable<R>>[][];
2871
+ ownedIntervals: RebalanceOwnedInterval[];
2872
+ };
2873
+
2874
+ const createRebalanceQueryPlan = <R extends NumericType>(
2875
+ changes: ReplicationChange<ReplicationRangeIndexable<R>>[],
2876
+ includeBoundaryWhenEmpty: boolean,
2877
+ ): RebalanceQueryPlan<R> => {
2878
+ const boundarySource = changes.find(
2879
+ (change) =>
2880
+ change.rebalanceBoundaryOnly ||
2881
+ change.range.mode === ReplicationIntent.NonStrict,
2882
+ );
2883
+ const boundaryChanges =
2884
+ boundarySource || includeBoundaryWhenEmpty
2885
+ ? boundarySource
2886
+ ? [{ ...boundarySource, rebalanceBoundaryOnly: true }]
2887
+ : []
2888
+ : undefined;
2889
+ const geometryChanges = changes.filter(
2890
+ (change) => !change.rebalanceBoundaryOnly,
2891
+ );
2892
+ if (geometryChanges.length === 0) {
2893
+ return { boundaryChanges, geometryBatches: [], ownedIntervals: [] };
2894
+ }
2895
+
2896
+ const template = geometryChanges[geometryChanges.length - 1];
2897
+ const exactSegments = mergeBigIntSegments(
2898
+ geometryChanges
2899
+ .flatMap((change) => toSegmentsBigInt(change.range))
2900
+ .filter(([start, end]) => start < end)
2901
+ .map(([start, end]) => [start, end]),
2902
+ );
2903
+ const exactRanges = buildStrictQueryRanges(exactSegments, template.range);
2904
+ const exactChanges: ReplicationChange<ReplicationRangeIndexable<R>>[] =
2905
+ exactRanges.map((range) => ({
2906
+ range,
2907
+ type: "replaced",
2908
+ timestamp: template.timestamp,
2909
+ }));
2910
+ const geometryBatches: ReplicationChange<ReplicationRangeIndexable<R>>[][] =
2911
+ [];
2912
+ for (
2913
+ let offset = 0;
2914
+ offset < exactChanges.length;
2915
+ offset += MAX_REBALANCE_QUERY_RANGES
2916
+ ) {
2917
+ geometryBatches.push(
2918
+ exactChanges.slice(offset, offset + MAX_REBALANCE_QUERY_RANGES),
2919
+ );
2920
+ }
2921
+
2922
+ // A row can contain coordinates in several query batches. Assign every exact
2923
+ // linear interval to its batch up front so candidates can be de-duplicated with
2924
+ // O(component-count) plan memory instead of an unbounded set of emitted hashes.
2925
+ // The two linear pieces of a wrapped seam range inherit the same batch tag.
2926
+ const ownedIntervals = exactRanges
2927
+ .flatMap((range, rangeIndex) =>
2928
+ toSegmentsBigInt(range)
2929
+ .filter(([start, end]) => start < end)
2930
+ .map(([start, end]) => ({
2931
+ start,
2932
+ end,
2933
+ batch: Math.floor(rangeIndex / MAX_REBALANCE_QUERY_RANGES),
2934
+ })),
2935
+ )
2936
+ .sort((a, b) =>
2937
+ a.start < b.start
2938
+ ? -1
2939
+ : a.start > b.start
2940
+ ? 1
2941
+ : a.end < b.end
2942
+ ? -1
2943
+ : a.end > b.end
2944
+ ? 1
2945
+ : 0,
2946
+ );
2947
+ for (let i = 1; i < ownedIntervals.length; i++) {
2948
+ if (ownedIntervals[i - 1].end > ownedIntervals[i].start) {
2949
+ throw new Error("Rebalance query intervals must not overlap");
2950
+ }
2951
+ }
2952
+
2953
+ return { boundaryChanges, geometryBatches, ownedIntervals };
2954
+ };
2955
+
2956
+ const findOwningRebalanceBatch = (
2957
+ coordinates: Array<number | bigint>,
2958
+ intervals: RebalanceOwnedInterval[],
2959
+ ): number | undefined => {
2960
+ let minimum: number | undefined;
2961
+ for (const value of coordinates) {
2962
+ const coordinate = typeof value === "number" ? BigInt(value) : value;
2963
+ let low = 0;
2964
+ let high = intervals.length - 1;
2965
+ while (low <= high) {
2966
+ const middle = low + Math.floor((high - low) / 2);
2967
+ const interval = intervals[middle];
2968
+ if (coordinate < interval.start) {
2969
+ high = middle - 1;
2970
+ } else if (coordinate >= interval.end) {
2971
+ low = middle + 1;
2972
+ } else {
2973
+ minimum =
2974
+ minimum == null ? interval.batch : Math.min(minimum, interval.batch);
2975
+ break;
2976
+ }
2977
+ }
2978
+ if (minimum === 0) {
2979
+ break;
2980
+ }
2981
+ }
2982
+ return minimum;
2983
+ };
2984
+
2985
+ const rotateRebalanceHistory = <R extends NumericType>(
2986
+ changes: ReplicationChange<ReplicationRangeIndexable<R>>[],
2987
+ rebalanceHistory: Cache<string>,
2988
+ ) => {
2989
+ // Only original source changes may enter history, and observed order defines
2990
+ // the terminal state: add -> remove ends absent; remove -> add ends present.
2991
+ for (const change of changes) {
2992
+ if (change.type === "added") {
2993
+ rebalanceHistory.add(change.range.rangeHash);
2994
+ } else {
2995
+ rebalanceHistory.del(change.range.rangeHash);
2996
+ }
2997
+ }
2998
+ };
2999
+
2642
3000
  export const mergeReplicationChanges = <R extends NumericType>(
2643
3001
  changesOrChangesArr:
2644
3002
  | ReplicationChanges<ReplicationRangeIndexable<R>>
2645
3003
  | ReplicationChanges<ReplicationRangeIndexable<R>>[],
2646
3004
  rebalanceHistory: Cache<string>,
3005
+ options?: { updateHistory?: boolean },
2647
3006
  ): ReplicationChange<ReplicationRangeIndexable<R>>[] => {
2648
- let first = changesOrChangesArr[0];
2649
- let changes: ReplicationChange<ReplicationRangeIndexable<R>>[];
2650
- if (!Array.isArray(first)) {
2651
- changes = changesOrChangesArr as ReplicationChange<
2652
- ReplicationRangeIndexable<R>
2653
- >[];
2654
- } else {
2655
- changes = changesOrChangesArr.flat() as ReplicationChange<
2656
- ReplicationRangeIndexable<R>
2657
- >[];
3007
+ // Work from an immutable snapshot. In particular, synthetic difference ranges
3008
+ // must never become input events or rebalance-history entries.
3009
+ const changes = flattenReplicationChanges(changesOrChangesArr);
3010
+ const originals: IndexedReplicationChange<R>[] = changes.map(
3011
+ (change, index) => ({
3012
+ change,
3013
+ index,
3014
+ wasInHistory: rebalanceHistory.has(change.range.rangeHash),
3015
+ }),
3016
+ );
3017
+ if (originals.length === 0) {
3018
+ return [];
2658
3019
  }
2659
3020
 
2660
- // group by hash so we can cancel out changes
2661
- const grouped = new Map<
3021
+ const consumed = new Set<number>();
3022
+ const fragmentGroups = new Map<
2662
3023
  string,
2663
- ReplicationChange<ReplicationRangeIndexable<R>>[]
3024
+ {
3025
+ segments: Array<[bigint, bigint]>;
3026
+ template: IndexedReplicationChange<R>;
3027
+ }
2664
3028
  >();
2665
- for (const change of changes) {
2666
- const prev = grouped.get(change.range.hash);
2667
- if (prev) {
2668
- prev.push(change);
3029
+ let boundarySource: IndexedReplicationChange<R> | undefined;
3030
+ const addFragments = (
3031
+ owner: string,
3032
+ segments: Array<[bigint, bigint]>,
3033
+ template: IndexedReplicationChange<R>,
3034
+ ) => {
3035
+ if (segments.length === 0) {
3036
+ return;
3037
+ }
3038
+ const existing = fragmentGroups.get(owner);
3039
+ if (existing) {
3040
+ existing.segments.push(...segments);
3041
+ if (
3042
+ existing.template.change.range.timestamp <
3043
+ template.change.range.timestamp
3044
+ ) {
3045
+ existing.template = template;
3046
+ }
2669
3047
  } else {
2670
- grouped.set(change.range.hash, [change]);
3048
+ fragmentGroups.set(owner, {
3049
+ segments: [...segments],
3050
+ template,
3051
+ });
2671
3052
  }
2672
- }
3053
+ };
3054
+ const requireBoundary = (source: IndexedReplicationChange<R>) => {
3055
+ if (!boundarySource) {
3056
+ boundarySource = source;
3057
+ }
3058
+ };
2673
3059
 
2674
- let all: ReplicationChange<ReplicationRangeIndexable<R>>[] = [];
2675
- for (const [_k, v] of grouped) {
2676
- if (v.length > 1) {
2677
- // sort by timestamp so newest is last
2678
- v.sort((a, b) =>
2679
- a.range.timestamp < b.range.timestamp
2680
- ? -1
2681
- : a.range.timestamp > b.range.timestamp
2682
- ? 1
2683
- : 0,
3060
+ // Replacement provenance is exact: a segment id describes a sequence of real
3061
+ // states. Compare each adjacent state once, then union the resulting query arcs.
3062
+ const replacementGroups = new Map<string, IndexedReplicationChange<R>[]>();
3063
+ for (const original of originals) {
3064
+ if (
3065
+ original.change.type !== "replaced" &&
3066
+ (original.change.type !== "added" || original.change.matured)
3067
+ ) {
3068
+ continue;
3069
+ }
3070
+ const key = `${original.change.range.hash}:${original.change.range.idString}`;
3071
+ const group = replacementGroups.get(key);
3072
+ if (group) {
3073
+ group.push(original);
3074
+ } else {
3075
+ replacementGroups.set(key, [original]);
3076
+ }
3077
+ }
3078
+ for (const statesUnsorted of replacementGroups.values()) {
3079
+ if (
3080
+ !statesUnsorted.some((state) => state.change.type === "replaced") ||
3081
+ !statesUnsorted.some((state) => state.change.type === "added")
3082
+ ) {
3083
+ continue;
3084
+ }
3085
+ // Range timestamps express role age and may intentionally stay fixed across
3086
+ // adaptive geometry updates. The debounced batch order is the authoritative
3087
+ // state-transition order.
3088
+ const states = [...statesUnsorted].sort((a, b) => a.index - b.index);
3089
+ if (states[states.length - 1].change.type !== "added") {
3090
+ continue;
3091
+ }
3092
+ // The overlap between adjacent replacement states is safe to omit only if
3093
+ // the first state was successfully processed before this batch. A replacement
3094
+ // event describes retired geometry, not proof that its overlap was queried.
3095
+ // Without that proof, leave the complete real states in the result so their
3096
+ // union (including the overlap) is inspected.
3097
+ if (!states[0].wasInHistory) {
3098
+ continue;
3099
+ }
3100
+ for (let i = 0; i + 1 < states.length; i++) {
3101
+ const previous = states[i];
3102
+ const next = states[i + 1];
3103
+ const difference = diffReplicationProfiles(
3104
+ [previous.change.range],
3105
+ [next.change.range],
2684
3106
  );
2685
-
2686
- let results: ReplicationChange<ReplicationRangeIndexable<R>>[] = [];
2687
- let consumed: Set<number> = new Set();
2688
- for (let i = 0; i < v.length; i++) {
2689
- // If segment is removed and we have previously processed it then go over each
2690
- // overlapping added segment and remove the overlap. Equivalent to: (1 - 1 + 1) = 1.
2691
- if (v[i].type === "removed" || v[i].type === "replaced") {
2692
- if (rebalanceHistory.has(v[i].range.rangeHash)) {
2693
- let adjusted = false;
2694
- const vStart = v.length;
2695
- for (let j = i + 1; j < vStart; j++) {
2696
- const newer = v[j];
2697
- if (newer.type === "added" && !newer.matured) {
2698
- adjusted = true;
2699
- const {
2700
- rangesFromA: updatedRemoved,
2701
- rangesFromB: updatedNewer,
2702
- } = symmetricDifferenceRanges(v[i].range, newer.range);
2703
-
2704
- for (const diff of updatedRemoved) {
2705
- results.push({
2706
- range: diff,
2707
- type: "removed" as const,
2708
- timestamp: v[i].timestamp,
2709
- });
2710
- }
2711
- for (const diff of updatedNewer) {
2712
- v.push({
2713
- range: diff,
2714
- type: "added" as const,
2715
- timestamp: newer.timestamp,
2716
- });
2717
- }
2718
- consumed.add(j);
2719
- }
2720
- }
2721
- rebalanceHistory.del(v[i].range.rangeHash);
2722
- if (!adjusted) {
2723
- results.push(v[i]);
2724
- }
2725
- } else {
2726
- results.push(v[i]);
2727
- }
2728
- } else if (v[i].type === "added") {
2729
- // TODO should the below clause be used?
2730
- // after testing it seems that certain changes are not propagating as expected using this
2731
- /* if (rebalanceHistory.has(v[i].range.rangeHash)) {
2732
- continue;
2733
- } */
2734
-
2735
- rebalanceHistory.add(v[i].range.rangeHash);
2736
- if (!consumed.has(i)) {
2737
- results.push(v[i]);
2738
- }
2739
- } else {
2740
- results.push(v[i]);
2741
- }
3107
+ addFragments(previous.change.range.hash, difference.geometry, next);
3108
+ if (difference.boundary) {
3109
+ requireBoundary(next);
2742
3110
  }
3111
+ }
3112
+ for (const state of states) {
3113
+ consumed.add(state.index);
3114
+ }
3115
+ }
2743
3116
 
2744
- all.push(...results);
3117
+ // Plain reset-style removals/additions have no id provenance. Only removals
3118
+ // whose old state was actually processed may cancel new owner state. Compare
3119
+ // their aggregate owner profiles; leave all unrelated/unmatched changes whole.
3120
+ const plainGroups = new Map<string, IndexedReplicationChange<R>[]>();
3121
+ for (const original of originals) {
3122
+ if (
3123
+ consumed.has(original.index) ||
3124
+ (original.change.type !== "removed" && original.change.type !== "added")
3125
+ ) {
3126
+ continue;
3127
+ }
3128
+ const group = plainGroups.get(original.change.range.hash);
3129
+ if (group) {
3130
+ group.push(original);
2745
3131
  } else {
2746
- rebalanceHistory.add(v[0].range.rangeHash);
2747
- all.push(v[0]);
3132
+ plainGroups.set(original.change.range.hash, [original]);
3133
+ }
3134
+ }
3135
+ for (const ownerChanges of plainGroups.values()) {
3136
+ const removed = ownerChanges.filter(
3137
+ (original) => original.change.type === "removed" && original.wasInHistory,
3138
+ );
3139
+ const added = ownerChanges.filter(
3140
+ (original) =>
3141
+ original.change.type === "added" && !original.change.matured,
3142
+ );
3143
+ if (removed.length === 0 || added.length === 0) {
3144
+ continue;
3145
+ }
3146
+ const difference = diffReplicationProfiles(
3147
+ removed.map((original) => original.change.range),
3148
+ added.map((original) => original.change.range),
3149
+ );
3150
+ const newestAdded = added.reduce((newest, current) =>
3151
+ newest.change.range.timestamp >= current.change.range.timestamp
3152
+ ? newest
3153
+ : current,
3154
+ );
3155
+ addFragments(
3156
+ newestAdded.change.range.hash,
3157
+ difference.geometry,
3158
+ newestAdded,
3159
+ );
3160
+ if (difference.boundary) {
3161
+ requireBoundary(newestAdded);
3162
+ }
3163
+ for (const original of [...removed, ...added]) {
3164
+ consumed.add(original.index);
2748
3165
  }
2749
3166
  }
2750
- return all;
3167
+
3168
+ const results: ReplicationChange<ReplicationRangeIndexable<R>>[] = originals
3169
+ .filter((original) => !consumed.has(original.index))
3170
+ .map((original) => original.change);
3171
+ for (const { segments, template } of fragmentGroups.values()) {
3172
+ for (const range of buildStrictQueryRanges(
3173
+ segments,
3174
+ template.change.range,
3175
+ )) {
3176
+ results.push({
3177
+ range,
3178
+ type: "replaced",
3179
+ timestamp: template.change.timestamp,
3180
+ });
3181
+ }
3182
+ }
3183
+
3184
+ // Query type and owner do not affect range matching. Remove exact geometry
3185
+ // duplicates while preferring a non-strict original when it carries boundary
3186
+ // semantics of its own.
3187
+ const deduplicated: ReplicationChange<ReplicationRangeIndexable<R>>[] = [];
3188
+ const positions = new Map<string, number>();
3189
+ for (const result of results) {
3190
+ if (result.rebalanceBoundaryOnly) {
3191
+ continue;
3192
+ }
3193
+ const key = replicationRangeGeometryKey(result.range);
3194
+ const previousPosition = positions.get(key);
3195
+ if (previousPosition == null) {
3196
+ positions.set(key, deduplicated.length);
3197
+ deduplicated.push(result);
3198
+ } else if (
3199
+ deduplicated[previousPosition].range.mode === ReplicationIntent.Strict &&
3200
+ result.range.mode === ReplicationIntent.NonStrict
3201
+ ) {
3202
+ deduplicated[previousPosition] = result;
3203
+ }
3204
+ }
3205
+ if (boundarySource) {
3206
+ deduplicated.push({
3207
+ ...boundarySource.change,
3208
+ rebalanceBoundaryOnly: true,
3209
+ });
3210
+ }
3211
+ if (options?.updateHistory !== false) {
3212
+ rotateRebalanceHistory(changes, rebalanceHistory);
3213
+ }
3214
+ return deduplicated;
2751
3215
  };
2752
3216
 
2753
3217
  const REBALANCE_ITERATOR_BATCH_SIZE = 1048;
@@ -2760,30 +3224,87 @@ export const toRebalance = <R extends "u32" | "u64">(
2760
3224
  rebalanceHistory: Cache<string>,
2761
3225
  options?: { forceFresh?: boolean },
2762
3226
  ): AsyncIterable<EntryReplicated<R>> => {
2763
- const change = options?.forceFresh
2764
- ? Array.isArray(changeOrChanges[0])
2765
- ? (
2766
- changeOrChanges as ReplicationChanges<ReplicationRangeIndexable<R>>[]
2767
- ).flat()
2768
- : (changeOrChanges as ReplicationChanges<ReplicationRangeIndexable<R>>)
2769
- : mergeReplicationChanges(changeOrChanges, rebalanceHistory);
3227
+ const sourceChanges = flattenReplicationChanges(changeOrChanges);
3228
+ const queryChanges = options?.forceFresh
3229
+ ? sourceChanges
3230
+ : mergeReplicationChanges(sourceChanges, rebalanceHistory, {
3231
+ updateHistory: false,
3232
+ });
3233
+ const plan = createRebalanceQueryPlan(
3234
+ queryChanges,
3235
+ sourceChanges.length === 0,
3236
+ );
2770
3237
  return {
2771
3238
  [Symbol.asyncIterator]: async function* () {
2772
- const iterator = index.iterate({
2773
- query: createAssignedRangesQuery(change),
2774
- });
3239
+ let completed = false;
2775
3240
  try {
2776
- while (iterator.done() !== true) {
2777
- const entries = await iterator.next(REBALANCE_ITERATOR_BATCH_SIZE);
2778
- if (entries.length === 0) {
2779
- break;
3241
+ const passes: Array<{
3242
+ changes: ReplicationChange<ReplicationRangeIndexable<R>>[];
3243
+ geometryBatch?: number;
3244
+ }> = [];
3245
+ if (plan.boundaryChanges) {
3246
+ passes.push({ changes: plan.boundaryChanges });
3247
+ }
3248
+ for (let batch = 0; batch < plan.geometryBatches.length; batch++) {
3249
+ passes.push({
3250
+ changes: plan.geometryBatches[batch],
3251
+ geometryBatch: batch,
3252
+ });
3253
+ }
3254
+
3255
+ for (const pass of passes) {
3256
+ const isGeometry = pass.geometryBatch != null;
3257
+ const iterator = index.iterate({
3258
+ query: createAssignedRangesQuery(pass.changes, {
3259
+ strict: isGeometry,
3260
+ includeBoundaryWhenEmpty: !isGeometry,
3261
+ }),
3262
+ });
3263
+ let passCompleted = false;
3264
+ try {
3265
+ while (iterator.done() !== true) {
3266
+ const entries = await iterator.next(
3267
+ REBALANCE_ITERATOR_BATCH_SIZE,
3268
+ );
3269
+ if (entries.length === 0) {
3270
+ break;
3271
+ }
3272
+ for (const entry of entries) {
3273
+ if (isGeometry) {
3274
+ if (
3275
+ plan.boundaryChanges &&
3276
+ entry.value.assignedToRangeBoundary
3277
+ ) {
3278
+ continue;
3279
+ }
3280
+ if (
3281
+ findOwningRebalanceBatch(
3282
+ entry.value.coordinates,
3283
+ plan.ownedIntervals,
3284
+ ) !== pass.geometryBatch
3285
+ ) {
3286
+ continue;
3287
+ }
3288
+ }
3289
+ yield entry.value;
3290
+ }
3291
+ }
3292
+ passCompleted = iterator.done() === true;
3293
+ } finally {
3294
+ await iterator.close();
2780
3295
  }
2781
- for (const entry of entries) {
2782
- yield entry.value;
3296
+ if (!passCompleted) {
3297
+ return;
2783
3298
  }
2784
3299
  }
3300
+ completed = true;
2785
3301
  } finally {
2786
- await iterator.close();
3302
+ // An early consumer return, iterator error, incomplete empty batch, or
3303
+ // close failure in any sequential pass must leave history untouched so the
3304
+ // next event retries the complete query. forceFresh preserves its bypass.
3305
+ if (completed && !options?.forceFresh) {
3306
+ rotateRebalanceHistory(sourceChanges, rebalanceHistory);
3307
+ }
2787
3308
  }
2788
3309
  },
2789
3310
  };