@peerbit/shared-log 16.0.5 → 16.0.7

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
@@ -476,7 +476,10 @@ export type NumericType = "u32" | "u64";
476
476
  * We always do the conversion to bigints internally.
477
477
  */
478
478
  export function toSegmentsBigInt<N extends NumericType>(
479
- range: ReplicationRangeIndexable<N>,
479
+ range: Pick<
480
+ ReplicationRangeIndexable<N>,
481
+ "start1" | "end1" | "start2" | "end2"
482
+ >,
480
483
  ): Array<[bigint, bigint]> {
481
484
  // Safely convert the numeric fields to bigint
482
485
  const s1: bigint =
@@ -1553,6 +1556,7 @@ const getClosest = <S extends Shape | undefined, R extends "u32" | "u64">(
1553
1556
  options?: {
1554
1557
  shape?: S;
1555
1558
  hash?: string;
1559
+ excludeId?: Uint8Array;
1556
1560
  time?: {
1557
1561
  roleAgeLimit: number;
1558
1562
  matured: boolean;
@@ -1560,13 +1564,16 @@ const getClosest = <S extends Shape | undefined, R extends "u32" | "u64">(
1560
1564
  };
1561
1565
  },
1562
1566
  ): IndexIterator<ReplicationRangeIndexable<R>, S> => {
1563
- const createQueries = (p: NumberFromType<R>, equality: boolean) => {
1567
+ const createQueries = (
1568
+ p: NumberFromType<R>,
1569
+ phase: "primary" | "wrapped",
1570
+ ) => {
1564
1571
  let queries: Query[];
1565
1572
  if (direction === "below") {
1566
1573
  queries = [
1567
1574
  new IntegerCompare({
1568
1575
  key: "end2",
1569
- compare: equality ? Compare.LessOrEqual : Compare.Less,
1576
+ compare: phase === "primary" ? Compare.LessOrEqual : Compare.Greater,
1570
1577
  value: p,
1571
1578
  }),
1572
1579
  ];
@@ -1574,7 +1581,7 @@ const getClosest = <S extends Shape | undefined, R extends "u32" | "u64">(
1574
1581
  queries = [
1575
1582
  new IntegerCompare({
1576
1583
  key: "start1",
1577
- compare: equality ? Compare.GreaterOrEqual : Compare.Greater,
1584
+ compare: phase === "primary" ? Compare.GreaterOrEqual : Compare.Less,
1578
1585
  value: p,
1579
1586
  }),
1580
1587
  ];
@@ -1608,6 +1615,16 @@ const getClosest = <S extends Shape | undefined, R extends "u32" | "u64">(
1608
1615
  if (options?.hash) {
1609
1616
  queries.push(new StringMatch({ key: "hash", value: options.hash }));
1610
1617
  }
1618
+ if (options?.excludeId) {
1619
+ queries.push(
1620
+ new Not(
1621
+ new ByteMatchQuery({
1622
+ key: "id",
1623
+ value: options.excludeId,
1624
+ }),
1625
+ ),
1626
+ );
1627
+ }
1611
1628
  return queries;
1612
1629
  };
1613
1630
 
@@ -1616,7 +1633,7 @@ const getClosest = <S extends Shape | undefined, R extends "u32" | "u64">(
1616
1633
 
1617
1634
  const iterator = rects.iterate(
1618
1635
  {
1619
- query: createQueries(point, false),
1636
+ query: createQueries(point, "primary"),
1620
1637
  sort: [
1621
1638
  direction === "below"
1622
1639
  ? new Sort({ key: ["end2"], direction: "desc" })
@@ -1630,10 +1647,7 @@ const getClosest = <S extends Shape | undefined, R extends "u32" | "u64">(
1630
1647
 
1631
1648
  const iteratorWrapped = rects.iterate(
1632
1649
  {
1633
- query: createQueries(
1634
- direction === "below" ? numbers.maxValue : numbers.zero,
1635
- true,
1636
- ),
1650
+ query: createQueries(point, "wrapped"),
1637
1651
  sort: [
1638
1652
  direction === "below"
1639
1653
  ? new Sort({ key: ["end2"], direction: "desc" })
@@ -1778,10 +1792,11 @@ export function getDistance(
1778
1792
  value < 0 ? -value : value;
1779
1793
  const diff = <T extends number | bigint>(a: T, b: T): T => abs(a - b) as T;
1780
1794
 
1795
+ if (from === to) {
1796
+ return typeof from === "number" ? 0 : 0n;
1797
+ }
1798
+
1781
1799
  if (direction === "closest") {
1782
- if (from === to) {
1783
- return typeof from === "number" ? 0 : 0n; // returns 0 of the correct type
1784
- }
1785
1800
  return diff(from, to) < diff(end, diff(from, to))
1786
1801
  ? diff(from, to)
1787
1802
  : diff(end, diff(from, to));
@@ -1804,22 +1819,57 @@ export function getDistance(
1804
1819
  throw new Error("Invalid direction");
1805
1820
  }
1806
1821
 
1807
- const joinIterator = <S extends Shape | undefined, R extends "u32" | "u64">(
1822
+ /** @internal */
1823
+ export const joinIterator = <
1824
+ S extends Shape | undefined,
1825
+ R extends "u32" | "u64",
1826
+ >(
1808
1827
  iterators: IndexIterator<ReplicationRangeIndexable<R>, S>[],
1809
1828
  point: NumberFromType<R>,
1810
1829
  direction: "above" | "below" | "closest",
1811
1830
  numbers: Numbers<R>,
1812
1831
  ): IndexIterator<ReplicationRangeIndexable<R>, S> => {
1832
+ type QueuedResult = {
1833
+ result: IndexedResult<ReturnTypeFromShape<ReplicationRangeIndexable<R>, S>>;
1834
+ dist: NumberFromType<R>;
1835
+ };
1836
+
1837
+ const compareQueuedResults = (left: QueuedResult, right: QueuedResult) => {
1838
+ if (left.dist < right.dist) return -1;
1839
+ if (left.dist > right.dist) return 1;
1840
+
1841
+ // Match the native closest-range total order. The shaped iterator users do
1842
+ // not always project every tie field, so only compare a field when both
1843
+ // results carry it; getSamples uses complete ranges and therefore always
1844
+ // follows distance, timestamp, owner hash, then range id.
1845
+ const leftValue = left.result.value as Partial<
1846
+ ReplicationRangeIndexable<R>
1847
+ >;
1848
+ const rightValue = right.result.value as Partial<
1849
+ ReplicationRangeIndexable<R>
1850
+ >;
1851
+ if (leftValue.timestamp != null && rightValue.timestamp != null) {
1852
+ if (leftValue.timestamp < rightValue.timestamp) return -1;
1853
+ if (leftValue.timestamp > rightValue.timestamp) return 1;
1854
+ }
1855
+ if (leftValue.hash != null && rightValue.hash != null) {
1856
+ if (leftValue.hash < rightValue.hash) return -1;
1857
+ if (leftValue.hash > rightValue.hash) return 1;
1858
+ }
1859
+ const leftId = leftValue.id == null ? undefined : toBase64(leftValue.id);
1860
+ const rightId = rightValue.id == null ? undefined : toBase64(rightValue.id);
1861
+ if (leftId != null && rightId != null) {
1862
+ if (leftId < rightId) return -1;
1863
+ if (leftId > rightId) return 1;
1864
+ }
1865
+ return 0;
1866
+ };
1867
+
1813
1868
  let queues: {
1814
- elements: {
1815
- result: IndexedResult<
1816
- ReturnTypeFromShape<ReplicationRangeIndexable<R>, S>
1817
- >;
1818
- dist: NumberFromType<R>;
1819
- }[];
1869
+ elements: QueuedResult[];
1820
1870
  }[] = [];
1821
1871
 
1822
- return {
1872
+ const joined: IndexIterator<ReplicationRangeIndexable<R>, S> = {
1823
1873
  next: async (
1824
1874
  count: number,
1825
1875
  ): Promise<
@@ -1884,13 +1934,16 @@ const joinIterator = <S extends Shape | undefined, R extends "u32" | "u64">(
1884
1934
 
1885
1935
  for (let i = 0; i < count; i++) {
1886
1936
  let closestQueue = -1;
1887
- let closestDist: bigint | number = Number.MAX_VALUE;
1937
+ let selected: QueuedResult | undefined;
1888
1938
  for (let j = 0; j < queues.length; j++) {
1889
1939
  let queue = queues[j];
1890
1940
  if (queue && queue.elements.length > 0) {
1891
- let closest = queue.elements[0];
1892
- if (closest.dist < closestDist) {
1893
- closestDist = closest.dist;
1941
+ const candidate = queue.elements[0];
1942
+ if (
1943
+ candidate &&
1944
+ (!selected || compareQueuedResults(candidate, selected) < 0)
1945
+ ) {
1946
+ selected = candidate;
1894
1947
  closestQueue = j;
1895
1948
  }
1896
1949
  }
@@ -1900,7 +1953,7 @@ const joinIterator = <S extends Shape | undefined, R extends "u32" | "u64">(
1900
1953
  break;
1901
1954
  }
1902
1955
 
1903
- let closest = queues[closestQueue]?.elements.shift();
1956
+ const closest = queues[closestQueue]?.elements.shift();
1904
1957
  if (closest) {
1905
1958
  results.push(closest.result);
1906
1959
  }
@@ -1909,25 +1962,43 @@ const joinIterator = <S extends Shape | undefined, R extends "u32" | "u64">(
1909
1962
  },
1910
1963
  pending: async () => {
1911
1964
  let allPending = await Promise.all(iterators.map((x) => x.pending()));
1912
- return allPending.reduce((acc, x) => acc + x, 0);
1965
+ // A child can be exhausted while this join still owns prefetched rows.
1966
+ // Counting local queues makes the same contract compose through nested joins.
1967
+ const buffered = queues.reduce(
1968
+ (acc, queue) => acc + queue.elements.length,
1969
+ 0,
1970
+ );
1971
+ return buffered + allPending.reduce((acc, x) => acc + x, 0);
1913
1972
  },
1914
- done: () => iterators.every((x) => x.done() === true),
1973
+ done: () =>
1974
+ queues.every((queue) => queue.elements.length === 0) &&
1975
+ iterators.every((x) => x.done() === true),
1915
1976
  close: async () => {
1916
- for (const iterator of iterators) {
1917
- await iterator.close();
1977
+ queues.length = 0;
1978
+ const settled = await Promise.allSettled(
1979
+ iterators.map(async (iterator) => iterator.close()),
1980
+ );
1981
+ const errors = settled.flatMap((result) =>
1982
+ result.status === "rejected" ? [result.reason] : [],
1983
+ );
1984
+ if (errors.length === 1) {
1985
+ throw errors[0];
1986
+ }
1987
+ if (errors.length > 1) {
1988
+ throw new AggregateError(errors, "Failed to close joined iterators");
1918
1989
  }
1919
1990
  },
1920
1991
  all: async () => {
1921
1992
  let results: IndexedResult<
1922
1993
  ReturnTypeFromShape<ReplicationRangeIndexable<R>, S>
1923
1994
  >[] = [];
1924
- for (const iterator of iterators) {
1925
- let res = await iterator.all();
1926
- results.push(...res);
1995
+ while (!joined.done()) {
1996
+ results.push(...(await joined.next(100)));
1927
1997
  }
1928
1998
  return results;
1929
1999
  },
1930
2000
  };
2001
+ return joined;
1931
2002
  };
1932
2003
 
1933
2004
  const getClosestAroundOrContaining = <
@@ -1976,6 +2047,7 @@ const getClosestAroundOrContaining = <
1976
2047
  export const getAdjecentSameOwner = async <R extends "u32" | "u64">(
1977
2048
  peers: Index<ReplicationRangeIndexable<R>>,
1978
2049
  range: {
2050
+ id?: Uint8Array;
1979
2051
  idString?: string;
1980
2052
  start1: NumberFromType<R>;
1981
2053
  end2: NumberFromType<R>;
@@ -1994,6 +2066,7 @@ export const getAdjecentSameOwner = async <R extends "u32" | "u64">(
1994
2066
  numbers,
1995
2067
  {
1996
2068
  hash: range.hash,
2069
+ excludeId: range.id,
1997
2070
  },
1998
2071
  );
1999
2072
  const closestBelow = await closestBelowIterator.next(1);
@@ -2006,21 +2079,21 @@ export const getAdjecentSameOwner = async <R extends "u32" | "u64">(
2006
2079
  numbers,
2007
2080
  {
2008
2081
  hash: range.hash,
2082
+ excludeId: range.id,
2009
2083
  },
2010
2084
  );
2011
2085
  const closestAbove = await closestAboveIterator.next(1);
2012
2086
  closestAboveIterator.close();
2013
- return {
2014
- below:
2015
- range.idString === closestBelow[0]?.value.idString
2016
- ? undefined
2017
- : closestBelow[0]?.value,
2018
- above:
2019
- closestBelow[0]?.id.primitive === closestAbove[0]?.id.primitive ||
2020
- range.idString === closestBelow[0]?.value.idString
2021
- ? undefined
2022
- : closestAbove[0]?.value,
2023
- };
2087
+ const below =
2088
+ range.idString === closestBelow[0]?.value.idString
2089
+ ? undefined
2090
+ : closestBelow[0]?.value;
2091
+ const above =
2092
+ range.idString === closestAbove[0]?.value.idString ||
2093
+ below?.idString === closestAbove[0]?.value.idString
2094
+ ? undefined
2095
+ : closestAbove[0]?.value;
2096
+ return { below, above };
2024
2097
  };
2025
2098
 
2026
2099
  export const getAllMergeCandiates = async <R extends "u32" | "u64">(
@@ -2128,7 +2201,7 @@ export const getSamples = async <R extends "u32" | "u64">(
2128
2201
  }
2129
2202
 
2130
2203
  const now = +new Date();
2131
- let matured = 0;
2204
+ const matureOwners = new Set<string>();
2132
2205
 
2133
2206
  let uniqueVisited = new Set<string>();
2134
2207
  const peerFilter = options?.peerFilter;
@@ -2146,10 +2219,10 @@ export const getSamples = async <R extends "u32" | "u64">(
2146
2219
  }
2147
2220
  uniqueVisited.add(rect.value.hash);
2148
2221
  let prev = leaders.get(rect.value.hash);
2222
+ if (isMatured(rect.value, now, roleAge)) {
2223
+ matureOwners.add(rect.value.hash);
2224
+ }
2149
2225
  if (!prev) {
2150
- if (isMatured(rect.value, now, roleAge)) {
2151
- matured++;
2152
- }
2153
2226
  leaders.set(rect.value.hash, { intersecting: true });
2154
2227
  } else {
2155
2228
  prev.intersecting = true;
@@ -2165,7 +2238,7 @@ export const getSamples = async <R extends "u32" | "u64">(
2165
2238
  }
2166
2239
  }
2167
2240
 
2168
- if (options?.onlyIntersecting || matured > i) {
2241
+ if (options?.onlyIntersecting || matureOwners.size > i) {
2169
2242
  continue;
2170
2243
  }
2171
2244
 
@@ -2180,11 +2253,11 @@ export const getSamples = async <R extends "u32" | "u64">(
2180
2253
  uniqueVisited.add(rect.hash);
2181
2254
  const prev = leaders.get(rect.hash);
2182
2255
  if (m) {
2256
+ matureOwners.add(rect.hash);
2183
2257
  if (!prev) {
2184
- matured++;
2185
2258
  leaders.set(rect.hash, { intersecting: false });
2186
2259
  }
2187
- if (matured > i) {
2260
+ if (matureOwners.size > i) {
2188
2261
  foundOneUniqueMatured = true;
2189
2262
  }
2190
2263
  }
@@ -2864,39 +2937,142 @@ const replicationRangeGeometryKey = <R extends NumericType>(
2864
2937
  // margin below SQLite's default expression-depth limit (1000), including the
2865
2938
  // nested comparisons needed for wrapped ranges.
2866
2939
  const MAX_REBALANCE_QUERY_RANGES = 128;
2940
+ const MAX_REBALANCE_TASKS_PER_PAGE = 8;
2867
2941
 
2868
- type RebalanceOwnedInterval = {
2942
+ export type RebalanceQueryRange<R extends NumericType> = Readonly<{
2943
+ start1: NumberFromType<R>;
2944
+ end1: NumberFromType<R>;
2945
+ start2: NumberFromType<R>;
2946
+ end2: NumberFromType<R>;
2947
+ mode: ReplicationIntent.Strict;
2948
+ }>;
2949
+
2950
+ export type RebalanceOwnedInterval = Readonly<{
2869
2951
  start: bigint;
2870
2952
  end: bigint;
2871
- batch: number;
2872
- };
2953
+ geometryTask: number;
2954
+ }>;
2955
+
2956
+ export type RebalanceHistoryMutation = Readonly<{
2957
+ rangeHash: string;
2958
+ present: boolean;
2959
+ }>;
2960
+
2961
+ /**
2962
+ * Complete control-plane query geometry. Its size is proportional to the input
2963
+ * range metadata; use task pages to bound query construction, not plan memory.
2964
+ * @internal
2965
+ */
2966
+ export type RebalanceScanPlan<R extends NumericType> = Readonly<{
2967
+ boundary: boolean;
2968
+ geometryRanges: readonly RebalanceQueryRange<R>[];
2969
+ ownedIntervals: readonly RebalanceOwnedInterval[];
2970
+ taskCount: number;
2971
+ historyMutations: readonly RebalanceHistoryMutation[];
2972
+ }>;
2973
+
2974
+ export type RebalanceScanTask<R extends NumericType> =
2975
+ | Readonly<{
2976
+ kind: "boundary";
2977
+ ordinal: number;
2978
+ }>
2979
+ | Readonly<{
2980
+ kind: "geometry";
2981
+ ordinal: number;
2982
+ geometryTask: number;
2983
+ ranges: readonly RebalanceQueryRange<R>[];
2984
+ }>;
2873
2985
 
2874
- type RebalanceQueryPlan<R extends NumericType> = {
2875
- boundaryChanges?: ReplicationChange<ReplicationRangeIndexable<R>>[];
2876
- geometryBatches: ReplicationChange<ReplicationRangeIndexable<R>>[][];
2877
- ownedIntervals: RebalanceOwnedInterval[];
2986
+ /**
2987
+ * Ephemeral offset within one immutable plan. It is not a restart cursor: any
2988
+ * durable executor must bind progress to an exact plan and placement view.
2989
+ */
2990
+ export type RebalanceTaskCursor = Readonly<{ nextTask: number }>;
2991
+
2992
+ export type RebalanceTaskPage<R extends NumericType> = Readonly<{
2993
+ tasks: readonly RebalanceScanTask<R>[];
2994
+ next?: RebalanceTaskCursor;
2995
+ }>;
2996
+
2997
+ type RebalanceQueryGeometryPlan<R extends NumericType> = Pick<
2998
+ RebalanceScanPlan<R>,
2999
+ "boundary" | "geometryRanges" | "ownedIntervals" | "taskCount"
3000
+ >;
3001
+
3002
+ const buildStrictQueryRangeDTOs = <R extends NumericType>(
3003
+ segments: Array<[bigint, bigint]>,
3004
+ template: ReplicationRangeIndexable<R>,
3005
+ ): RebalanceQueryRange<R>[] => {
3006
+ const merged = mergeBigIntSegments(
3007
+ segments
3008
+ .filter(([start, end]) => start < end)
3009
+ .map(([start, end]) => [start, end]),
3010
+ );
3011
+ if (merged.length === 0) {
3012
+ return [];
3013
+ }
3014
+
3015
+ const max = isU32Range(template) ? BigInt(MAX_U32) : MAX_U64;
3016
+ const arcs: Array<{
3017
+ first: [bigint, bigint];
3018
+ second?: [bigint, bigint];
3019
+ }> = [];
3020
+ if (
3021
+ merged.length > 1 &&
3022
+ merged[0][0] === 0n &&
3023
+ merged[merged.length - 1][1] === max
3024
+ ) {
3025
+ // Preserve the historical seam-first order. Apart from compatibility, this
3026
+ // keeps the two linear pieces of a wrapped query range in one task.
3027
+ arcs.push({
3028
+ first: merged[merged.length - 1],
3029
+ second: merged[0],
3030
+ });
3031
+ for (let i = 1; i + 1 < merged.length; i++) {
3032
+ arcs.push({ first: merged[i] });
3033
+ }
3034
+ } else {
3035
+ for (const segment of merged) {
3036
+ arcs.push({ first: segment });
3037
+ }
3038
+ }
3039
+
3040
+ return arcs.map(({ first, second }) => {
3041
+ const [start1, end1] = toOriginalType(first, template);
3042
+ const [start2, end2] = second
3043
+ ? toOriginalType(second, template)
3044
+ : ([start1, end1] as [NumberFromType<R>, NumberFromType<R>]);
3045
+ return {
3046
+ start1,
3047
+ end1,
3048
+ start2,
3049
+ end2,
3050
+ mode: ReplicationIntent.Strict,
3051
+ };
3052
+ });
2878
3053
  };
2879
3054
 
2880
- const createRebalanceQueryPlan = <R extends NumericType>(
2881
- changes: ReplicationChange<ReplicationRangeIndexable<R>>[],
3055
+ const createRebalanceQueryGeometryPlan = <R extends NumericType>(
3056
+ changes: readonly ReplicationChange<ReplicationRangeIndexable<R>>[],
2882
3057
  includeBoundaryWhenEmpty: boolean,
2883
- ): RebalanceQueryPlan<R> => {
2884
- const boundarySource = changes.find(
2885
- (change) =>
2886
- change.rebalanceBoundaryOnly ||
2887
- change.range.mode === ReplicationIntent.NonStrict,
2888
- );
2889
- const boundaryChanges =
2890
- boundarySource || includeBoundaryWhenEmpty
2891
- ? boundarySource
2892
- ? [{ ...boundarySource, rebalanceBoundaryOnly: true }]
2893
- : []
2894
- : undefined;
3058
+ ): RebalanceQueryGeometryPlan<R> => {
3059
+ const boundary =
3060
+ includeBoundaryWhenEmpty ||
3061
+ changes.some(
3062
+ (change) =>
3063
+ change.rebalanceBoundaryOnly ||
3064
+ change.range.mode === ReplicationIntent.NonStrict,
3065
+ );
2895
3066
  const geometryChanges = changes.filter(
2896
3067
  (change) => !change.rebalanceBoundaryOnly,
2897
3068
  );
2898
3069
  if (geometryChanges.length === 0) {
2899
- return { boundaryChanges, geometryBatches: [], ownedIntervals: [] };
3070
+ return {
3071
+ boundary,
3072
+ geometryRanges: [],
3073
+ ownedIntervals: [],
3074
+ taskCount: boundary ? 1 : 0,
3075
+ };
2900
3076
  }
2901
3077
 
2902
3078
  const template = geometryChanges[geometryChanges.length - 1];
@@ -2906,37 +3082,23 @@ const createRebalanceQueryPlan = <R extends NumericType>(
2906
3082
  .filter(([start, end]) => start < end)
2907
3083
  .map(([start, end]) => [start, end]),
2908
3084
  );
2909
- const exactRanges = buildStrictQueryRanges(exactSegments, template.range);
2910
- const exactChanges: ReplicationChange<ReplicationRangeIndexable<R>>[] =
2911
- exactRanges.map((range) => ({
2912
- range,
2913
- type: "replaced",
2914
- timestamp: template.timestamp,
2915
- }));
2916
- const geometryBatches: ReplicationChange<ReplicationRangeIndexable<R>>[][] =
2917
- [];
2918
- for (
2919
- let offset = 0;
2920
- offset < exactChanges.length;
2921
- offset += MAX_REBALANCE_QUERY_RANGES
2922
- ) {
2923
- geometryBatches.push(
2924
- exactChanges.slice(offset, offset + MAX_REBALANCE_QUERY_RANGES),
2925
- );
2926
- }
3085
+ const geometryRanges = buildStrictQueryRangeDTOs(
3086
+ exactSegments,
3087
+ template.range,
3088
+ );
2927
3089
 
2928
3090
  // A row can contain coordinates in several query batches. Assign every exact
2929
3091
  // linear interval to its batch up front so candidates can be de-duplicated with
2930
3092
  // O(component-count) plan memory instead of an unbounded set of emitted hashes.
2931
3093
  // The two linear pieces of a wrapped seam range inherit the same batch tag.
2932
- const ownedIntervals = exactRanges
3094
+ const ownedIntervals = geometryRanges
2933
3095
  .flatMap((range, rangeIndex) =>
2934
3096
  toSegmentsBigInt(range)
2935
3097
  .filter(([start, end]) => start < end)
2936
3098
  .map(([start, end]) => ({
2937
3099
  start,
2938
3100
  end,
2939
- batch: Math.floor(rangeIndex / MAX_REBALANCE_QUERY_RANGES),
3101
+ geometryTask: Math.floor(rangeIndex / MAX_REBALANCE_QUERY_RANGES),
2940
3102
  })),
2941
3103
  )
2942
3104
  .sort((a, b) =>
@@ -2956,12 +3118,60 @@ const createRebalanceQueryPlan = <R extends NumericType>(
2956
3118
  }
2957
3119
  }
2958
3120
 
2959
- return { boundaryChanges, geometryBatches, ownedIntervals };
3121
+ return {
3122
+ boundary,
3123
+ geometryRanges,
3124
+ ownedIntervals,
3125
+ taskCount:
3126
+ (boundary ? 1 : 0) +
3127
+ Math.ceil(geometryRanges.length / MAX_REBALANCE_QUERY_RANGES),
3128
+ };
2960
3129
  };
2961
3130
 
2962
- const findOwningRebalanceBatch = (
2963
- coordinates: Array<number | bigint>,
2964
- intervals: RebalanceOwnedInterval[],
3131
+ /**
3132
+ * Bounds control-plane query construction only. A task can still match an
3133
+ * arbitrary number of local rows, so executors must apply their own durable
3134
+ * row/byte/time budgets while scanning it.
3135
+ */
3136
+ export const getRebalanceScanTaskPage = <R extends NumericType>(
3137
+ plan: RebalanceScanPlan<R>,
3138
+ cursor?: RebalanceTaskCursor,
3139
+ ): RebalanceTaskPage<R> => {
3140
+ const start = cursor?.nextTask ?? 0;
3141
+ if (!Number.isSafeInteger(start) || start < 0 || start > plan.taskCount) {
3142
+ throw new Error("Invalid rebalance task cursor");
3143
+ }
3144
+
3145
+ const end = Math.min(start + MAX_REBALANCE_TASKS_PER_PAGE, plan.taskCount);
3146
+ const boundaryOffset = plan.boundary ? 1 : 0;
3147
+ const tasks: RebalanceScanTask<R>[] = [];
3148
+ for (let ordinal = start; ordinal < end; ordinal++) {
3149
+ if (plan.boundary && ordinal === 0) {
3150
+ tasks.push({ kind: "boundary", ordinal });
3151
+ continue;
3152
+ }
3153
+ const geometryTask = ordinal - boundaryOffset;
3154
+ const rangeOffset = geometryTask * MAX_REBALANCE_QUERY_RANGES;
3155
+ tasks.push({
3156
+ kind: "geometry",
3157
+ ordinal,
3158
+ geometryTask,
3159
+ ranges: plan.geometryRanges.slice(
3160
+ rangeOffset,
3161
+ rangeOffset + MAX_REBALANCE_QUERY_RANGES,
3162
+ ),
3163
+ });
3164
+ }
3165
+
3166
+ return {
3167
+ tasks,
3168
+ next: end < plan.taskCount ? { nextTask: end } : undefined,
3169
+ };
3170
+ };
3171
+
3172
+ export const findOwningRebalanceTask = (
3173
+ coordinates: readonly (number | bigint)[],
3174
+ intervals: readonly RebalanceOwnedInterval[],
2965
3175
  ): number | undefined => {
2966
3176
  let minimum: number | undefined;
2967
3177
  for (const value of coordinates) {
@@ -2977,7 +3187,9 @@ const findOwningRebalanceBatch = (
2977
3187
  low = middle + 1;
2978
3188
  } else {
2979
3189
  minimum =
2980
- minimum == null ? interval.batch : Math.min(minimum, interval.batch);
3190
+ minimum == null
3191
+ ? interval.geometryTask
3192
+ : Math.min(minimum, interval.geometryTask);
2981
3193
  break;
2982
3194
  }
2983
3195
  }
@@ -2989,7 +3201,7 @@ const findOwningRebalanceBatch = (
2989
3201
  };
2990
3202
 
2991
3203
  const rotateRebalanceHistory = <R extends NumericType>(
2992
- changes: ReplicationChange<ReplicationRangeIndexable<R>>[],
3204
+ changes: readonly ReplicationChange<ReplicationRangeIndexable<R>>[],
2993
3205
  rebalanceHistory: Cache<string>,
2994
3206
  ) => {
2995
3207
  // Only original source changes may enter history, and observed order defines
@@ -3003,12 +3215,24 @@ const rotateRebalanceHistory = <R extends NumericType>(
3003
3215
  }
3004
3216
  };
3005
3217
 
3006
- export const mergeReplicationChanges = <R extends NumericType>(
3218
+ const snapshotRebalanceHistory = (
3219
+ rebalanceHistory: Cache<string>,
3220
+ ): ReadonlySet<string> => {
3221
+ // Expiration is a cache concern, not a planner concern. Resolve it once and
3222
+ // give the pure planner a stable view for the complete plan.
3223
+ rebalanceHistory.trim();
3224
+ return new Set(
3225
+ [...rebalanceHistory.map.keys()].filter(
3226
+ (key): key is string => typeof key === "string",
3227
+ ),
3228
+ );
3229
+ };
3230
+
3231
+ const mergeReplicationChangesFromHistory = <R extends NumericType>(
3007
3232
  changesOrChangesArr:
3008
3233
  | ReplicationChanges<ReplicationRangeIndexable<R>>
3009
3234
  | ReplicationChanges<ReplicationRangeIndexable<R>>[],
3010
- rebalanceHistory: Cache<string>,
3011
- options?: { updateHistory?: boolean },
3235
+ rebalanceHistory: ReadonlySet<string>,
3012
3236
  ): ReplicationChange<ReplicationRangeIndexable<R>>[] => {
3013
3237
  // Work from an immutable snapshot. In particular, synthetic difference ranges
3014
3238
  // must never become input events or rebalance-history entries.
@@ -3214,10 +3438,62 @@ export const mergeReplicationChanges = <R extends NumericType>(
3214
3438
  rebalanceBoundaryOnly: true,
3215
3439
  });
3216
3440
  }
3441
+ return deduplicated;
3442
+ };
3443
+
3444
+ export const mergeReplicationChanges = <R extends NumericType>(
3445
+ changesOrChangesArr:
3446
+ | ReplicationChanges<ReplicationRangeIndexable<R>>
3447
+ | ReplicationChanges<ReplicationRangeIndexable<R>>[],
3448
+ rebalanceHistory: Cache<string>,
3449
+ options?: { updateHistory?: boolean },
3450
+ ): ReplicationChange<ReplicationRangeIndexable<R>>[] => {
3451
+ const changes = flattenReplicationChanges(changesOrChangesArr);
3452
+ const merged = mergeReplicationChangesFromHistory(
3453
+ changes,
3454
+ snapshotRebalanceHistory(rebalanceHistory),
3455
+ );
3217
3456
  if (options?.updateHistory !== false) {
3218
3457
  rotateRebalanceHistory(changes, rebalanceHistory);
3219
3458
  }
3220
- return deduplicated;
3459
+ return merged;
3460
+ };
3461
+
3462
+ export const createRebalanceScanPlan = <R extends NumericType>(input: {
3463
+ changes: readonly ReplicationChange<ReplicationRangeIndexable<R>>[];
3464
+ history: ReadonlySet<string>;
3465
+ forceFresh?: boolean;
3466
+ }): RebalanceScanPlan<R> => {
3467
+ const sourceChanges = [...input.changes];
3468
+ const queryChanges = input.forceFresh
3469
+ ? sourceChanges
3470
+ : mergeReplicationChangesFromHistory(sourceChanges, input.history);
3471
+ const geometry = createRebalanceQueryGeometryPlan(
3472
+ queryChanges,
3473
+ sourceChanges.length === 0,
3474
+ );
3475
+ return {
3476
+ ...geometry,
3477
+ historyMutations: input.forceFresh
3478
+ ? []
3479
+ : sourceChanges.map((change) => ({
3480
+ rangeHash: change.range.rangeHash,
3481
+ present: change.type === "added",
3482
+ })),
3483
+ };
3484
+ };
3485
+
3486
+ const applyRebalanceHistoryMutations = (
3487
+ mutations: readonly RebalanceHistoryMutation[],
3488
+ rebalanceHistory: Cache<string>,
3489
+ ) => {
3490
+ for (const mutation of mutations) {
3491
+ if (mutation.present) {
3492
+ rebalanceHistory.add(mutation.rangeHash);
3493
+ } else {
3494
+ rebalanceHistory.del(mutation.rangeHash);
3495
+ }
3496
+ }
3221
3497
  };
3222
3498
 
3223
3499
  const REBALANCE_ITERATOR_BATCH_SIZE = 1048;
@@ -3231,85 +3507,80 @@ export const toRebalance = <R extends "u32" | "u64">(
3231
3507
  options?: { forceFresh?: boolean },
3232
3508
  ): AsyncIterable<EntryReplicated<R>> => {
3233
3509
  const sourceChanges = flattenReplicationChanges(changeOrChanges);
3234
- const queryChanges = options?.forceFresh
3235
- ? sourceChanges
3236
- : mergeReplicationChanges(sourceChanges, rebalanceHistory, {
3237
- updateHistory: false,
3238
- });
3239
- const plan = createRebalanceQueryPlan(
3240
- queryChanges,
3241
- sourceChanges.length === 0,
3242
- );
3510
+ const plan = createRebalanceScanPlan({
3511
+ changes: sourceChanges,
3512
+ history: options?.forceFresh
3513
+ ? new Set<string>()
3514
+ : snapshotRebalanceHistory(rebalanceHistory),
3515
+ forceFresh: options?.forceFresh,
3516
+ });
3243
3517
  return {
3244
3518
  [Symbol.asyncIterator]: async function* () {
3245
3519
  let completed = false;
3246
3520
  try {
3247
- const passes: Array<{
3248
- changes: ReplicationChange<ReplicationRangeIndexable<R>>[];
3249
- geometryBatch?: number;
3250
- }> = [];
3251
- if (plan.boundaryChanges) {
3252
- passes.push({ changes: plan.boundaryChanges });
3253
- }
3254
- for (let batch = 0; batch < plan.geometryBatches.length; batch++) {
3255
- passes.push({
3256
- changes: plan.geometryBatches[batch],
3257
- geometryBatch: batch,
3258
- });
3259
- }
3260
-
3261
- for (const pass of passes) {
3262
- const isGeometry = pass.geometryBatch != null;
3263
- const iterator = index.iterate({
3264
- query: createAssignedRangesQuery(pass.changes, {
3265
- strict: isGeometry,
3266
- includeBoundaryWhenEmpty: !isGeometry,
3267
- }),
3268
- });
3269
- let passCompleted = false;
3270
- try {
3271
- while (iterator.done() !== true) {
3272
- const entries = await iterator.next(
3273
- REBALANCE_ITERATOR_BATCH_SIZE,
3274
- );
3275
- if (entries.length === 0) {
3276
- break;
3277
- }
3278
- for (const entry of entries) {
3279
- if (isGeometry) {
3280
- if (
3281
- plan.boundaryChanges &&
3282
- entry.value.assignedToRangeBoundary
3283
- ) {
3284
- continue;
3285
- }
3286
- if (
3287
- findOwningRebalanceBatch(
3288
- entry.value.coordinates,
3289
- plan.ownedIntervals,
3290
- ) !== pass.geometryBatch
3291
- ) {
3292
- continue;
3521
+ let cursor: RebalanceTaskCursor | undefined;
3522
+ for (;;) {
3523
+ const page = getRebalanceScanTaskPage(plan, cursor);
3524
+ for (const task of page.tasks) {
3525
+ const isGeometry = task.kind === "geometry";
3526
+ const iterator = index.iterate({
3527
+ query: createAssignedRangesQuery(
3528
+ isGeometry ? task.ranges.map((range) => ({ range })) : [],
3529
+ {
3530
+ strict: isGeometry,
3531
+ includeBoundaryWhenEmpty: !isGeometry,
3532
+ },
3533
+ ),
3534
+ });
3535
+ let taskCompleted = false;
3536
+ try {
3537
+ while (iterator.done() !== true) {
3538
+ const entries = await iterator.next(
3539
+ REBALANCE_ITERATOR_BATCH_SIZE,
3540
+ );
3541
+ if (entries.length === 0) {
3542
+ break;
3543
+ }
3544
+ for (const entry of entries) {
3545
+ if (isGeometry) {
3546
+ if (plan.boundary && entry.value.assignedToRangeBoundary) {
3547
+ continue;
3548
+ }
3549
+ if (
3550
+ findOwningRebalanceTask(
3551
+ entry.value.coordinates,
3552
+ plan.ownedIntervals,
3553
+ ) !== task.geometryTask
3554
+ ) {
3555
+ continue;
3556
+ }
3293
3557
  }
3558
+ yield entry.value;
3294
3559
  }
3295
- yield entry.value;
3296
3560
  }
3561
+ taskCompleted = iterator.done() === true;
3562
+ } finally {
3563
+ await iterator.close();
3564
+ }
3565
+ if (!taskCompleted) {
3566
+ return;
3297
3567
  }
3298
- passCompleted = iterator.done() === true;
3299
- } finally {
3300
- await iterator.close();
3301
3568
  }
3302
- if (!passCompleted) {
3303
- return;
3569
+ if (!page.next) {
3570
+ break;
3304
3571
  }
3572
+ cursor = page.next;
3305
3573
  }
3306
3574
  completed = true;
3307
3575
  } finally {
3308
3576
  // An early consumer return, iterator error, incomplete empty batch, or
3309
3577
  // close failure in any sequential pass must leave history untouched so the
3310
3578
  // next event retries the complete query. forceFresh preserves its bypass.
3311
- if (completed && !options?.forceFresh) {
3312
- rotateRebalanceHistory(sourceChanges, rebalanceHistory);
3579
+ if (completed) {
3580
+ applyRebalanceHistoryMutations(
3581
+ plan.historyMutations,
3582
+ rebalanceHistory,
3583
+ );
3313
3584
  }
3314
3585
  }
3315
3586
  },