Skip to content

test(client-tree): unimplemented "minimize" post-processor#27633

Open
jason-ha wants to merge 9 commits into
mainfrom
tree/transaction-minimizer-tests
Open

test(client-tree): unimplemented "minimize" post-processor#27633
jason-ha wants to merge 9 commits into
mainfrom
tree/transaction-minimizer-tests

Conversation

@jason-ha

@jason-ha jason-ha commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Creates a "minimize" placeholder for transaction post-process functionality.

Unit tests are added to cover the intended new functionality with unsupported tests disabled. (Since implementation is not expected to change observable result tests for observable result can be enabled with the simple do-nothing implementation that is the placeholder.)

  transaction minimize post-processor
    ✔ can be supplied as a transaction post-processor without error
    ✔ throws when edits are made before and after a schema change
    ✔ preserves the observable result across an async transaction and new content appears in change
    ✔ temporary schema change throws restoring schema
    self-tests - no minimization applicable
      ✔ embeds surviving markers but not transient marker for a purely additive scenario
      ✔ result carries no build when pre-existing content is only rearranged
      ✔ result carries no build when pre-existing content is only removed
      ✔ reflects the order of only-rearranged inserted nodes and keeps every build
    preserves the observable result and new content appears in change
      ✔ keeps inserted nodes
      ✔ nets a create-then-remove to no change
      ✔ keeps only the persisted node when a transient node is also created
      ✔ reflects only the final value of a node replaced within the transaction
      ✔ reflects only the surviving node when inserted content is relocated then removed
      ✔ keeps the surrounding nodes when a node in the middle of an inserted run is removed
      ✔ keeps the surrounding nodes when an inserted node is moved then removed
      ✔ keeps only the trailing node when a moved node and its successor from leading node are removed
      ✔ keeps only the leading node when a moved node and its insertion companion are removed
      ✔ leaves pre-existing content unchanged when a transient node is inserted then removed
      ✔ keeps pre-existing content and the surviving inserted node
      ✔ reflects only the final value of a field set multiple times
      ✔ reflects only the final empty array when only item's value of a field is set and then the item is removed
      ✔ reflects edits made before a schema change
      ✔ reflects edits made after a schema change
    removes extraneous data from the squashed changes (expected to fail until minimize is implemented)
      - drops the build and destroy for a create-then-remove
      - keeps only the persisted node's build when a transient node is also created
      - keeps only the final node's build when a node is replaced
      - keeps only the surviving node's build when inserted content is relocated then removed
      - keeps the surrounding builds when a node in the middle of an inserted run is removed
      - drops the build for an inserted node that is moved then removed
      - keeps only the trailing node's [modified] build when a moved node and its successor from leading node build are removed
      - keeps only the leading node's build when a moved node and its insertion companion are removed
      - carries no build for a transient insert over pre-existing content
      - keeps only the surviving inserted node's build over pre-existing content
      - keeps only the final value's build when a field is set multiple times
      - carries no build when only item's value of a field is set and then the item is removed
      - keeps only edits' surviving builds made before a schema change
      - keeps only edits' surviving builds made after a schema change

Creates a "minimize" placeholder for transaction post-process functionality.

Unit tests are added to cover the intended new functionality with unsupported tests disabled. (Since implementation is not expected to change observable result tests for observable result can be enabled with the simple do-nothing implementation that is the placeholder.)

```text
  transaction minimize post-processor
    ✔ can be supplied as a transaction post-processor without error
    ✔ preserves the observable result across an async transaction
    self-tests - no minimization applicable
      ✔ embeds surviving markers but not transient marker for a purely additive scenario
      ✔ result carries no build when pre-existing content is only rearranged
      ✔ result carries no build when pre-existing content is only removed
      ✔ reflects the order of only-rearranged inserted nodes and keeps every build
    preserves the observable result
      ✔ keeps inserted nodes
      ✔ nets a create-then-remove to no change
      ✔ keeps only the persisted node when a transient node is also created
      ✔ reflects only the final value of a node replaced within the transaction
      ✔ reflects only the surviving node when inserted content is relocated then removed
      ✔ keeps the surrounding nodes when a node in the middle of an inserted run is removed
      ✔ keeps the surrounding nodes when an inserted node is moved then removed
      ✔ keeps only the trailing node when a moved node and its successor from leading node are removed
      ✔ keeps only the leading node when a moved node and its insertion companion are removed
      ✔ leaves pre-existing content unchanged when a transient node is inserted then removed
      ✔ keeps pre-existing content and the surviving inserted node
      ✔ reflects only the final value of a field set multiple times
      ✔ reflects only the final empty array when only item's value of a field is set and then the item is removed
    removes extraneous data from the squashed change (expected to fail until minimize is implemented)
      - drops the build and destroy for a create-then-remove
      - keeps only the persisted node's build when a transient node is also created
      - keeps only the final node's build when a node is replaced
      - keeps only the surviving node's build when inserted content is relocated then removed
      - keeps the surrounding builds when a node in the middle of an inserted run is removed
      - drops the build for an inserted node that is moved then removed
      - keeps only the trailing node's [modified] build when a moved node and its successor from leading node build are removed
      - keeps only the leading node's build when a moved node and its insertion companion are removed
      - carries no build for a transient insert over pre-existing content
      - keeps only the surviving inserted node's build over pre-existing content
      - keeps only the final value's build when a field is set multiple times
      - carries no build when only item's value of a field is set and then the item is removed
```
@jason-ha jason-ha requested a review from a team as a code owner July 1, 2026 21:46
Copilot AI review requested due to automatic review settings July 1, 2026 21:46
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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

