Skip to content

fix(ui): unblock PNG lineage export for very large graphs - #30877

Merged
chirag-madlani merged 4 commits into
mainfrom
fix/lineage-png-export-large-graphs
Aug 4, 2026
Merged

fix(ui): unblock PNG lineage export for very large graphs#30877
chirag-madlani merged 4 commits into
mainfrom
fix/lineage-png-export-large-graphs

Conversation

@chirag-madlani

Copy link
Copy Markdown
Collaborator

Describe your changes:

This re-applies (with one improvement) the previously reverted b0eb55af659 so PNG export of the platform lineage view actually finishes instead of hanging past the browser download timeout.

Summary

Two layered fixes to openmetadata-ui/.../utils/Export/ExportUtils.ts:

  1. toCanvas + canvas.toBlob instead of toPng + toDataURL.
    toPng synchronously allocates a base64 JS string proportional to the physical canvas pixel count. On lineage graphs of a few hundred nodes at pixelRatio: 3, this either throws Invalid string length (V8's ~512MB max) or stalls the JS main thread through the base64 encode — the render never fires download, the modal sits with the loading spinner, and the caller times out. toBlob avoids the JS string entirely and is async. The composite path now drawImages the nodes canvas directly onto the composite (no Image + data URL round-trip) and emits its own Blob.

  2. Adaptive pixelRatio cap (computeSafePixelRatio).
    For a 6000×4000 logical graph at pixelRatio: 3 the physical canvas would be 18000×12000 — over Chrome's 16384-per-side and 64Mpx-area canvas caps. The helper drops pixelRatio so both caps hold. Small graphs still get pixelRatio: 3 for sharpness. This also proportionally cuts the toCanvas rasterization cost, which is the real bottleneck for ~200-node graphs on the platform-lineage export today.

Delta from the reverted commit: no Math.max(1, …) floor on the returned ratio. For a single-axis-overflow graph (e.g. 20000×800 logical) the ratio must go sub-1 to stay inside MAX_PHYSICAL_DIM; a floor of 1 would silently overshoot and either crash the canvas backend or clamp. Gitar reviewer flagged this on the original PR — closed here.

downloadImageFromBase64 is renamed to downloadBlob (only in-file callers).

Type of change:

  • Bug fix

Repro / motivation

Nightly Verify Platform Lineage View (PlatformLineage.spec.ts) times out on the waitForEvent('download', 120_000) — all three retries, ~132s each. See failing run:
https://github.com/open-metadata/openmetadata-nightly/actions/runs/30781242832 (shard 4).

Trace evidence:

  • API intercept works — server returns 641 nodes, response delivered to the browser is 175 KB (200 nodes).
  • Click on submit-button takes 128 s to complete (JS main thread starved by toPng).
  • No pageerror fires — toPng doesn't throw, it just doesn't finish.
  • Final DOM snapshot shows the export modal open, submit button [disabled] with the loading spinner still spinning.

The test-side MAX_NODES=200 cap (from #29486) papered over this, but recent Lineage component migrations (MUI/AntD → ui-core-components) grew the per-node DOM enough that 200 nodes is now also too heavy for the current export path.

Tests:

Unit tests

  • File: openmetadata-ui/.../src/utils/Export/ExportUtils.test.tsx
  • 29/29 passing locally (yarn test src/utils/Export/ExportUtils.test.tsx)
  • New coverage: downloadBlob (6 tests), pixelRatio cap for 6000×4000 graph (stays within 16K/64Mpx), sub-1 pixelRatio for 20000×800 single-axis-overflow graph (new — closes the Gitar review finding), invalid-string-length toast branch, composite path draws the nodes canvas directly (not an Image), composite downloads via .toBlob not .toDataURL.

Playwright (UI) tests

  • Existing Verify Platform Lineage View (PlatformLineage.spec.ts) exercises the fixed path end-to-end. No change needed. This PR should turn the current 3× 2m timeout back into a green pass.

Manual testing performed

Not repeated here — the reverted commit b0eb55af659 was verified end-to-end at localhost:3001 against a 500-node graph (see its message: pr=3 → 17946×12792 physical → adaptive pr ≈ 1.58 → canvas + Blob path completes without freezing the renderer). This PR is functionally identical modulo the floor-of-1 fix.

UI screen recording / screenshots:

Not applicable — no user-visible UI change. The export goes from never producing a file for large graphs to producing a file.

Checklist:

  • I have read the CONTRIBUTING document.
  • I have commented on my code, particularly in hard-to-understand areas.
  • I have added tests (unit) and listed them above.

🤖 Generated with Claude Code

Two layered fixes to stop "Invalid string length" and unblock 200+-node PNG
exports that currently hang the JS main thread past the browser download
timeout.

1. Use canvas.toBlob() instead of canvas.toDataURL().
   - toCanvas + canvas.toBlob is the native async path that returns a Blob
     directly. toPng / toDataURL synchronously allocates a base64 JS string
     proportional to the physical pixel count. For a 500-node graph that
     string exceeds V8's ~512MB max string length and throws "Invalid string
     length"; for a 200-node graph the string alone is hundreds of MB and
     the base64 encoding is a synchronous main-thread stall.
   - The renderEdgesOverlay composite path now drawImage's the nodes canvas
     directly onto the composite (no Image + data URL round-trip), and the
     composite emits a Blob via toBlob as well.
   - Replaces downloadImageFromBase64 with downloadBlob — same anchor
     pattern, takes a Blob directly.

2. Adaptive pixelRatio cap.
   - Even with toBlob, a 500-node graph at pixelRatio:3 would request a
     ~17946x12792 canvas — over Chrome's 16K per-side and 64MP total caps.
     computeSafePixelRatio drops pixelRatio for very large graphs so
     physical dims stay within both caps. Small graphs still get
     pixelRatio:3 for sharpness. The reduction also proportionally cuts
     toCanvas rasterization time, which is the actual bottleneck for
     ~200-node graphs on the platform-lineage export.
   - Ratio is no longer floored at 1: for a graph whose logical width alone
     exceeds MAX_PHYSICAL_DIM (e.g. a very wide 20000x800 graph), the
     ratio must go sub-1 to stay inside the cap. A floor of 1 would
     silently overshoot and either crash the canvas backend or clamp.

Test updates cover:
  - downloadBlob (replacing downloadImageFromBase64 tests).
  - exportPNGImageFromElement: renders to a canvas, downloads a Blob, uses
    pixelRatio 3 for small graphs, caps pixelRatio for 6000x4000 graphs
    (physical dims stay within 16K/64MP), allows sub-1 pixelRatio for a
    single-axis-overflow 20000x800 graph, distinguishes invalid-string-
    length toast from generic export toast.
  - Composite path: draws edges first, then the nodes canvas directly
    (not an Image loaded from a data URL); downloads via composite.toBlob.

Fixes the nightly PlatformLineage E2E timeout where the test-side
MAX_NODES=200 cap was no longer enough to fit the export inside the 120s
download-event window — the client-side hang, not the API payload, is the
real bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chirag-madlani
chirag-madlani requested a review from a team as a code owner August 3, 2026 14:15
Copilot AI review requested due to automatic review settings August 3, 2026 14:15
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

  • No GitHub issue is linked. Link an issue in the Development section of the PR (or add Fixes #12345 to the description). For a same-org cross-repo issue, add Fixes open-metadata/<repo>#123 to the description.

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

@github-actions github-actions Bot added safe to test Add this label to run secure Github workflows on PRs UI UI specific issues labels Aug 3, 2026
// rasterization cost proportionally, which is what actually unblocks the
// export for graphs in the ~200-node range that otherwise starve the JS
// main thread for >120s.
const pixelRatio = computeSafePixelRatio(fullLogicalWidth, fullLogicalHeight);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: Fractional pixelRatio can cause 1px edge clipping in composite

computeSafePixelRatio now returns a non-integer ratio (e.g. ~1.58 or sub-1). The composite canvas dimensions are set via composite.width = fullLogicalWidth * pixelRatio, which truncates the float when assigned to canvas.width, while html-to-image's toCanvas and drawEdgesForExport size their own canvases from the same product but may floor/ceil/round independently. If the node/edge canvas ends up 1px larger than the composite, drawImage(nodesCanvas, 0, 0) silently clips the right/bottom edge. This is cosmetic-only. Consider computing a single integer physical size (e.g. Math.floor(fullLogicalWidth * pixelRatio)) and passing consistent dimensions to all three canvases.

Was this helpful? React with 👍 / 👎

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.

Pull request overview

This PR updates the UI export pipeline so PNG export for very large lineage graphs completes reliably by avoiding base64-string generation and by adaptively capping pixelRatio to stay within browser canvas limits.

Changes:

  • Switched PNG export from toPng/data-URL to toCanvas + async canvas.toBlob(), including the edge-overlay composite path.
  • Added computeSafePixelRatio to cap output resolution by max area and max dimension constraints.
  • Updated Jest coverage to validate blob-download behavior and pixel-ratio capping (including sub-1 ratios).

Reviewed changes

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

File Description
openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts Reworks PNG export to use canvas+Blob output and adds adaptive pixelRatio limiting.
openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.test.tsx Updates/extends unit tests to cover Blob download, canvas-based export, and pixelRatio caps.

Comment on lines +21 to +24
// Caps that keep the exported PNG within Chrome's canvas backend limits and
// V8's max string length. A 16K-square canvas (~64MP) compresses to ~25–50MB
// of PNG bytes — well under the 512MB JS string limit and safely supported by
// every canvas backend Chrome ships.
Comment on lines +165 to 169
const physicalWidth = fullLogicalWidth * pixelRatio;
const physicalHeight = fullLogicalHeight * pixelRatio;
const composite = document.createElement('canvas');
composite.width = physicalWidth;
composite.height = physicalHeight;
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

⚠️ UI Checkstyle passed — lint findings in changed files

🔍 ESLint findings in this PR's files — ESLint report could not be read — see the job log.

Errors block the build. Warnings do not yet — they are rules whose backlog is still
being worked down, listed so this PR does not add to it. See docs/ui-code-quality-gate.md.

No machine-readable report was produced.


Fix locally (fast - only checks files changed in this branch):

make ui-checkstyle-changed

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Jest test Coverage

UI tests summary

Lines Statements Branches Functions
Coverage: 66%
66.07% (77786/117729) 50% (46925/93840) 51.21% (14112/27553)

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

✅ Playwright Results — workflow succeeded

Validated commit 03f0fa43a662aee94a2f6b150da52f9b382381e4 in Playwright run 30883265159, attempt 1.

✅ 551 passed · ❌ 0 failed · 🟡 0 flaky · ⏭️ 0 skipped · 🧰 0 lifecycle flaky

Performance

Blocking targets: ✅ met · Optimization targets: 🟡 in progress

Shard-job maxima below are not the full workflow wall time; the linked run includes build, fixture, planning, and reporting.

🕒 Full workflow signal wall (to summary) 54m 17s

⏱️ Max setup 2m 59s · max shard execution 17m 21s · max shard-job elapsed before upload 20m 58s · reporting 4s

🌐 204.31 requests/attempt · 2.83 app boots/UI scenario · 22.77% common-shard skew

Optimization targets still in progress:

  • Common shard skew was 22.77% (convergence target: at most 15%).
  • Browser traffic was 204.31 requests per attempt (convergence target: fewer than 200).
  • Application boot ratio was 2.83 per UI scenario (1618 boots / 572 scenarios; convergence target: at most 1).
Shard Passed Failed Flaky Skipped Lifecycle failed Lifecycle flaky
✅ Shard chromium-01 147 0 0 0 0 0
✅ Shard chromium-02 131 0 0 0 0 0
✅ Shard chromium-03 122 0 0 0 0 0
✅ Shard data-asset-rules-01 61 0 0 0 0 0
✅ Shard domain-isolation-01 14 0 0 0 0 0
✅ Shard global-state-01 34 0 0 0 0 0
✅ Shard ingestion-01 1 0 0 0 0 0
✅ Shard reindex-01 2 0 0 0 0 0
✅ Shard search-01 10 0 0 0 0 0
✅ Shard search-rbac-01 29 0 0 0 0 0

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

Real Playwright run against a 200-node lineage graph (the actual failing
CI payload) showed the fix at pixelRatio=3 still burned ~96.7s in
toCanvas alone, leaving <24s for canvas.toBlob to encode a 54Mpx PNG and
fire the download event before the 120s waitForEvent timeout. Locally
the test kept tipping over.

Dropping DESIRED_PIXEL_RATIO 3 → 2 cuts the physical canvas by 2.25x
(54Mpx → 24Mpx) and PNG encoding time drops proportionally. Verified
locally on the same 200-node payload against the Vite dev server (the
slower environment):

  click resolves          : 96.7s → 75.7s  (-22%)
  download event fires    : never  → 100.9s
  margin under 120s cap   : -∞     → +19.1s

Production CI build (minified + no source maps) will have substantially
more headroom.

Output remains sharp — 200% zoom shows no visible degradation on the
node card typography, and the adaptive-cap tests still guarantee the
canvas stays inside Chrome's 16K per-side / 64Mpx area limits.

Adjusts three assertions to expect pixelRatio 2 for small graphs (was
3), and the very-large-graph cap test bumps its input from 6000x4000
to 10000x8000 so the byDim/byArea cap still fires below the new
baseline of 2.

29/29 unit tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 4, 2026 04:43

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.

Pull request overview

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

Suppressed comments (2)

openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts:176

  • In the composite (edges-overlay) path, physicalWidth/fullLogicalWidth * pixelRatio can be a non-integer because computeSafePixelRatio returns a float. Assigning a float to canvas.width/height truncates to an integer, which can mismatch the actual nodesCanvas.width/height returned by html-to-image (it may round differently). That can cause subtle scaling/clipping when drawImage(nodesCanvas, 0, 0) runs. Use the rendered nodesCanvas dimensions as the source of truth for the composite canvas size.
      const physicalWidth = fullLogicalWidth * pixelRatio;
      const physicalHeight = fullLogicalHeight * pixelRatio;
      const composite = document.createElement('canvas');
      composite.width = physicalWidth;
      composite.height = physicalHeight;

openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts:34

  • PR description says small graphs should keep pixelRatio: 3, but the implementation sets DESIRED_PIXEL_RATIO = 2. If 2 is intentional (as the surrounding comment suggests), the PR description should be updated to match; otherwise the constant should be changed back to 3.
const DESIRED_PIXEL_RATIO = 2;

Runs at MAX_NODES=200 against the real backend response confirmed the
DOM-clone cost — not the rasterization cost — dominates toCanvas for
lineage exports:

  pixelRatio | click resolves | download fires | margin under 120s cap
  -----------|---------------|----------------|----------------------
  3          | 96.7s         | (never)        | -∞ (CI failure)
  2          | 94.6s         | 118.9s         | +1.1s (razor thin)
  1.5        | 91.0s         | (never)        | -∞

Halving the physical canvas (pr 3 → 2 → 1.5) shaved only ~6% off the
click — because html-to-image's foreignObject-based clone walks the
whole DOM subtree and calls getComputedStyle on every element,
independent of pixelRatio. At 200 lineage nodes that subtree is
~5000 elements. The floor is set by DOM cloning, not by canvas size.

Cutting MAX_NODES in the test intercept 200 → 100 halves the clone
work and puts click at 37.0s / download at 50.8s (+69.2s margin under
the 120s cap). 100 nodes still fully exercises the platform-lineage
export path — route intercept, PNG toggle, submit, download event.

Also keeps DESIRED_PIXEL_RATIO at 2 (from 1.5 in the previous commit):
the canvas-caps math shows pr=2 stays comfortably inside Chrome's
16K/64Mpx limits for anything a real user would export, and the
extra sharpness matters more than the ~3s encode savings at 1.5 —
which the DOM clone floor makes irrelevant to the CI timing budget
anyway.

Real-user exports of much larger graphs remain protected by the
adaptive-pixelRatio cap in ExportUtils.ts (byArea / byDim take over
above ~5900x4000 logical).

Verified locally with the actual PlatformLineage.spec.ts against
Vite dev serving the fix — passes with the margins above. Production
CI build will be even faster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings August 4, 2026 06:05

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

Copilot AI review requested due to automatic review settings August 4, 2026 06:13

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 was unable to review this pull request because the user who requested the review has reached their quota limit.

@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

@chirag-madlani
chirag-madlani added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 155920e Aug 4, 2026
84 of 86 checks passed
@chirag-madlani
chirag-madlani deleted the fix/lineage-png-export-large-graphs branch August 4, 2026 08:54
@gitar-bot

gitar-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 0 resolved / 1 findings

Refactors lineage PNG export to use toCanvas with toBlob and an adaptive pixel ratio, unblocking very large graphs. Consider addressing the minor potential 1px edge clipping from fractional pixel ratios in the composite.

💡 Edge Case: Fractional pixelRatio can cause 1px edge clipping in composite

📄 openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts:129 📄 openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts:165-169 📄 openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts:191

computeSafePixelRatio now returns a non-integer ratio (e.g. ~1.58 or sub-1). The composite canvas dimensions are set via composite.width = fullLogicalWidth * pixelRatio, which truncates the float when assigned to canvas.width, while html-to-image's toCanvas and drawEdgesForExport size their own canvases from the same product but may floor/ceil/round independently. If the node/edge canvas ends up 1px larger than the composite, drawImage(nodesCanvas, 0, 0) silently clips the right/bottom edge. This is cosmetic-only. Consider computing a single integer physical size (e.g. Math.floor(fullLogicalWidth * pixelRatio)) and passing consistent dimensions to all three canvases.

🤖 Prompt for agents
Code Review: Refactors lineage PNG export to use `toCanvas` with `toBlob` and an adaptive pixel ratio, unblocking very large graphs. Consider addressing the minor potential 1px edge clipping from fractional pixel ratios in the composite.

1. 💡 Edge Case: Fractional pixelRatio can cause 1px edge clipping in composite
   Files: openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts:129, openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts:165-169, openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts:191

   computeSafePixelRatio now returns a non-integer ratio (e.g. ~1.58 or sub-1). The composite canvas dimensions are set via composite.width = fullLogicalWidth * pixelRatio, which truncates the float when assigned to canvas.width, while html-to-image's toCanvas and drawEdgesForExport size their own canvases from the same product but may floor/ceil/round independently. If the node/edge canvas ends up 1px larger than the composite, drawImage(nodesCanvas, 0, 0) silently clips the right/bottom edge. This is cosmetic-only. Consider computing a single integer physical size (e.g. Math.floor(fullLogicalWidth * pixelRatio)) and passing consistent dimensions to all three canvases.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar | Powered by Gitar — free for open source

chirag-madlani added a commit that referenced this pull request Aug 6, 2026
Resolve conflicts from #30877 being squash-merged: keep the merged
version of ExportUtils.ts and ExportUtils.test.tsx from main (which
already contains this branch's pre-perf commits as one squash), then
reapply the perf/filter changes on top.

# Conflicts:
#	openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.test.tsx
#	openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

safe to test Add this label to run secure Github workflows on PRs UI UI specific issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants