Skip to content

merge-tree: split reset arms + previousProps accessor backed by WeakMap#27414

Draft
anthony-murphy wants to merge 7 commits into
microsoft:mainfrom
anthony-murphy:prep-merge-tree
Draft

merge-tree: split reset arms + previousProps accessor backed by WeakMap#27414
anthony-murphy wants to merge 7 commits into
microsoft:mainfrom
anthony-murphy:prep-merge-tree

Conversation

@anthony-murphy
Copy link
Copy Markdown
Contributor

@anthony-murphy anthony-murphy commented May 27, 2026

Description

Bundles three merge-tree structural changes:

  1. Extract resetAnnotateOp / resetInsertOp helpers from the long switch in Client.resetPendingDeltaToOps (client.ts). Pure code motion: the ANNOTATE and INSERT arms become one-line calls to the new private helpers, with their original control flow, parameters, and assertions preserved byte-identical.
  2. SegmentGroupCollection.previousPropsForSegment accessor — encapsulates the "what's the previousProps entry for this collection's segment in the given SegmentGroup" lookup.
  3. SegmentGroup.previousProps: WeakMap<ISegmentLeaf, PropertySet> — replaces the array (paired by index with SegmentGroup.segments) with a WeakMap keyed by segment. Removes the fragile "i-th previousProps entry pairs with i-th segments entry" invariant that comments had to call out. The previousPropsForSegment accessor becomes a clean return segmentGroup.previousProps?.get(this.segment); one-liner.

Sites converted for the WeakMap migration:

  • mergeTreeNodes.ts: type declaration.
  • mergeTree.ts:addToPendingList: previousProps = new WeakMap().
  • mergeTree.ts write site: set(segment, propertySet) instead of array push.
  • mergeTree.ts:2479 (rollback ANNOTATE): previousProps!.get(segment) instead of previousProps![i]. The i counter is removed.
  • client.ts reissue path: the original .slice(0) was a defensive copy of the props array, but the surrounding loop only ever wrote a single segment to the new group, so the array shape was already mispaired post-copy (latent inconsistency). Replaced with a new WeakMap containing only the entry for the current segment.
  • segmentGroupCollection.ts:enqueueOnCopy: drops the indexOf(sourceSegment); maps source's WeakMap value onto destination segment in the destination map.

Truthy-presence semantics (e.g. op.type === ANNOTATE && !pendingSegmentGroup.previousProps) work unchanged — a WeakMap is truthy.

Why

Client.resetPendingDeltaToOps is ~360 lines with a 4-way switch and triple-nested ternaries in the ANNOTATE arm. Extracting the arms makes future per-arm changes localized to small focused helpers.

The implicit "i-th entry pairs with i-th segment" invariant in previousProps was a known source of bugs and forced comment annotations. Keying on the segment directly via WeakMap makes the pairing inherent in the type. As a bonus, two array indexOf calls in segmentGroupCollection.ts are now O(1).

Notes

previousProps is purely internal merge-tree bookkeeping (not on the wire, not public API). Pure structural prep — no behavior change, no squash logic, no tests touched, no api-report changes.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 27, 2026

Hi! Thank you for opening this PR. Want me to review it?

Based on the diff (253 lines, 5 files), I've queued these reviewers:

  • Correctness — logic errors, race conditions, lifecycle issues
  • Security — vulnerabilities, secret exposure, injection
  • API Compatibility — breaking changes, release tags, type design
  • Performance — algorithmic regressions, memory leaks
  • Testing — coverage gaps, hollow tests

How this works

  • Adjust the reviewer set by ticking/unticking boxes above. Reviewer toggles alone don't trigger anything.

  • Tick Start review below to dispatch the review fleet.

  • After review finishes, tick Start review again to request another run — it auto-resets after each dispatch.

  • This comment updates as new commits land; your reviewer selections are preserved.

  • Start review

if (index === -1) {
return undefined;
}
return segmentGroup.previousProps[index];
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep Review: previousPropsForSegment adds three observable branches — previousProps === undefinedundefined, indexOf === -1undefined, and the happy path returning segmentGroup.previousProps[index] — but the PR touches only production code; there's no segmentGroupCollection.spec.ts hunk and no other test exercises the new method directly.

The silent-undefined-over-assert shape is a deliberate design choice (consistent with SegmentGroup being intentionally internal per PR #22696 / PR #23401), but right now only the docstring locks it in. A future drift toward an assert-on-miss variant would go uncaught until a downstream caller broke, and the upcoming squash PR is about to start depending on this contract.

A ~5-line direct test covering the three branches — previousProps === undefinedundefined, segment not in group → undefined, happy-path positional pairing returns segmentGroup.previousProps[index] for the correct index — closes the gap before the first migrated caller lands. Either in this PR or paired with the first caller migration in the squash PR.

@anthony-murphy anthony-murphy changed the title merge-tree: split resetPendingDeltaToOps switch arms + add previousProps accessor merge-tree: split reset arms + previousProps accessor backed by WeakMap May 27, 2026
*/
public previousPropsForSegment(segmentGroup: SegmentGroup): PropertySet | undefined {
return segmentGroup.previousProps?.get(this.segment);
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep Review: The PR description states the accessor "encapsulates the … lookup" and that callers become a clean one-liner, but the two natural call sites that landed in the same PR continue to access previousProps directly — mergeTree.ts:2479 uses pendingSegmentGroup.previousProps!.get(segment) and client.ts:~1286 uses segmentGroup.previousProps.get(segment). grep previousPropsForSegment across packages/dds/merge-tree returns only this declaration; the encapsulation goal isn't realized in this commit.

Blast radius is narrow — index.ts does not re-export SegmentGroupCollection, so this isn't package-public surface — but the description-vs-diff inconsistency is real and PR #11961's review precedent was to keep copy-only previousProps plumbing private until a consumer exists.

Either (a) migrate mergeTree.ts:2479 and client.ts:~1286 to segment.segmentGroups.previousPropsForSegment(segmentGroup) so the encapsulation lands here, or (b) drop the accessor until the consumer lands in the squash PR.

assert(
props !== undefined,
"Segment missing previousProps entry on annotate rollback",
);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep Review: This new assert uses a raw string, but every other assert(...) in mergeTree.ts uses a numbered hex tag (0x036, 0x037, 0x0430x04f, 0x751, 0x86c, 0xa40, 0xa6e, 0xb5c, 0xb5d, 0xb6e0xb73, 0xbaf — 16+ examples). This very PR preserves 0x036, 0x037, 0xbaf, 0xb5c byte-identical in the extracted client.ts helpers, so the convention is intentional.

The repo ships policy-check:asserts (flub generate assertTags) that rewrites raw strings to hex tags, so this either gets rewritten before merge or trips the policy gate. Tags are how assertionShortCodesMap maps production failures back to messages without shipping the strings.

Run npm run policy-check:asserts before merge, or tag manually now:

Suggested change
);
const props = pendingSegmentGroup.previousProps!.get(segment);
assert(
props !== undefined,
0xXXXX /* Segment missing previousProps entry on annotate rollback */,
);

if (sourceProps !== undefined) {
newPreviousProps.set(segment, sourceProps);
}
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep Review: This hunk replaces previousProps: segmentGroup.previousProps?.slice(0) with a freshly constructed WeakMap carrying only the current segment's entry — the PR description identifies the old .slice(0) as a latent inconsistency (defensive copy of the props array, but the surrounding loop only ever wrote a single segment to the new group, so the array shape was already mispaired post-copy). The PR ships with no test changes.

Closest existing coverage (resetPendingSegmentsToOp.spec.ts:189-211, client.rollback.spec.ts:206-245 and :658-688) doesn't inspect per-segment previousProps entries. Prior bugs in this exact reset/rebase path were fuzz-discovered, not unit-discovered (#11946, #22069), so existing unit tests are an unreliable safety net for semantic regressions here.

Add a focused regression in resetPendingSegmentsToOp.spec.ts (or client.rollback.spec.ts) that constructs a pending annotate group with multiple segments, triggers resetPendingDeltaToOps reissue, and asserts each new per-segment SegmentGroup has a previousProps WeakMap containing exactly the entry for its own segment.

@anthony-murphy
Copy link
Copy Markdown
Contributor Author

Deep Review: The missing-tests concern is resolved — this commit adds three direct cases for previousPropsForSegment in test/segmentGroupCollection.spec.ts (the undefined / not-in-group / happy-path branches called out above). Remaining accessor concern — that the two production lookup sites at mergeTree.ts:2479 and client.ts:~1286 still bypass the accessor — is tracked separately in the inline thread on segmentGroupCollection.ts:58.

const sourceProps = segmentGroup.previousProps.get(sourceSegment);
if (sourceProps !== undefined) {
segmentGroup.previousProps.set(this.segment, sourceProps);
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deep Review: enqueueOnCopy is the structural successor to PR #11961 (scarlettjlee, 2022-09-15), which fixed the exact split-after-annotate rollback crash: "when a segment is split after an annotation, there was no corresponding property set for the new segment, causing rollback to crash." The rewritten copy path — segmentGroup.previousProps.get(sourceSegment)segmentGroup.previousProps.set(this.segment, sourceProps) — is the load-bearing replacement for the prior indexOf + parallel-array push logic.

The new .copyTo spec in test/segmentGroupCollection.spec.ts only enqueues groups with previousProps === undefined (segmentGroups.enqueue({ segments: [], localSeq: 1, refSeq: 0 })). Three new tests were added for the trivial previousPropsForSegment read accessor; zero for the nontrivial copy logic. Prior bugs on this path (#11946, #22069, #24253) were fuzz-discovered, not unit-discovered — existing unit tests aren't a reliable safety net for a silent regression on the split-then-rollback case.

Distinct from the reissue-path concern on client.ts:1293 — that thread covers resetPendingDeltaToOps; this is the split-via-copyTo path.

Add a .copyTo (or sibling) case here that constructs a source SegmentGroup with a populated previousProps WeakMap entry for the source segment, runs the enqueue-on-copy path, and asserts the destination's SegmentGroup.previousProps.get(destSegment) === sourceProps. Ideally also exercise MergeTree.rollback ANNOTATE end-to-end against the split case PR #11961 originally regressed on.

@anthony-murphy
Copy link
Copy Markdown
Contributor Author

Deep Review

Reviewed commit 3138872 on 2026-06-02.

Readiness: 6/10 — MAKING PROGRESS

Structural-prep refactor (helper extraction + WeakMap migration of previousProps) remains mechanically sound on this SHA. Score is unchanged from the prior review because no new commits since c976b85 addressed the three live inline threads: untagged rollback assert (mergeTree.ts:2479), previousPropsForSegment accessor that no production call site uses, and missing regression coverage on the two behavior-bearing migration hunks (reissue client.ts:1293 and enqueueOnCopy split-path segmentGroupCollection.ts:67). These three cluster around the same migration surface and need to land together — routing mergeTree.ts:2479 and client.ts:~1286 through segmentGroups.previousPropsForSegment(...) simultaneously closes the encapsulation gap and deletes the untagged assert.

Path to Ready

  • Resolve inline threads

Context for Reviewers

For human reviewer
  • Re-audit the four control-flow exits in resetAnnotateOp / resetInsertOp: ANNOTATE !isRemovedAndAckedcreateAdjustRangeOp / createAnnotateRangeOp vs undefined; INSERT isSquashedOpundefined; INSERT squash+remove → undefined; INSERT remote-obliterate → undefined; INSERT fall-through → createInsertSegmentOp. Equivalence already confirmed mechanically; fresh eyes in the most bug-prone area of this package are cheap insurance.
  • Confirm staging-mode squash PR scope — whether the cross-file callsite migrations land soon enough that previousPropsForSegment doesn't sit as unused internal surface (PR MergeTree: Remove SegmentGroup from internal #23401 trimmed SegmentGroup from the internal API specifically to avoid that). Owner-level scope call; affects disposition of the accessor-encapsulation inline thread.
  • Cross-fork / fuzz determinism — the merge-tree fuzz suite has historically been the safety net for this code path (PR Normalize merge-tree's segment order on rebase of an insert op #11946, MergeTree: Fix annotate rebase properties #22069, feat(merge-tree): Support resubmission of obliterates #24253). Confirm it still passes on this branch. Cannot be assessed by the pipeline.
Review history (4 prior reviews)
  • c976b85 2026-05-27 · 6/10 — load-bearing enqueueOnCopy rewrite lacks a populated-previousProps test; three Tier 3 inline items still live
  • 7ac273f 2026-05-27 · 7/10 — three polish items live inline; PR Notes "no behavior change" contradicts description bullet 3
  • 0c08285 2026-05-27 · 8/10 — pure structural-prep refactor; three inline polish items (accessor encapsulation, untagged assert, missing reissue regression test)
  • 8d34c55 2026-05-27 · 9/10 — pure-refactor prep; one inline polish item on missing accessor test

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant