Remove merge-tree's red-black tree - #27966
Conversation
…kups Client.clientNameToIds and TestServer.upstreamMap used RedBlackTree purely as a key/value dictionary - neither called any ordered operation (floor, ceil, min, max, walk, mapRange). A plain Map provides the same behavior with O(1) lookup instead of O(log n) pointer chasing. Note that getOrAddShortClientId previously tested the returned node for truthiness, which was only correct because a node object is always truthy. The Map equivalent uses has() so that short client id 0 is handled correctly. This is a step toward removing the hand-rolled red-black tree implementation entirely. All types involved are internal-only, so there is no API surface change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
EndpointIndex had no dedicated test file, unlike its siblings EndpointInRangeIndex and StartpointInRangeIndex. Add one covering previousInterval, nextInterval, and removal. The final describe block is skipped because it currently fails. It documents a real defect: EndpointIndex orders intervals with `compareEnd` alone, which is only a partial order, so two distinct intervals sharing an end position and end side compare equal. RedBlackTree treats "compares equal" as "same key", so those intervals collapse onto a single node. Removing either one then evicts both, and an interval that is still live in the collection silently disappears from the index. previousInterval and nextInterval are public IIntervalCollection API and EndpointIndex is constructed for every LocalIntervalCollection, so this is reachable. This is not a regression - the comparator has been end-only since endIntervalTree was introduced in cb52fa0 (2018). PR microsoft#6425 (e7a0353, "Make endIntervalTree preserve nodes with different start positions") fixed precisely this in 2021 by adding a compareStart tie-break, but was closed unmerged with "I'll rethink this after merging microsoft#6407". That is sound reasoning rather than an oversight: microsoft#6407 went on to allow multiple intervals with identical (start, end) and established interval id as the third ordering key, which would have made a compareStart tie-break insufficient. The rethink never happened. Later commits rewrote the comparator (0848dde, and microsoft#17125 which produced the current `(a, b) => a.compareEnd(b)`) without restoring any tie-break. The sibling indexes added in microsoft#16573 do tie-break on interval id, but their comparator cannot simply be copied here. Those indexes only run range queries, and pin their transient probes outside the equal-end group using the `forceCompare` sentinel. EndpointIndex instead probes with createTransientIntervalFromSequence and relies on floor/ceil; that transient is assigned a random uuid, so an id tie-break would order the probe randomly within a group of intervals sharing an end position and make floor/ceil non-deterministic. The fix needs to separate ordering (end-only, for probing) from identity (for insertion and removal). These tests are left skipped rather than asserting the current behavior so that they can simply be enabled by the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
EndpointIndex backed previousInterval/nextInterval with a RedBlackTree keyed
only by end position. That comparator is a partial order: two distinct intervals
ending at the same position compare equal, so the tree treated them as one node.
Adding the second overwrote the first's data (nodePut's equal-key branch assigns
node.data but never node.key, so key and data desynchronized), and removing
either evicted both. A live interval could silently disappear from the index.
Replace the tree with SortedSet, the sorted-array abstraction already used by
partialLengths and sortedSegmentSet, and separate ordering from probing:
- `compare` is a total order on (end, id), so every interval occupies its own
entry and binary search is exact.
- `compareEnds` is end-only and backs custom lowerBound/upperBound, giving
deterministic floor/ceil.
The split is required, not incidental. previousInterval/nextInterval probe with
createTransientIntervalFromSequence, which assigns the transient a random uuid().
Ordering the probe by id would place it arbitrarily within a group of intervals
sharing its end position, making floor/ceil non-deterministic. Storage needs a
total order; probing must not use one.
Ordering on (end, id) also matters for performance. A first attempt kept
`compare` end-only and recovered identity by scanning the run of equal entries in
onFindEquivalent, which is O(k) per operation and quadratic when many intervals
share an end: 8000 duplicates took 203ms to add versus 4.3ms for the total order.
History: this bug dates to the index's introduction in cb52fa0 (2018) and was
never a regression. e7a0353 (PR microsoft#6425, 2021) fixed it with a compareStart
tie-break but was closed unmerged - "I'll rethink this after merging microsoft#6407" -
and microsoft#6407 then made identical (start, end) intervals legal and established id as
the third ordering key, which is precisely why a compareStart tie-break would
have been insufficient. He never returned to it. The sibling indexes gained id
tie-breaks in microsoft#16573; endpointIndex was left behind.
Un-skips the three regression tests added in 0cc078c.
Performance, measured against the built merge-tree lib: at the interval counts
the repo targets - 100 (fuzz maxIntervals) through 2000 (the heaviest config in
intervalCollection.perf.spec.ts) - the full add/query/remove cycle is 5-7x
faster. SortedSet stays ahead through ~30k intervals in a single collection and
crosses over around 40-45k, roughly 20x beyond anything the repo benchmarks.
Queries are faster at every size measured (~3x at 50k); only mutation churn
regresses past the crossover.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
EndpointInRangeIndex and StartpointInRangeIndex were character-for-character identical apart from compareEnd versus compareStart, and both used a RedBlackTree keyed on (endpoint, forceCompare, id). Replace both with SortedSet via a shared SequenceIntervalEndpointSet base, which also absorbs the copy introduced in EndpointIndex by the previous commit. The base owns the (endpoint, id) total order, the equivalence fallback, and the endpoint-only binary searches; subclasses supply only compareEndpoints. This retires the forceCompare sentinel. It existed because a range query needs to land outside the run of intervals sharing a boundary position, which the tree could only express by ordering the transient probes with an out-of-band field poked onto them: -1 to sort before the run at `start`, +1 to sort after the run at `end`. Those are lowerBound and upperBound. Expressed directly against a sorted array they need no probe mutation, no symbol, and no participation in the stored ordering, so intervalIndexUtils.ts sheds forceCompare, HasComparisonOverride and compareOverrideables - it had no other consumers - and the range query becomes a slice between the two bounds. One deliberate behavior detail: the id tie-break is ordinal (`<`) rather than `localeCompare`. The ordering must never report two distinct ids as equal, or intervals collapse onto one entry - the bug fixed in the previous commit - and localeCompare makes no such guarantee, while ordinal comparison does. The two agree on every id shape in use: verified over 200k generated uuids and all decimal ids up to 300, with zero disagreements and zero collisions. Behavior is otherwise unchanged; these indexes already ordered by id and so never had the collapse bug. Covered by the existing 24 tests, including the two brute-force comparisons against a linear-scan reference over random inputs. Net -73 lines. intervalTree.ts is now the only remaining RedBlackTree consumer in sequence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two of the three SequenceIntervalEndpointSet subclasses were byte-identical - EndpointIndex and EndpointInRangeIndex each declared their own private compareEnd subclass - which is only non-obvious because they sat in different files. There are really just two sets: one keyed on end, one keyed on start. Move both next to the base and export them as SequenceIntervalEndSet and SequenceIntervalStartSet, so the duplicate disappears and the set of available orderings is visible in one place. The base is no longer exported; it is an implementation detail now that every subclass lives beside it. Rename intervalIndexUtils.ts to sequenceIntervalEndpointSet.ts. The old name was a junk drawer, and after the previous commit removed forceCompare the file holds exactly one concept. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… set
`OverlappingIntervalsIndex` was backed by `IntervalTree`, an augmented red-black tree from
merge-tree in which every node carried the union of the intervals beneath it so that a query
could skip subtrees which could not overlap it. `SequenceInterval.union` allocates a new
interval and generates a uuid, and rebalancing an insert calls the augmentation up to three
times per rotation, so building the index cost tens of interval allocations per interval added.
Replace it with `SequenceIntervalOverlapSet`: the same ordering, held in a sorted array, with a
segment tree of maximum end positions layered over it to do the pruning the union did. The
segment tree stores references to intervals rather than resolved positions, so - like the
array's ordering itself - it survives text edits and only needs rebuilding when an interval is
added or removed, which is deferred to the next query.
Measured against the previous build, using the layout and query of
intervalCollection.perf.spec.ts:
building a 2000 interval index 155.19 ms -> 2.45 ms (0.02x)
the perf spec's own query 13.1 us -> 5.4 us (0.41x)
queries swept across the document 10.5-12.5 us -> 4.6-6.4 us (0.40-0.55x)
the same plus 5 document-spanning intervals
13.4-20.6 us -> 5.7-9.2 us (0.43-0.48x)
adding and querying alternately 54.7-88.3 us -> 14.3-50.2 us (0.19-0.63x)
This also fixes `gatherIterationResults`: when given both a start and an end position it
compared against the intervals using an ordering that includes interval id, and the range being
searched for is described by a transient interval carrying a random uuid, so it always gathered
nothing. Ids are no longer considered when gathering.
Removing an interval the index does not hold is now a no-op. The index called the tree's
`removeExisting`, which skips the containment check `remove` performs and dereferences the
resulting undefined node, so such a call threw. `IntervalCollection` only ever removes intervals
it has added, so this was not reachable in production.
`map` and `mapUntil` are removed from `OverlappingIntervalsIndex`; they existed only to expose
the tree walk and have no callers.
`IntervalTree` was the last consumer of merge-tree's `RedBlackTree` outside of merge-tree's own
tests.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`simpleTest`, `integerTest1` and `fileTest1` are exported from beastTest.spec.ts but nothing calls them: the suite only runs `firstTest`, `randolicious`, `mergeTreeCheckedTest`, `clientServer` and `findReplacePerf`, none of which touch a RedBlackTree. Removing them also retires `LinearDictionary`, a hand-written array-backed SortedDictionary which existed solely as the oracle `fileTest1` compared the tree against, along with the two property printers and `took`. No test which runs today loses any coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Nothing uses it any more. `Client` and `TestServer` used it as a plain key/value dictionary and now use `Map`; sequence's three interval endpoint indexes use `SortedSet`; and its last consumer, sequence's `IntervalTree`, has been replaced by `SequenceIntervalOverlapSet`. This removes `RedBlackTree` and the twelve supporting types exported alongside it - `RBColor`, `RBNode`, `RBNodeActions`, `IRBAugmentation`, `IRBMatcher`, `KeyComparer`, `Property`, `PropertyAction`, `QProperty`, `ConflictAction`, `Dictionary` and `SortedDictionary`. All are `@internal` and none appear in an API report, so this is not a breaking change. `src/collections` held nothing else and is removed with them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Hi! Thank you for opening this PR. Want me to review it? Based on the diff (2522 lines, 20 files), I've queued these reviewers:
How this works
|
🔭 PR Review Fleet ReportNote This report is generated by an experimental AI review fleet and is provided as a beta feature. Findings are a starting point for discussion, not a gate. Use your own judgement. Verdict: 0 Spicy, 2 Pungent, 0 Smelly Findings
|
There was a problem hiding this comment.
Pull request overview
Removes merge-tree’s red-black tree and migrates interval indexing and key/value consumers to simpler structures.
Changes:
- Replaces lookup trees with
Mapand endpoint indexes withSortedSet. - Adds an array-backed segment tree for overlap queries.
- Removes obsolete tree code and adds regression tests and changesets.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
.changeset/olive-cases-repeat.md |
Documents endpoint-index fix. |
.changeset/tidy-melons-grow.md |
Documents bounded-iteration fix. |
packages/dds/merge-tree/src/client.ts |
Uses Map for client IDs. |
packages/dds/merge-tree/src/collections/index.ts |
Removes collection exports. |
packages/dds/merge-tree/src/collections/rbTree.ts |
Deletes red-black tree. |
packages/dds/merge-tree/src/index.ts |
Removes tree API exports. |
packages/dds/merge-tree/src/test/beastTest.spec.ts |
Removes obsolete tree exercises. |
packages/dds/merge-tree/src/test/index.ts |
Removes test tree exports. |
packages/dds/merge-tree/src/test/testServer.ts |
Uses Map for upstream mappings. |
packages/dds/sequence/src/intervalIndex/endpointIndex.ts |
Migrates endpoint lookup. |
packages/dds/sequence/src/intervalIndex/endpointInRangeIndex.ts |
Migrates end-range queries. |
packages/dds/sequence/src/intervalIndex/intervalIndexUtils.ts |
Removes comparison overrides. |
packages/dds/sequence/src/intervalIndex/overlappingIntervalsIndex.ts |
Uses the new overlap set. |
packages/dds/sequence/src/intervalIndex/sequenceIntervalEndpointSet.ts |
Adds shared endpoint sets. |
packages/dds/sequence/src/intervalIndex/sequenceIntervalOverlapSet.ts |
Adds segment-tree overlap indexing. |
packages/dds/sequence/src/intervalIndex/startpointInRangeIndex.ts |
Migrates start-range queries. |
packages/dds/sequence/src/intervalTree.ts |
Deletes interval tree wrapper. |
packages/dds/sequence/src/test/collections.intervalTree.spec.ts |
Removes obsolete tests. |
packages/dds/sequence/src/test/endpointIndex.spec.ts |
Adds endpoint regressions. |
packages/dds/sequence/src/test/overlappingIntervalsIndex.spec.ts |
Adds overlap and iteration tests. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The interval tree this index replaced was keyed by SequenceInterval.compare, which tie-breaks on interval id, so putting the same interval twice landed on one node. The sorted set inserted unconditionally, so a caller which added an interval twice - attachIndex does this if the same index is attached twice - got duplicate query and iteration results. Skip the insert when the set already holds the interval, matching the endpoint sets, whose SortedSet.addOrUpdate has always behaved this way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
On H1 and H2 (array + splice is asymptotically worse than a balanced tree): that was the hypothesis this change was built to test, and it was measured rather than assumed, at the sizes this repo targets (the fuzz suite's H1 — endpoint indexes, full pass (build + floor/ceil probe of every item + remove all): 6.4× faster at N=100, 7.0× at N=200, 4.7× at N=2,000. H2 — interleaved mutation and query is called out as the risk in the description and was benchmarked directly: still 3.8× / 5.2× / 1.6× faster. The rebuild is O(n) with a tiny constant, while the tree re-derives its augmented min/max on every rotation — building 2,000 intervals issued 57,620 The incremental root-to-leaf max-end update H2 suggests is a reasonable follow-up if these indexes ever need to scale into the tens of thousands, but it isn't needed to avoid a regression — the new code is faster at every size this repo targets. Full tables are in the PR description. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (3)
packages/dds/sequence/src/intervalIndex/sequenceIntervalOverlapSet.ts:152
- After the segment tree has been built, removing entries only marks it stale, so
maxEndskeeps leaf references to every removed interval until a later non-empty overlap query rebuilds it. Since this index stays attached to each collection, deleting intervals without querying again retains disposed interval objects indefinitely. Clear the cached array on removal while keeping the stale flag so the next query still rebuilds it.
public remove(interval: BaseSequenceInterval): void {
const index = this.indexOf(interval);
if (index !== undefined) {
this.ordered.splice(index, 1);
this.maxEndsStale = true;
packages/dds/sequence/src/test/overlappingIntervalsIndex.spec.ts:37
- This helper discards interval IDs, so the removal tests with identical endpoints pass even if the implementation removes the wrong interval and returns the removed instance. Include the ID in the rendered value so these assertions distinguish the expected interval from another interval at the same positions.
`[${sharedString.localReferencePositionToPosition(
interval.start,
)}, ${sharedString.localReferencePositionToPosition(interval.end)}]`,
packages/dds/sequence/src/test/overlappingIntervalsIndex.spec.ts:214
- The randomized oracle only compares result counts, so equal numbers of false positives and false negatives still pass even though the test says the result agrees with the brute-force scan. Compare the returned interval IDs (order-independently) to validate actual membership.
assert.equal(
index.findOverlappingIntervals(start, end).length,
expected.length,
`mismatched overlap count for [${start}, ${end}]`,
);
The existing brute force comparison only ever grew the set, so the segment tree was always rebuilt over an array which had never shrunk. Add a round based fuzz which grows and then randomly shrinks the set, comparing against a brute force scan after every round, so the recursive index math is checked against the lopsided tree shapes that repeated removals produce. Verified the new coverage by mutation: changing the split point in gatherOverlapping fails the suite, and reverting it passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Second round: M1 (fuzz coverage through removals): agreed, addressed in 3a0528a. The existing brute force comparison only ever grew the set, so the segment tree was always rebuilt over an array that had never shrunk. Added a round based fuzz which grows and then randomly shrinks the set, comparing full result sets (not just counts) against a brute force scan after every round, so the recursive index math is exercised against the lopsided shapes repeated removals produce. Verified the new coverage by mutation: changing the split point in H1 is the same performance finding as the previous round — already answered above with measurements. Short version: measured 4.7–7× faster at the sizes this repo targets, crossover around 40,000 intervals. Also worth noting the bundle size check landed and confirms the secondary benefit: |
Neither field needs to be visible to a subclass: nothing extends OverlappingIntervalsIndex, the class is absent from the API reports, and it is not re-exported from the package root - only createOverlappingIntervalsIndex and ISequenceOverlappingIntervalsIndex are. intervalSet was protected only because the interval tree it replaced was, and sequence has been protected since it was introduced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/dds/sequence/src/test/overlappingIntervalsIndex.spec.ts:38
- This formatter drops interval identity, so the removal test with two
[10, 20]intervals still passes ifremovedeletes the retained object and leaves the removed one. Include the interval ID in the rendered value so the test actually verifies per-interval removal semantics.
(interval) =>
`[${sharedString.localReferencePositionToPosition(
interval.start,
)}, ${sharedString.localReferencePositionToPosition(interval.end)}]`,
Bundle size comparisonBase commit: Notable changes
Per-bundle deltas
|
Description
Tracked by AB#80505.
merge-treecarried a hand-written red-black tree (collections/rbTree.ts, plus an augmentedIntervalTreebuilt on it). This PR removes both and migrates every consumer to something simpler, then deletes the files.There were five consumers:
Client.clientNameToIdsMapTestServer.upstreamMapMapEndpointIndex(sequence)SortedSetEndpointInRangeIndex/StartpointInRangeIndex(sequence)SortedSetOverlappingIntervalsIndex(sequence, viaIntervalTree)SequenceIntervalOverlapSet— sorted array + segment treeThe first two were plain key/value lookups that never used the ordering at all. The three interval indexes now share a
SequenceIntervalEndpointSetbase built on merge-tree's existingSortedSet. The overlapping-intervals index needed a real replacement, since it depended on the tree's interval augmentation; that isSequenceIntervalOverlapSet, a sorted array with a segment tree of maximum end positions layered over it for pruning.All of the removed types were
@internaland none appeared in an API report, so there is no public API surface change here.Net: −1,418 / +1,022 across 20 files. Bundle size should improve by a couple of percent on the affected entry points — the automated bundle-size check on this PR will report the exact numbers.
Bugs fixed
EndpointIndex).compareEndalone is only a partial order, andRedBlackTreetreatscompare === 0as the same key, so only one of several intervals ending at the same position was retained. Reachable throughpreviousInterval/nextInterval. NewSortedSetorders by(end, id), which is total. Changeset included.gatherIterationResultswith both a start and an end always returned[]. The probe built bycreateTransientIntervalFromSequencecarries a freshuuid(), and the lookup compared withcompare(), which includes the interval id — so it never matched anything. Reachable through the@legacy @betaiterator API. Changeset included.OverlappingIntervalsIndex.removeof an interval that was never added threwTypeError. It called the tree'sremoveExisting, which skips the containment checkremoveperforms. Not reachable today, sinceIntervalCollectiononly removes what it added, but the new implementation is a no-op as intended.Bug 1 has some history: PR #6425 ("Make endIntervalTree preserve nodes with different start positions", Paul Leathers (@pleath), 2021) fixed it, but was closed unmerged with a note about rethinking it after PR #6407 merged. That PR merged shortly after and the fix was never revisited. It was never a regression — the collapsing behavior dates to the index's introduction in 2018; the later PR just made it reachable in more situations.
Performance
Everything below was measured, not assumed, against the interval counts this repo actually targets (the fuzz suite's
maxIntervalsand the sequence perf spec configs). Numbers are means over repeated passes on a warmed VM.Endpoint indexes — full pass (build + floor/ceil probe of every item + remove all):
maxIntervalsThe array only loses to the tree above ~40,000 intervals, roughly 20× the heaviest size the repo targets.
Range indexes —
findIntervalsWith(End|Start)pointInRange, per query, k = results returned:The old range indexes walked the tree node-by-node and allocated per step; the new ones binary-search once and slice.
Overlapping intervals index — build cost, adding all N to a fresh index:
Building 2,000 intervals in the tree issued 57,620 calls to
union()— re-deriving the augmented min/max on every rotation, which is where ~85% of the build time went.Query, 2,000 intervals, swept across the document, including five document-spanning intervals (the case that defeats a simple prefix-maximum optimization):
Interleaved mutation and query — the risk case, since the overlap set rebuilds its segment tree on the first query after any add or remove:
Still faster, because the tree's insert is expensive enough to outweigh the array's O(n) rebuild.
Dead code removed along the way
OverlappingIntervalsIndex.map/mapUntil—@internal, no callers.beastTest.spec.ts'ssimpleTest,integerTest1,fileTest1, andLinearDictionary— unreachable exercises of the tree (231 lines).Testing
New
endpointIndex.spec.tscovers the endpoint index directly, including the same-end-position case that was broken. Existingmerge-treeandsequencesuites pass (1,568 and 1,694 respectively), andpnpm lintis clean in both packages with no API report drift.