Based on the diff (1188 lines, 11 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

Copilot AI left a comment

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.

Copilot reviewed 10 out of 10 changed files in this pull request and generated no comments.

Comment on lines +9 to +10
* "Minimizes" a {@link ModularChangeset} so that it contains no extraneous information, i.e. no data that has no net
* effect on the document.

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.

Suggested change
* "Minimizes" a {@link ModularChangeset} so that it contains no extraneous information, i.e. no data that has no net
* effect on the document.
* "Minimizes" a {@link ModularChangeset} so that it contains no extraneous content,
* i.e. no content that was both created and removed within the same transaction.

Here's my current view on key terms:

  • "Information" could be anything in the changeset (content or not).
  • "Content" is data that could exist in the document.
  • "Data", without further qualifiers, is ambiguous to me: is it like information or is it like content?
  • "Document", without qualifiers, includes all detached content. (I might say "document tree" to indicate just the attached tree.)

So, when read with that dictionary...

  • "no extraneous information" is too strong of a statement. For example, changesets include IDs that link the detach and attach portions of a move. These IDs have zero impact on the document, but we're not going to strip that from the changeset.
  • "no data that has no net effect on the document", if taken to mean "no content that has no net effect on the attached document tree", is also too strong of a statement. For example, we plan to leave moves that affect detached content.
  • "no data that has no net effect on the document", if taken to mean "no content that has no net effect on the whole document, including detached content", is too weak of a statement: it is correct, but it doesn't really describe the impact of the minimizer.

Comment thread packages/dds/tree/src/shared-tree/transactionMinimize.ts Outdated
* This iterates over the change's constituent {@link ModularChangeset}s,
* replacing each with its {@link minimizeModularChangeset | minimized} form.
* Schema changes are left unchanged.
*/

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.

Note that, in scenarios where there is more than one data change, we won't be able to minimize the overall change as much: if a data change creates content that a latter data change removes, that content won't be minimized away.

This means we have a choice to make:
Option 1: we throw a usage error if the number of data changes is > 1.
Option 2: we keep this code as is (no error) but we update the docs to weaken the contract.

I don't have a strong opinion.

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.

The advantage of throwing is that the contract remains simple, and there's no risk that an app author would end up with a solution that is less secure than they thought.

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.

?Option 3? (that I don't think we must do now) Track ids for all new content over the entire set and react to removed added content.

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.

Option 1 implemented (and tested)

for (const inner of change.changes) {
if (inner.type === "data") {
total += inner.innerChange.builds?.size ?? 0;
}

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.

Note that each build may be building 1+ nodes. I'm not sure if the current tests care about that, but we probably should have some tests that do, so we'll want to sum the top-level length of the chunk on each build entry.

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.

The fact that the tests actually look at the JSON for the changeset make this less of an issue. I'm fine leaving it as is. In fact, do we even care about the number of builds and destroys?

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.

Unfortunately inspecting JSON with hopes of not finding content (our goal) is unreliable. Negative tests are unreliable.
That is mitigated with some positive tests even if those are fussy.
Using the "sum the top-level length of the chunk on each build entry" seems like it should make things less fussy. Will attempt.

const { view, stringifiedChange } = runStringArrayScenario(scenarioAThenBInserted);
assert.deepEqual([...view.root], ["A❤️", "B❤️"]);
assert.match(stringifiedChange, someSurvivingMarkerRegex);
});

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.

These tests are looking at the stringified changed. This is unexpected given how they whole describe block is advertised.

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.

updated describe description

// observable result of a transaction, so these are expected to PASS regardless of whether minimization is
// actually implemented.
describe("preserves the observable result", () => {
it("keeps inserted nodes", () => {

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.

Rather than having a test for each scenario and asserting a specific end state, consider putting all the scenarios in an array (or two arrays) and running them both with and without minimization, then comparing the resulting document tree state.

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.

both seem best. The current pattern helps ensure tests are good and the comparison give breadth and depth. What is the good way to compare two?

Comment thread packages/dds/tree/src/test/shared-tree/transactionMinimize.spec.ts
// The created node is not present in the final document, so its build/destroy should be removed.
assert.equal(countBuilds(change), 0);
assert.equal(countDestroys(change), 0);
});

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.

If we stop worrying about the number of builds and destroys (see comment at the top), then we could put all the scenarios in one (or two) arrays and have a single test implementation that asserts there's no transient markers in the stringified change.

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.

small history aside: when I developed these, I did so from design that cares about builds and that was the known inspection point to me. Only later did I find path to inspect the serialized changes.

  1. And neither of the two checks are full-proof. The builds are implementation subjective beyond some point. The serialized change could use an encoding and ☠️ might not show anyway - the serialization logic is far beyond the immediate implementation's working area.
  2. More tests are generally better than a large one. We want to test small elements to aid in our development - easier to follow and easier to diagnose trouble. We also want (especially with agentic AI) to cover multiple small variations to avoid result hacking. One or two large complicated cases are desirable two.
    So I keep 'em.
    That said overnight I was thinking that we can use a single schema and there are more cases that only doable with more complicated schema.

I think once we can enable these cases, we could combine most or all of them and not need to double execute scenarios.

}

// #region Scenario definitions
// Each scenario declares the initial document content and the edits applied to the strongly-typed root node

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.

We need a systematic way to think about what scenarios are needed. Was the current set of scenarios based on such a thing?

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.

The collection should cover all fundamental cases where some content is added while under transaction but ultimately is not going to be exhaustive.

@yann-achard-MS yann-achard-MS Jul 2, 2026

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.

Another set of test we want to run is checking that the end state of all peers is consistent when one peer minimizes a transaction. You can test that by having a test for each scenario where the test is applied on one peer and the peers are then synchronized. You can use validateViewConsistency to validate that both peers get the same state.

I expect these tests will start failing once you implement minimization. That's because we're going to need to roll back the changes applied to the local branch and apply the minimized change. I forgot about that when I reviewed #27610.

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.

You'll have to tell me more. Follow-up PR?

@jason-ha jason-ha requested a review from yann-achard-MS July 7, 2026 10:21
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

🔗 No broken links found! ✅

Your attention to detail is admirable.

linkcheck output

1: starting server using command "npm run serve -- --no-open"
and when url "[ 'http://127.0.0.1:3000' ]" is responding with HTTP status code 200
running tests using command "npm run check-links"


> fluid-framework-website@0.0.0 serve
> docusaurus serve --no-open

[SUCCESS] Serving "build" directory at: http://localhost:3000/

> fluid-framework-website@0.0.0 check-links
> linkcheck http://localhost:3000 --skip-file skipped-urls.txt

Crawling...

Stats:
  294289 links
    1937 destination URLs
    2187 URLs ignored
       0 warnings
       0 errors


@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Bundle size comparison

Base commit: db34092e6747504e6d7c7b8fc1f65dbb4240a89a
Head commit: 3825606690c9235836acca4e323bc49d26a5b29e

Notable changes

No bundles changed by ≥ 500 bytes parsed.

Per-bundle deltas

@fluid-example/bundle-size-tests

  • azureClient.js: parsed 619098 → 619154 (+56), gzip 164826 → 164872 (+46)
  • odspClient.js: parsed 591824 → 591880 (+56), gzip 158916 → 158958 (+42)
  • aqueduct.js: parsed 525587 → 525622 (+35), gzip 140710 → 140739 (+29)
  • fluidFramework.js: parsed 392745 → 392766 (+21), gzip 111523 → 111539 (+16)
  • sharedTree.js: parsed 382132 → 382146 (+14), gzip 108908 → 108920 (+12)
  • containerRuntime.js: parsed 303937 → 303951 (+14), gzip 83213 → 83222 (+9)
  • 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 145256 → 145270 (+14), gzip 39063 → 39076 (+13)
  • odspDriver.js: parsed 104329 → 104350 (+21), gzip 32649 → 32658 (+9)
  • directory.js: parsed 66616 → 66623 (+7), gzip 18532 → 18540 (+8)
  • 748.js: parsed 58793 → 58793 (0), gzip 17827 → 17827 (0)
  • map.js: parsed 46709 → 46716 (+7), gzip 14310 → 14318 (+8)
  • odspPrefetchSnapshot.js: parsed 45642 → 45656 (+14), gzip 15277 → 15285 (+8)
  • 594.js: parsed 44493 → 44493 (0), gzip 13744 → 13744 (0)
  • summarizerDelayLoadedModule.js: parsed 30749 → 30749 (0), gzip 7753 → 7753 (0)
  • socketModule.js: parsed 26477 → 26484 (+7), gzip 7886 → 7894 (+8)
  • 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 432 → 441 (+9)
  • debugAssert.js: parsed 429 → 429 (0), gzip 299 → 299 (0)
  • FluidFramework-HashFallback.js: parsed 422 → 422 (0), gzip 316 → 316 (0)

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.

3 participants