Skip to content

fix(container-runtime): prevent invalid handle reuse after parent skip recursion#27659

Draft
jzaffiro wants to merge 1 commit into
microsoft:mainfrom
jzaffiro:jzaffiro-investigate-fluid-element-not-found
Draft

fix(container-runtime): prevent invalid handle reuse after parent skip recursion#27659
jzaffiro wants to merge 1 commit into
microsoft:mainfrom
jzaffiro:jzaffiro-investigate-fluid-element-not-found

Conversation

@jzaffiro

@jzaffiro jzaffiro commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes a summarizer bug that caused ODSP 404 fluidElementNotFound errors during incremental summarization, most visibly for containers using SharedTree as the root DDS:

ODSP fetch error [404] ... fluidElementNotFound
Cannot locate node with path '.app/.channels/<dataStore>/.channels/root' under '<snapshotId>'

Root cause

When a SummarizerNode emits a SummaryType.Handle (skips recursion), its subtree's content is not directly stored in the resulting summary version — the child paths are only reachable transitively through the parent's handle reference. However, completeSummaryCore was still promoting each descendant's _lastSummaryReferenceSequenceNumber (via refreshLatestSummaryFromPending) as if their content had been written.

On a later summary, if the parent became dirty and re-summarized while a descendant had not changed, the descendant would emit a handle pointing back to the version where its content only lived behind the parent's handle. The ODSP snapshot upload path (odspSummaryUploadManager.writeSummaryTree) then constructed an id like ${parentHandle}/${rootNodeName}${handlePath} that the server cannot resolve, because it does not follow nested handle references, yielding fluidElementNotFound.

Why this is disproportionately visible on SharedTree

SharedTree's ForestIncrementalSummaryBuilder emits many nested chunk-level handles inside the DDS itself — a pattern unique among current DDSes. Workloads with large trees and frequent GC state churn on the containing data store made the bad handle emission a near-certainty. Other DDSes only reuse handles at the DDS-root boundary and largely dodge the bug.

Fix

In SummarizerNode.completeSummaryCore, when parentSkipRecursion is true, clear _lastSummaryReferenceSequenceNumber on the entire descendant subtree (not just newly-created nodes, which was the prior guard). This forces those descendants to emit a full SummaryType.Tree the next time they are summarized instead of an unresolvable handle. Once they've been directly summarized and acked, normal handle reuse resumes.

The clearing also indirectly disables SharedTree's chunk-level incremental handles for that next summary, since summarizerNode.ts passes incrementalSummaryContext: undefined when there is no tracked reference sequence number.

Cost

Bounded and rare: it only triggers on the summary immediately after a parent used a handle and then became dirty. Cost is one extra full serialization of the affected subtree, one time per cycle. The prior behavior produced broken summaries and forced full retries, which is strictly worse.

Repro / verification

Two regression tests were added in summarizerNode.spec.ts under Handle Reuse After Parent Skip Recursion:

  1. After a parent emits a handle and later becomes dirty, an unchanged descendant must emit a full tree (not a handle).
  2. After that forced re-summarization is acked, normal handle reuse resumes on the following summary.

All 998 container-runtime tests pass locally. Linting and formatting are clean.

Reviewer Guidance

The review process is outlined on this wiki page.

  • Please sanity-check the interaction with SummarizerNodeWithGc.completeSummaryCore. With the fix, when the parent skips recursion, super.completeSummaryCore does not add the descendant to pendingSummaries; the GC subclass's lookup then gracefully no-ops, and the 15 GC-focused tests still pass.
  • I considered narrowing the fix to only clear descendants that had actually been loaded from a previous summary (rather than "all descendants"), but the extra bookkeeping did not seem worth it given how rarely this path fires and how surgical the fallout is (one extra tree serialization).

…p recursion

When a parent summarizer node emits a handle (skips recursion), its
children's content is not directly stored in the new summary version -
only transitively accessible via the parent's handle chain. Previously,
children would still have their _lastSummaryReferenceSequenceNumber
updated, allowing them to emit handles in subsequent summaries. This
caused fluidElementNotFound (404) errors from the ODSP storage service
because it cannot resolve nested paths through handle references.

The fix clears _lastSummaryReferenceSequenceNumber for all descendants
when parentSkipRecursion is true, forcing them to fully re-summarize
the next time their content is needed directly in a summary.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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

Based on the diff (131 lines, 3 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

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Bundle size comparison

Base commit: 255d4505ed574676735c6f2078199e5b29dede2a
Head commit: 70124eadff3424b36700feb2aa29ee24ab5adad7

Notable changes

  • 🔴 azureClient.js: parsed 618613 → 619218 (+605), gzip 164734 → 164888 (+154)
  • 🔴 fluidFramework.js: parsed 392149 → 392845 (+696), gzip 111130 → 111540 (+410)
  • 🔴 sharedTree.js: parsed 381536 → 382225 (+689), gzip 108525 → 108924 (+399)
  • 594.js: removed (was parsed 44493, gzip 13744)
  • 985.js: added (parsed 44491, gzip 13726)
Per-bundle deltas

@fluid-example/bundle-size-tests

  • 🔴 azureClient.js: parsed 618613 → 619218 (+605), gzip 164734 → 164888 (+154)
  • odspClient.js: parsed 591845 → 591944 (+99), gzip 158885 → 158961 (+76)
  • aqueduct.js: parsed 525463 → 525686 (+223), gzip 140683 → 140751 (+68)
  • 🔴 fluidFramework.js: parsed 392149 → 392845 (+696), gzip 111130 → 111540 (+410)
  • 🔴 sharedTree.js: parsed 381536 → 382225 (+689), gzip 108525 → 108924 (+399)
  • containerRuntime.js: parsed 303813 → 304015 (+202), gzip 83188 → 83236 (+48)
  • sharedString.js: parsed 175984 → 175991 (+7), gzip 49445 → 49453 (+8)
  • experimentalSharedTree.js: parsed 160798 → 160798 (0), gzip 45804 → 45804 (0)
  • matrix.js: parsed 159845 → 159852 (+7), gzip 45411 → 45418 (+7)
  • loader.js: parsed 145307 → 145270 (-37), gzip 39063 → 39076 (+13)
  • odspDriver.js: parsed 104423 → 104350 (-73), gzip 32644 → 32656 (+12)
  • directory.js: parsed 66616 → 66623 (+7), gzip 18532 → 18540 (+8)
  • 748.js: parsed 58793 → 58793 (0), gzip 17826 → 17827 (+1)
  • map.js: parsed 46709 → 46716 (+7), gzip 14310 → 14318 (+8)
  • odspPrefetchSnapshot.js: parsed 45642 → 45656 (+14), gzip 15268 → 15285 (+17)
  • 594.js: removed (was parsed 44493, gzip 13744)
  • summarizerDelayLoadedModule.js: parsed 30753 → 30749 (-4), gzip 7767 → 7753 (-14)
  • socketModule.js: parsed 26486 → 26483 (-3), gzip 7879 → 7893 (+14)
  • createNewModule.js: parsed 12480 → 12480 (0), gzip 4786 → 4786 (0)
  • summaryModule.js: parsed 3797 → 3797 (0), gzip 1860 → 1860 (0)
  • connectionState.js: parsed 724 → 724 (0), gzip 429 → 429 (0)
  • sharedTreeAttributes.js: parsed 666 → 673 (+7), gzip 431 → 442 (+11)
  • debugAssert.js: parsed 429 → 429 (0), gzip 299 → 299 (0)
  • FluidFramework-HashFallback.js: parsed 422 → 422 (0), gzip 316 → 316 (0)
  • 985.js: added (parsed 44491, gzip 13726)

@agarwal-navin

Copy link
Copy Markdown
Contributor

I don't understand what the bug is. What's described in Root-cause seems like expected behavior to me and that should not be a bug. Then there is the Why this is disproportionately visible on SharedTree which kinds tells me the bug is actually in ForestIncrementalSummaryBuilder? But it isn't clear to me what the bug is there or how it's introduced.
@jzaffiro @markfields FYI

// _lastSummaryReferenceSequenceNumber so that this node does not attempt to emit a
// handle in future summaries pointing to this version, because the storage service
// cannot resolve nested paths through handle references.
this._lastSummaryReferenceSequenceNumber = undefined;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is not right. This breaks incremental summary - basically whenever a parent hasn't changed, in the next summary, all of it's children will re-summarize.

The comment is also incorrect / unclear. Handles are a client / runtime concept - when the service sees handles, it basically gets the summary tree from the previous summary and replaces the handle with it. So, in the next summary, if a child doesn't change and uses handle to point to this summary, the server should find the contents just fine.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants