Skip to content

fix(core): Prevent exponential re-merge of shared entity references - #5085

Open
TheHypnoo wants to merge 4 commits into
vendurehq:masterfrom
TheHypnoo:fix/5083-merge-deep-shared-references
Open

fix(core): Prevent exponential re-merge of shared entity references#5085
TheHypnoo wants to merge 4 commits into
vendurehq:masterfrom
TheHypnoo:fix/5083-merge-deep-shared-references

Conversation

@TheHypnoo

@TheHypnoo TheHypnoo commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes #5083.

mergeDeep() re-merges a source object once for every path that reaches it, so hydration cost grows with the number of paths through the entity graph rather than the number of objects in it.

Cause

Before #4945, visited accumulated every source object seen during a hydrate() call, so each object was merged once. #4945 changed it to track only the current recursion path (visited.delete(b) on the way out) so that an instance shared by several targets is merged into each of them, which is correct — but it also means a shared instance is re-merged once per path that reaches it, and path counts multiply with each level of sharing.

The graphs this runs on are full of shared instances by construction: EntityHydrator.hydrate() loads with relationLoadStrategy: 'query', which gives every referencing parent the same instance of a related entity. That is the same property the e2e test added in #4945 relies on (order.lines[0].productVariant === order.lines[1].productVariant).

Fix

Memoise each (target, source) pair. Merging a source into a target is idempotent, so a given pair only needs merging once, and the cost goes back to being a function of the graph size. The path-scoped cycle detection and the shared-instance behaviour from #4945 are both unchanged.

Two alternatives were considered and rejected:

Measurements

Real data, sqljs e2e: an Order with 3 lines whose ProductVariants reference the same N Channels, each Channel sharing one Zone. mergeDeep calls during the second hydrate() of the Order — the state a plugin sees after an order mutation leaves one line without the relation.

Channels Objects in graph pre-#4945 current master this PR
10 305 167 676 196
31 515 272 1810 364
100 1205 617 5536 916
200 2205 1117 10936 1716
500 5205 2617 27136 4116

Master's cost relative to the pre-#4945 implementation keeps climbing with the amount of sharing (4.0x, 6.7x, 9.0x, 9.8x, 10.4x). This PR settles at ~1.6x and stops growing; that remainder is the work #4945 requires, since merging a shared source into each distinct target is strictly more merging than skipping it.

The e2e seed data only has two levels of sharing, so it shows a large constant factor rather than a hang. The shape that stops completing is covered by the first unit test: on a graph with depth + 2 objects and 2 ^ (depth + 1) paths and no cycles, master takes 11.6s at depth 22 and this PR takes under a millisecond.

Trade-off

If the first merge of a pair was cut short by the cycle guard, the memo prevents it being completed later via another path. This matches the pre-#4945 behaviour, where a global visited had the same effect, so it is not a new limitation.

Tests

  • should merge each object once rather than once per path — bounds the cost on a cycle-free graph whose path count is exponential in its depth. Fails by assertion (11.6s) without the fix, not by timeout.
  • should keep the existing data of every target a shared source is merged into — guards that the memo is keyed on the pair rather than on the source alone.

Verified green: 1139 core unit tests, 25 entity-hydrator e2e tests, 197 order / shop-order e2e tests.

No e2e test is added. With a channel count small enough for CI it would not fail against the regression, so it would add runtime without detecting anything; the exponential shape is what the unit test pins down.

Breaking changes

None. mergeDeep is exported from @vendure/core, so the memo is threaded through a new optional fourth parameter rather than by changing the type of the existing visited parameter.

Collapsing both into a single WeakMap<target, WeakSet<source>> would be tidier — the pair memo alone already guarantees termination on cycles, so visited becomes redundant — but that changes cycle behaviour as well as cost, so it seemed better kept out of a patch fix. Happy to do it that way instead if you prefer.

Checklist

📌 Always:

  • I have set a clear title
  • My PR is small and contains a single feature
  • I have checked my own PR

👍 Most of the time:

  • I have added or updated test cases
  • I have updated the README if needed

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Fixes vendurehq#5083.

Since vendurehq#4945, mergeDeep() tracks only the current recursion path, so a source
object reachable by more than one path is re-merged once per path. Hydrated
entity graphs are loaded with the 'query' relation strategy, which gives every
referencing parent the same instance of a related entity, so path counts
multiply with each level of sharing. On an Order in a multi-channel setup the
merge cost grows an order of magnitude faster than the graph itself, and deeper
graphs stop completing at all.

Memoise each (target, source) pair instead: merging a source into a target is
idempotent, so a given pair only needs to be merged once. The path-scoped cycle
detection and the shared-instance behaviour added in vendurehq#4945 are unchanged.
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vendure-storybook Ready Ready Preview Aug 3, 2026 8:51am

Request Review

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 2a3f5cf2-d861-4752-8a4e-9e06f8797de3

📥 Commits

Reviewing files that changed from the base of the PR and between ad8a2b3 and 274e025.

📒 Files selected for processing (1)
  • packages/core/src/service/helpers/entity-hydrator/merge-deep.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/core/src/service/helpers/entity-hydrator/merge-deep.spec.ts

📝 Walkthrough

Walkthrough

mergeDeep now tracks merged source-target object pairs with a WeakMap. Recursive calls reuse this map and skip repeated pairs while retaining path-based cycle detection. Tests cover deeply shared acyclic graphs and verify that separate targets preserve existing fields while receiving data from shared sources.

Possibly related PRs

Suggested reviewers: biggamesmallworld

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the core fix for exponential re-merging of shared entity references.
Description check ✅ Passed The description explains the cause, fix, trade-offs, tests, measurements, breaking changes, and checklist status.
Linked Issues check ✅ Passed The changes address issue #5083 by bounding shared-reference work while preserving distinct-target merging, cycle detection, and regression coverage.
Out of Scope Changes check ✅ Passed The changes are limited to the mergeDeep implementation and focused regression tests required by issue #5083.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/service/helpers/entity-hydrator/merge-deep.spec.ts`:
- Around line 118-122: Replace the Date.now()-based performance assertion around
mergeDeep with a deterministic instrumented shared leaf that throws if its
properties are enumerated more than once. Keep the shared leaf referenced by
both a and b, and assert mergeDeep completes without triggering the throw so
repeated-path processing fails immediately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b21e0cf-61b1-4f89-a0ea-2dff296d8355

📥 Commits

Reviewing files that changed from the base of the PR and between 611658f and a6b446a.

📒 Files selected for processing (2)
  • packages/core/src/service/helpers/entity-hydrator/merge-deep.spec.ts
  • packages/core/src/service/helpers/entity-hydrator/merge-deep.ts

Comment thread packages/core/src/service/helpers/entity-hydrator/merge-deep.spec.ts Outdated
@michaelbromley michaelbromley added the T2: Elevated risk Touches critical paths or has wide blast radius. Needs careful, owned work. label Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T2: Elevated risk Touches critical paths or has wide blast radius. Needs careful, owned work.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hydrate regression in 3.7.1 due to mergeDeep changes hangs nodejs

2 participants