Skip to content

fix(ui): make PNG lineage export work for very large graphs - #29480

Closed
chirag-madlani wants to merge 4 commits into
mainfrom
fix-lineage-png-large-graphs
Closed

fix(ui): make PNG lineage export work for very large graphs#29480
chirag-madlani wants to merge 4 commits into
mainfrom
fix-lineage-png-large-graphs

Conversation

@chirag-madlani

@chirag-madlani chirag-madlani commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator

Describe your changes:

Follow-up to #29362 / #29250. On lineage graphs with hundreds of nodes (e.g., the 500-node platform lineage at /lineage?fullscreen=true), PNG export throws Invalid string length and the existing catch path surfaces the toast "PNG image could not be generated due to an excessively large Lineage" — the user cannot actually export.

Verified end-to-end against the running app: 500 nodes → logical viewport 5982×4264 → at pixelRatio:3, physical canvas would be 17946×12792 (229 MP), well over Chrome's 16K dim cap, and canvas.toDataURL() would allocate a base64 string ~400 MB, hitting V8's ~512 MB max string length. The renderer freezes for 30+ s before the error path runs.

Two layered fixes:

  1. canvas.toBlob() instead of canvas.toDataURL() — switch from toPng to toCanvas from html-to-image, then use canvas.toBlob (native async API that returns a Blob directly, no JS-string intermediate). The composite edges path now drawImages the nodes canvas directly onto the composite — no Image + data-URL round-trip. downloadImageFromBase64 is replaced with downloadBlob (same anchor pattern, takes a Blob).

  2. Adaptive pixelRatio capcomputeSafePixelRatio keeps physical dimensions ≤ 16384 per side and total area ≤ 64 MP. For 500 nodes, pixelRatio drops from 3 to ~1.58 (logical 5982×4264 → physical ~9460×6748, ~64 MP). Small graphs still get the full pixelRatio:3 for sharpness.

Type of change:

  • Bug fix

High-level design:

N/A — small change.

Tests:

Use cases covered

  • 500-node platform lineage exports a valid PNG instead of throwing 'Invalid string length'
  • Renderer no longer freezes during export
  • Small graphs (e.g., 5-node entity lineage) still export at pixelRatio:3
  • Composite edges path (entity lineage with renderEdgesOverlay) still produces a white-background image with edges behind nodes

Unit tests

  • src/utils/Export/ExportUtils.test.tsx — 28 tests, all pass. Covers:
    • downloadBlob anchor lifecycle (replaces the downloadImageFromBase64 describe block)
    • exportPNGImageFromElement uses toCanvas + canvas.toBlob (no base64 string)
    • Adaptive pixelRatio — asserts both area bound (≤64 MP) and dim bound (≤16384 per side)
    • Composite path with renderEdgesOverlay — draws edges before nodes, uses composite.toBlob

Backend integration tests

  • Not applicable.

Ingestion integration tests

  • Not applicable.

Playwright (UI) tests

  • Not applicable — covered by the existing LineageExportPNGSnapshot.spec.ts (edge-presence via file size) and PlatformLineage.spec.ts.

Manual testing performed

  1. Opened http://localhost:3001/lineage?fullscreen=true — 500 nodes
  2. Confirmed bug: clicking Export froze the renderer for 30+ s, then surfaced "PNG image could not be generated due to an excessively large Lineage"
  3. After fix: 28 unit tests pass, computed pixelRatio for the 500-node graph drops from 3 → ~1.58, physical canvas stays under 16K per side

UI screen recording / screenshots:

Not applicable — backend logic change inside the export utility. Exported PNG content is unchanged for small/medium graphs; large graphs now succeed at a slightly lower DPI instead of failing.

Checklist:

  • I have read the CONTRIBUTING document.
  • I have commented on my code, particularly in hard-to-understand areas.
  • I have added tests (unit / integration / Playwright as applicable) and listed them above.
  • I have added a test that covers the exact scenario we are fixing.

Greptile Summary

This PR fixes PNG export failures on very large lineage graphs (500+ nodes) by replacing the toPng → base64 string pipeline in ExportUtils.ts with toCanvascanvas.toBlob, eliminating the V8 max-string-length and Chrome canvas-dimension crashes that previously made large exports impossible.

  • toCanvas + canvas.toBlob: swaps html-to-image's toPng for toCanvas, then converts directly to a Blob via canvasToBlob; the composite-edges path now drawImages the nodes canvas directly instead of round-tripping through a data URL and an Image element. downloadImageFromBase64 is replaced with the simpler downloadBlob.
  • Adaptive pixelRatio cap (computeSafePixelRatio): keeps physical canvas dimensions ≤ 16 384 px per side and ≤ 64 MP total; small graphs still render at the full 3× ratio while a 500-node graph drops to ~1.58× to stay within browser limits.
  • Tests: 28 unit tests updated to exercise the Blob path, adaptive ratio bounds, the composite drawing order, and both error branches.

Confidence Score: 5/5

Safe to merge — the fix directly eliminates two well-understood browser limits that were causing export failures, with no regressions on small/medium graphs.

Both the toCanvas+Blob path and the adaptive pixelRatio computation are mechanically sound, manually verified end-to-end, and backed by 28 unit tests covering the adaptive capping bounds, composite drawing order, and error branches. The only minor open point is a floating-point rounding alignment between the html-to-image canvas and the composite canvas at non-integer pixel ratios, which is imperceptible in practice.

No files require special attention; ExportUtils.ts is the critical path but the logic is well-tested and the PR author verified it against the 500-node production graph.

Important Files Changed

Filename Overview
openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.ts Core fix: replaces toPng+base64 pipeline with toCanvas+Blob and adds adaptive pixelRatio capping; logic is correct and well-commented.
openmetadata-ui/src/main/resources/ui/src/utils/Export/ExportUtils.test.tsx Test suite updated to match new Blob-based API; covers pixelRatio capping, composite path, and error branches — 28 tests, well structured.
openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PlatformLineage.spec.ts Adds diagnostic console.log showing original vs filtered node count in the route interceptor; cosmetic change with no functional impact.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[exportPNGImageFromElement] --> B[querySelector]
    B -- not found --> C[throw Error]
    B -- found --> D[computeSafePixelRatio\nlogicalW × logicalH]
    D --> E{pixelRatio ≤ 3?}
    E -- large graph → capped --> F[toCanvas with reduced pixelRatio]
    E -- small graph → full 3× --> F
    F --> G{renderEdgesOverlay?}
    G -- yes composite path --> H[renderEdgesOverlay → edgesCanvas]
    H --> I[create composite canvas\nphysicalW × physicalH]
    I --> J[fillRect white]
    J --> K{edgesCanvas != null?}
    K -- yes --> L[drawImage edgesCanvas]
    K -- no --> M[drawImage nodesCanvas]
    L --> M
    M --> N[canvasToBlob composite]
    G -- no simple path --> O[canvasToBlob nodesCanvas]
    N --> P[downloadBlob]
    O --> P
    P --> Q[createObjectURL → anchor click → revokeObjectURL]
    F -- throws --> R{Invalid string length?}
    R -- yes --> S[showErrorToast invalid-string-length]
    R -- no --> T[showErrorToast generic]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[exportPNGImageFromElement] --> B[querySelector]
    B -- not found --> C[throw Error]
    B -- found --> D[computeSafePixelRatio\nlogicalW × logicalH]
    D --> E{pixelRatio ≤ 3?}
    E -- large graph → capped --> F[toCanvas with reduced pixelRatio]
    E -- small graph → full 3× --> F
    F --> G{renderEdgesOverlay?}
    G -- yes composite path --> H[renderEdgesOverlay → edgesCanvas]
    H --> I[create composite canvas\nphysicalW × physicalH]
    I --> J[fillRect white]
    J --> K{edgesCanvas != null?}
    K -- yes --> L[drawImage edgesCanvas]
    K -- no --> M[drawImage nodesCanvas]
    L --> M
    M --> N[canvasToBlob composite]
    G -- no simple path --> O[canvasToBlob nodesCanvas]
    N --> P[downloadBlob]
    O --> P
    P --> Q[createObjectURL → anchor click → revokeObjectURL]
    F -- throws --> R{Invalid string length?}
    R -- yes --> S[showErrorToast invalid-string-length]
    R -- no --> T[showErrorToast generic]
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into fix-lineage-png..." | Re-trigger Greptile

Two layered fixes to stop "Invalid string length" / frozen renderer on PNG
export for lineage graphs with hundreds of nodes.

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", which the existing catch path surfaced as a toast but
     left the export broken.
   - 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.

2. Adaptive pixelRatio cap.
   - Even with toBlob, a 500-node graph at pixelRatio:3 would request a
     ~17946×12792 canvas which exceeds Chrome's 16K dim cap and 64MP area
     limit. computeSafePixelRatio drops pixelRatio for very large graphs
     so physical dims stay ≤16384 per side and ≤64MP total. Small graphs
     still get pixelRatio:3 for sharpness.

Verified the bug end-to-end against the running app at localhost:3001
with 500 nodes (logical 5982×4264 → physical 17946×12792 at pr=3, base64
estimate ~400MB). After the fix, computed pixelRatio drops to ~1.58 and
the canvas + Blob path completes without freezing the renderer.

Update ExportUtils.test.tsx (28 tests, all pass) to cover the new
downloadBlob helper, the adaptive pixelRatio cap (asserts both area and
dim bounds), and the toCanvas / toBlob composite path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@chirag-madlani
chirag-madlani requested a review from a team as a code owner June 25, 2026 14:38
@github-actions

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 Jun 25, 2026
Comment on lines +44 to +52
const canvasToBlob = (canvas: HTMLCanvasElement): Promise<Blob> =>
new Promise((resolve, reject) => {
canvas.toBlob(
(blob) =>
blob ? resolve(blob) : reject(new Error('canvas.toBlob returned null')),
'image/png',
1.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.

P2 canvas.toBlob can still return null in the simple (non-composite) path with no targeted error message

canvasToBlob correctly rejects when blob is null, and the outer catch in exportPNGImageFromElement will surface a generic error toast. However, a null blob typically signals a cross-origin-tainted canvas, which has a distinct cause from a general export failure. The existing "Invalid string length" branch in the error handler won't match, so the user sees the same generic message regardless of the actual cause. This isn't a regression — the old code had the same gap — but the new wrapper makes the null case more visible, so a more informative error message (or at minimum a dedicated test assertion) for toBlob === null might be worth a follow-up.

Comment on lines 133 to 134
quality: 1.0,
style: {

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.

P2 The quality option is ignored by toCanvas — it is only consumed by lossy encoders (toJpeg, toWebp). Leaving it in the options object is harmless but misleading, since the PNG output quality is always lossless regardless of this value. Removing it avoids confusion for future maintainers.

Suggested change
quality: 1.0,
style: {
style: {

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Jest test Coverage

UI tests summary

Lines Statements Branches Functions
Coverage: 63%
63.22% (71153/112535) 46.25% (41318/89327) 47.52% (12694/26708)

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

🔴 Playwright Results — 2 failure(s), 29 flaky

✅ 4472 passed · ❌ 2 failed · 🟡 29 flaky · ⏭️ 38 skipped

Shard Passed Failed Flaky Skipped
🟡 Shard 1 439 0 4 16
🔴 Shard 2 798 2 10 8
🟡 Shard 3 808 0 1 7
🟡 Shard 4 806 0 5 5
🟡 Shard 5 855 0 2 0
🟡 Shard 6 766 0 7 2

Genuine Failures (failed on all attempts)

Features/BulkImport.spec.ts › Database Schema (shard 2)
Error: �[2mexpect(�[22m�[31mlocator�[39m�[2m).�[22mtoContainText�[2m(�[22m�[32mexpected�[39m�[2m)�[22m failed

Locator: locator('.rdg-cell-details')
Timeout: 15000ms
�[32m- Expected  - 1�[39m
�[31m+ Received  + 1�[39m

�[2m  Array [�[22m
�[31m+   "#FIELD_REQUIRED: Field 1 is required;#FIELD_REQUIRED: Field 13 is required",�[39m
�[2m    "Entity created",�[22m
�[2m    "Entity updated",�[22m
�[32m-   "Entity created",�[39m
�[2m    "Entity updated",�[22m
�[2m  ]�[22m

Call log:
�[2m  - Expect "toContainText" with timeout 15000ms�[22m
�[2m  - waiting for locator('.rdg-cell-details')�[22m
�[2m    19 × locator resolved to 4 elements�[22m

Features/GlobalPageSize.spec.ts › Page size should persist across different pages (shard 2)
�[31mTest timeout of 60000ms exceeded.�[39m
🟡 29 flaky test(s) (passed on retry)
  • Features/Pagination.spec.ts › should test API Collection normal pagination (shard 1, 1 retry)
  • Pages/Lineage/LineageRightPanel.spec.ts › Verify custom properties tab IS visible for supported type: metric (shard 1, 1 retry)
  • Pages/SearchSettings.spec.ts › Preview config reflects reverted n-gram weight after save (shard 1, 2 retries)
  • Flow/SearchRBAC.spec.ts › the browse tree only shows the asset-type categories a user can access (shard 1, 1 retry)
  • Features/BulkEditEntity.spec.ts › Database (shard 2, 1 retry)
  • Features/BulkEditEntity.spec.ts › Database Schema (shard 2, 2 retries)
  • Features/BulkEditEntity.spec.ts › Glossary Term (Nested) (shard 2, 1 retry)
  • Features/BulkEditOperationBadges.spec.ts › Glossary bulk edit search filters rows and clear restores them (shard 2, 1 retry)
  • Features/BulkImport.spec.ts › Database service (shard 2, 2 retries)
  • Features/BulkImport.spec.ts › Database (shard 2, 2 retries)
  • Features/ContextCenterPermission.spec.ts › user with deleteAll permission can see delete action but not restore action on an archived document, and can delete it (shard 2, 2 retries)
  • Features/DataQuality/TestCaseImportExportE2eFlow.spec.ts › Admin: Complete export-import-validate flow (shard 2, 2 retries)
  • Features/ExploreQuickFilters.spec.ts › explore tree sidebar selection is not cleared when a top dropdown filter is applied (shard 2, 1 retry)
  • Features/Glossary/GlossaryHierarchy.spec.ts › should cancel move operation (shard 2, 1 retry)
  • Features/Tasks/TaskNavigation.spec.ts › navigating to /table/TASK-XXXXX should show 404 (invalid URL pattern) (shard 3, 1 retry)
  • Features/UserProfileOnlineStatus.spec.ts › Should show "Active recently" for users active within last hour (shard 4, 1 retry)
  • Pages/CustomProperties.spec.ts › Set & Update all CP types on apiCollection (shard 4, 1 retry)
  • Pages/DataContractsSemanticRules.spec.ts › Validate Description Rule Is_Set (shard 4, 1 retry)
  • Pages/DataProductODPS.spec.ts › name guard blocks a YAML with no readable product name (shard 4, 2 retries)
  • Pages/DataProductODPS.spec.ts › edits data product metadata (type, visibility, priority) via the modal (shard 4, 2 retries)
  • Pages/Entity.spec.ts › Glossary Term Add, Update and Remove for child entities (shard 5, 1 retry)
  • Pages/ExplorePageRightPanel_KnowledgeCenter.spec.ts › Should remove user owner for knowledgeCenter (shard 5, 1 retry)
  • Features/AutoPilot.spec.ts › Create Service and check the AutoPilot status (shard 6, 2 retries)
  • Pages/GlossaryImportExport.spec.ts › Check for Circular Reference in Glossary Import (shard 6, 1 retry)
  • Pages/Lineage/DataAssetLineage.spec.ts › Column lineage for dashboard -> apiEndpoint (shard 6, 1 retry)
  • Pages/Lineage/LineageFilters.spec.ts › Verify Impact Analysis service filter selection (shard 6, 1 retry)
  • Pages/Lineage/LineageRightPanel.spec.ts › Verify custom properties tab is NOT visible for pipelineService in platform lineage (shard 6, 1 retry)
  • Pages/Lineage/LineageRightPanel.spec.ts › Verify custom properties tab is NOT visible for apiService in platform lineage (shard 6, 1 retry)
  • Pages/Users.spec.ts › Reset Password for Data Steward (shard 6, 1 retry)

📦 Download artifacts

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

chirag-madlani and others added 2 commits July 3, 2026 10:32
… at 500

The previous route.fulfill forwarded response.headers() verbatim, which
kept Content-Encoding: gzip and Content-Length from the upstream response
even though we replaced the body with plain (unencoded) JSON. The browser
either failed to gunzip our plain body or read the wrong number of bytes,
so the intercepted "capped at 500" payload never actually reached the
frontend and the test silently ran against the full upstream data.

Switch to Playwright's { response, json } shortcut (same pattern used in
Table.spec.ts:779) so headers stay valid after the body change — the
shortcut auto-strips Content-Encoding and recomputes Content-Length.

Also log original → filtered node counts inside the handler so it's
obvious in the Playwright output whether the cap actually applied for
each of the service / domain / dataProduct views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 3, 2026 05:03

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.

Comment on lines +56 to +60
console.log(
`[PlatformLineage route] ${route
.request()
.url()} — nodes: ${originalNodeCount} → ${filteredNodeCount}`
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: Committed console.log adds noise to CI test output

This commit adds a console.log diagnostic inside the route interceptor. It runs once per intercepted view (service/domain/dataProduct), so it will emit multiple lines into every CI run of this spec. While the accompanying comment explains it is a deliberate diagnostic to confirm the slice is applied, persistent logging in committed test code adds noise and is usually removed once the interceptor is verified. Consider removing it before merge, or gating it behind a debug flag (e.g. process.env.DEBUG_PLATFORM_LINEAGE) so normal runs stay quiet. This is a minor, non-blocking concern; the node-counting logic itself is correct and null-safe.

Was this helpful? React with 👍 / 👎

@sonarqubecloud

sonarqubecloud Bot commented Jul 3, 2026

Copy link
Copy Markdown

@gitar-bot

gitar-bot Bot commented Jul 21, 2026

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

Enables successful PNG exports for large lineage graphs by replacing base64-string conversions with canvas.toBlob and implementing adaptive pixel density capping. Consider removing the console.log from the test suite to keep CI logs clean.

💡 Quality: Committed console.log adds noise to CI test output

📄 openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PlatformLineage.spec.ts:56-60

This commit adds a console.log diagnostic inside the route interceptor. It runs once per intercepted view (service/domain/dataProduct), so it will emit multiple lines into every CI run of this spec. While the accompanying comment explains it is a deliberate diagnostic to confirm the slice is applied, persistent logging in committed test code adds noise and is usually removed once the interceptor is verified. Consider removing it before merge, or gating it behind a debug flag (e.g. process.env.DEBUG_PLATFORM_LINEAGE) so normal runs stay quiet. This is a minor, non-blocking concern; the node-counting logic itself is correct and null-safe.

🤖 Prompt for agents
Code Review: Enables successful PNG exports for large lineage graphs by replacing base64-string conversions with `canvas.toBlob` and implementing adaptive pixel density capping. Consider removing the `console.log` from the test suite to keep CI logs clean.

1. 💡 Quality: Committed console.log adds noise to CI test output
   Files: openmetadata-ui/src/main/resources/ui/playwright/e2e/Flow/PlatformLineage.spec.ts:56-60

   This commit adds a `console.log` diagnostic inside the route interceptor. It runs once per intercepted view (service/domain/dataProduct), so it will emit multiple lines into every CI run of this spec. While the accompanying comment explains it is a deliberate diagnostic to confirm the slice is applied, persistent logging in committed test code adds noise and is usually removed once the interceptor is verified. Consider removing it before merge, or gating it behind a debug flag (e.g. `process.env.DEBUG_PLATFORM_LINEAGE`) so normal runs stay quiet. This is a minor, non-blocking concern; the node-counting logic itself is correct and null-safe.

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

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.

2 participants