Skip to content

Fixes 32021: read native Unity Catalog lineage and its SQL in one streamed query - #33141

Draft
ulixius9 wants to merge 4 commits into
mainfrom
uc-lineage-row-driven
Draft

Fixes 32021: read native Unity Catalog lineage and its SQL in one streamed query#33141
ulixius9 wants to merge 4 commits into
mainfrom
uc-lineage-row-driven

Conversation

@ulixius9

@ulixius9 ulixius9 commented Sep 10, 2026

Copy link
Copy Markdown
Member

Describe your changes:

Fixes #32021

Native Unity Catalog lineage edges carry no SQL even when the statement that wrote
them is available. I attached it by joining system.access.table_lineage to
system.query.history inside the query that already reads the lineage, and while
there I removed the two things that made that lookup expensive: the walk over every
table in the service, and the in-memory copy of every edge in the catalog.

Before: two system-table queries cached into unbounded maps, then one paginated
list_all_entities request per hundred tables in the service (nearly all of them for
tables no edge mentions) plus one uncached get_by_name per edge. Now: one query for
table edges, column mappings and SQL together, streamed in target order, with the
tables an edge actually names resolved once each behind an LRU cache.

This supersedes #33002, which fixes the same issue by batching a second
table_lineage ⋈ query.history query per 100 edges. Measured on the same live
workspace and window, that costs 53.2s of warehouse time against 7.1s here
(numbers below). This branch is cut from current main, which #33002's branch
predates by ~150 lines in this file (path lineage, self-reference dedupe).

Type of change:

  • Improvement

High-level design:

One query instead of two plus one per batch.
unity_catalog_native_lineage_query(query_log_duration, include_query_history)
aggregates each edge's column mappings server-side
(to_json(collect_set(struct(...)))) and left-joins system.query.history on the
edge's latest statement_id/workspace_id (max_by(struct(...), event_time)).
Column mappings join on <=>, not =: source_path is NULL on every edge reported
by table name, and = on NULL never matches, so a plain join would silently drop
those edges' mappings.

Graceful degradation. statement_id is only populated for statements run on a
SQL warehouse and system.query.history is separately granted, so a query naming
them would fail as a whole and take the lineage with it. A single WHERE 1=0 probe
decides once per run; on failure the statement columns and the join are left out
entirely and lineage is ingested without SQL.

Streaming, one target at a time. The query is ORDER BY target, so a target's
edges are complete as soon as the next target appears. _stream_native_lineage
buffers only the target in hand and hands it to _process_target_lineage.

Why the rows are grouped at all (rather than emitted one by one, Snowflake
ACCESS_HISTORY style): Databricks reports one edge twice when it names a side by
table on one row and by path on another, and LineageRepository.addLineage replaces
an edge's lineageDetails instead of merging them — a second request for a pair
already sent would drop the column mappings of the first. That replace-not-merge
behaviour was confirmed live: running main's connector over the same service
stripped sqlQuery from all 444 edges, and re-running this one put it back.
Grouping per target covers every duplication observed so far, since both rows share
the target. A target reported both by name and by path would still emit twice; on
the live dataset 0 of 239 targets had more than one raw key. Closing that case
too would mean pushing path→table resolution (scheme aliases, trailing slashes) into
SQL, moving the path_utils behaviours out of unit tests and into something only
verifiable on a live workspace. Rejected on that basis.

Iteration is driven by the lineage rows, not the catalog: targets come from the
system tables and external-location edges from system.information_schema.tables. No
list_all_entities call remains. The FQN of a table is built rather than searched
for — Unity Catalog normalises identifiers, so fqn.build's Elasticsearch round trip
per table returns the same string fqn._build produces (same approach as
snowflake/lineage.py); all 444 live edges still resolve. Filter patterns apply to
the catalog.schema.table names the system tables report, and a table that is both a
lineage target and an external table is reported once in the run summary.

Backward compatibility: no schema, API or config change. Same edges and column
mappings as before, plus SQL. queryLogDuration keeps its meaning; history reaches
one day further back so a statement that ran just before the oldest lineage day is
still found.

Tests:

Use cases covered

  • A native lineage edge whose statement is in query history shows that SQL in the Edge
    Information panel (the issue).
  • Lineage is still ingested, without SQL, when system.query.history is not readable.
  • Column mappings survive on edges whose source or target is reported by path.
  • An edge reported twice (once by table name, once by path) is sent once, with both
    rows' column mappings merged.
  • Ingestion filter patterns exclude catalogs/schemas/tables named by the system tables.
  • External tables still get their container edge with no lineage rows at all.

Unit tests

  • I added unit tests for the new/changed logic.
  • Files added/updated:
    • ingestion/tests/unit/topology/database/test_unity_catalog_lineage.py (rewritten
      to assert on emitted AddLineageRequests rather than internal maps; 57 tests)
    • ingestion/tests/unit/topology/database/test_unitycatalog_self_referencing_lineage.py
      (retargeted to the method that now owns the rule; 4 tests)
  • Coverage: 99% of changed executable lines (128/129). Whole-module: lineage.py
    90%, queries.py 100%.
  • 61 passed in those two files; 140 passed across all collectible test_unity* files.

Backend integration tests

  • Not applicable (no backend API changes).

Ingestion integration tests

  • No automated ingestion integration test added — the behaviour that needs a live
    metastore is the SQL of the query itself, which no fixture reproduces. Validated
    manually against a live workspace instead; see below.

Playwright (UI) tests

  • Not applicable (no UI changes — only connector help text in
    locales/en-US/Database/UnityCatalog.md).

Manual testing performed

Live Databricks workspace, catalog demo, schema om_lineage_acceptance
(205 batch targets + my_source/my_target), 7-day lookback.

  1. Ingested metadata into a fresh service (uc_lineage_live_test): 245 tables, 0 errors.
  2. Ran the lineage workflow on this branch: Reading native lineage from system tables (lookback: 7 days, SQL text: yes)emitted 444 edges over 239 targets,
    444 processed, 0 errors, 0 warnings, 100% success.
  3. Verified all 444 edges through /v1/lineage/getLineageEdge/...: every edge has a
    non-empty sqlQuery byte-identical to the statement in system.query.history,
    and its column-mapping count matches the warehouse's mappings for that pair exactly.
    Example (my_source → my_target): INSERT INTO demo.om_lineage_acceptance.my_target SELECT id, amount * 2 AS total FROM demo.om_lineage_acceptance.my_source with
    2 column mappings and source: QueryLineage.
  4. Equivalence with main's two queries over the same window: 444 edges and 683
    column mappings on both sides, 0 missing, 0 extra
    — the single query loses nothing.
  5. Row ordering holds on real data: 239 distinct targets, 239 adjacent runs, so the
    one-target buffer never sees a target twice.
  6. Fallback path: the include_query_history=False variant runs on the warehouse and
    returns the same 444 rows with statement_text NULL and column mappings intact.
  7. Re-ran the workflow: same 444 edges, sqlQuery preserved.

Warehouse cost, same window, warm warehouse:

queries time
main (no SQL text) table_lineage + column_lineage 7.5s
this PR (with SQL text) 1 joined query 7.1s
this PR, no query-history access 1 query 4.5s
#33002 (with SQL text) the 2 above + 5 batched enrichment joins (11.0 / 8.9 / 8.5 / 9.0 / 8.3s) 53.2s

Source-side wall clock is a wash at this size — 10.2s here (245 get_by_name, 1.5s
probe, 7.1s query) against main's 10.6s (450 get_by_name + catalog pagination,
7.5s+ of queries) — because this service has 245 tables and 239 of them are lineage
targets, the best possible case for the catalog walk. The saving scales with
tables-in-service ÷ tables-in-lineage. End to end the run is 39s against main's 23s;
that difference is the sink writing the statement text onto 444 edges, i.e. the
feature, not the restructure.

Static checks: ruff check, ruff format --check, check_ruff_suppressions.py --check
(the 5 obsolete G004 baseline entries this change retires are pruned), and
basedpyright --baselinemode=discard — its two reportOptionalIterable hits on
for row in rows also occur on main's copy of this file in the same environment
(3 there, 2 here), so they are the local partial venv, not a regression.

UI screen recording / screenshots:

Not applicable.

Checklist:

  • I have read the CONTRIBUTING document.

  • My PR title is Fixes <issue-number>: <short explanation>

  • My PR is linked to a GitHub issue via Fixes #<issue-number> above.

  • I have commented on my code, particularly in hard-to-understand areas.

  • For JSON Schema changes: not applicable, no schema change.

  • For UI changes: not applicable.

  • I have added tests (unit / integration / Playwright as applicable) and listed them above.

  • I have added tests around the new logic.

  • For connector/ingestion changes: I updated the documentation.

🤖 Generated with Claude Code

ulixius9 and others added 3 commits September 10, 2026 17:09
Native lineage edges could appear without the SQL that produced them. Attach the
statement by joining system.access.table_lineage to system.query.history, and
read the column mappings from system.access.column_lineage in the same query
instead of a second result set.

Drive the emission from the lineage rows rather than from the catalog: the
system tables name every table an edge can end at, so those are the tables
looked up. Listing every table of every schema of every catalog spent a
paginated request per hundred tables in the service, nearly all of them for
tables no edge mentions, and an upstream was re-resolved once per edge naming
it. Both endpoints now resolve through one LRU cache of hits and definite
misses, and an external table is only resolved once its storage is known to be
an ingested container.

system.query.history is probed once. When it cannot be read, the statement
columns and the join are left out of the query entirely, so a missing grant
costs the SQL text rather than all of the lineage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read the rows ordered by target and emit a target's edges as soon as the next
target appears, so what is held is one target rather than every edge in the
catalog. Drops table_lineage_map, column_lineage_map, path_lineage_map and the
per-edge SQL map; the grouping that remains is one target wide.

Rows are still grouped rather than emitted one by one because Databricks
reports an edge twice when it names a side by table on one row and by path on
another, and addLineage replaces an edge's details instead of merging them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 10, 2026 12: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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added Ingestion safe to test Add this label to run secure Github workflows on PRs labels Sep 10, 2026
Comment on lines +55 to 64
UNITY_CATALOG_QUERY_HISTORY_PROBE = textwrap.dedent(
"""
SELECT
source_table_full_name,
source_path,
target_table_full_name,
target_path
FROM system.access.table_lineage
WHERE event_time >= current_date() - INTERVAL {query_log_duration} DAYS
AND (source_table_full_name IS NOT NULL OR source_path IS NOT NULL)
AND (target_table_full_name IS NOT NULL OR target_path IS NOT NULL)
GROUP BY source_table_full_name, source_path, target_table_full_name, target_path
SELECT lineage.statement_id, history.statement_text
FROM system.access.table_lineage lineage
JOIN system.query.history history
ON lineage.statement_id = history.statement_id
AND lineage.workspace_id = history.workspace_id
WHERE 1=0
"""
)

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: Probe does not validate history.start_time used by main query

The probe query (UNITY_CATALOG_QUERY_HISTORY_PROBE) exercises statement_id, workspace_id and statement_text on system.query.history, but the actual join in UNITY_CATALOG_QUERY_HISTORY_JOIN additionally references history.start_time. The stated purpose of the probe is to guarantee the main query won't fail on the history join and take the lineage down with it; since start_time is not covered, a schema mismatch on that column would pass the probe yet fail the whole native-lineage query, losing all lineage rather than just the SQL text. Consider referencing start_time (and statement columns) in the probe so its guarantee matches what the main query actually needs.

Was this helpful? React with 👍 / 👎

@ulixius9
ulixius9 marked this pull request as draft September 10, 2026 13:22
@sonarqubecloud

Copy link
Copy Markdown

@github-actions

Copy link
Copy Markdown
Contributor

✅ Playwright Results — workflow succeeded

Validated commit e3d0230f1196e398125175c565ec80e859498496 in Playwright run 34478351349, attempt 1.

✅ 4500 passed · ❌ 0 failed · 🟡 5 flaky · ⏭️ 1 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) 1h 7m 50s

⏱️ Max setup 4m 50s · max shard execution 23m 40s · max shard-job elapsed before upload 26m 38s · reporting 22s

🌐 216.93 requests/attempt · 2.31 app boots/UI scenario · 35.85% common-shard skew

Optimization targets still in progress:

  • Common shard skew was 35.85% (convergence target: at most 15%).
  • Browser traffic was 216.93 requests per attempt (convergence target: fewer than 200).
  • Application boot ratio was 2.31 per UI scenario (10979 boots / 4762 scenarios; convergence target: at most 1).
Shard Passed Failed Flaky Skipped Lifecycle failed Lifecycle flaky
✅ Shard advanced-search-01 130 0 0 0 0 0
✅ Shard chromium-01 183 0 0 0 0 0
🟡 Shard chromium-02 167 0 1 0 0 0
✅ Shard chromium-03 162 0 0 0 0 0
🟡 Shard chromium-04 145 0 1 0 0 0
✅ Shard chromium-05 169 0 0 0 0 0
✅ Shard chromium-06 198 0 0 0 0 0
✅ Shard chromium-07 192 0 0 0 0 0
✅ Shard chromium-08 194 0 0 0 0 0
🟡 Shard chromium-09 171 0 1 0 0 0
✅ Shard chromium-10 190 0 0 0 0 0
✅ Shard chromium-11 186 0 0 0 0 0
✅ Shard chromium-12 201 0 0 0 0 0
✅ Shard chromium-13 174 0 0 0 0 0
✅ Shard chromium-14 159 0 0 0 0 0
✅ Shard chromium-15 216 0 0 0 0 0
✅ Shard chromium-16 188 0 0 0 0 0
🟡 Shard chromium-17 133 0 1 0 0 0
🟡 Shard chromium-18 216 0 1 0 0 0
✅ Shard chromium-19 131 0 0 1 0 0
✅ Shard chromium-20 188 0 0 0 0 0
✅ Shard chromium-21 197 0 0 0 0 0
✅ Shard chromium-22 181 0 0 0 0 0
✅ Shard data-asset-rules-01 65 0 0 0 0 0
✅ Shard domain-isolation-01 16 0 0 0 0 0
✅ Shard global-state-01 34 0 0 0 0 0
✅ Shard import-export-01 80 0 0 0 0 0
✅ Shard import-export-02 70 0 0 0 0 0
✅ Shard ingestion-01 54 0 0 0 0 0
✅ Shard ingestion-02 41 0 0 0 0 0
✅ Shard reindex-01 28 0 0 0 0 0
✅ Shard search-01 12 0 0 0 0 0
✅ Shard search-rbac-01 29 0 0 0 0 0
🟡 5 flaky test(s) (passed on retry)
  • Pages/DataContracts.spec.tsContract Status badge should be visible on condition if Contract Tab is present/hidden by Persona (shard chromium-02, 1 retry)
  • Features/UserProfileOnlineStatus.spec.tsShould not show online status for inactive users (shard chromium-04, 1 retry)
  • Pages/TasksUIFlow.spec.tsCreate and reject tag task for Dashboard via UI (shard chromium-09, 1 retry)
  • Flow/Tour.spec.tsTour should work from help section (shard chromium-17, 1 retry)
  • Features/PersonaAIContext.spec.tsblocks saving a rule whose condition has no value entered (shard chromium-18, 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

Unity Catalog normalises its identifiers, so the three parts the system tables
report are the three parts the table was ingested under and the Elasticsearch
search fqn.build runs per table returns that same string. Verified against a
live workspace: all 444 edges still resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gitar-bot

gitar-bot Bot commented Sep 11, 2026

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

Optimizes Unity Catalog native lineage ingestion from two system-table queries plus paginated lookups to a single streamed query that joins lineage with query history and SQL in one pass, eliminating the unbounded map caching and expensive per-edge resolution. Comprehensive test coverage (99% of changed lines, 84 tests passing) validates edge cases including path-based sources, merged column mappings, and graceful degradation when query history is unavailable.

Consider extending the system.query.history probe query to reference start_time alongside the statement columns, so its guarantee that the main query won't fail covers all columns the join actually uses.

💡 Edge Case: Probe does not validate history.start_time used by main query

📄 ingestion/src/metadata/ingestion/source/database/unitycatalog/queries.py:55-64 📄 ingestion/src/metadata/ingestion/source/database/unitycatalog/queries.py:128

The probe query (UNITY_CATALOG_QUERY_HISTORY_PROBE) exercises statement_id, workspace_id and statement_text on system.query.history, but the actual join in UNITY_CATALOG_QUERY_HISTORY_JOIN additionally references history.start_time. The stated purpose of the probe is to guarantee the main query won't fail on the history join and take the lineage down with it; since start_time is not covered, a schema mismatch on that column would pass the probe yet fail the whole native-lineage query, losing all lineage rather than just the SQL text. Consider referencing start_time (and statement columns) in the probe so its guarantee matches what the main query actually needs.

✅ 1 resolved
Quality: License header removed from self-referencing lineage test

📄 ingestion/tests/unit/topology/database/test_unitycatalog_self_referencing_lineage.py:1-8
test_unitycatalog_self_referencing_lineage.py had its Collate license header (the 10-line comment block) deleted in this PR, while every other Python file in the repo — including the sibling test_unity_catalog_lineage.py in this same change — retains it. OpenMetadata's CI enforces the license header on Python sources, so this file will likely fail the license/format check and block the merge. Restore the header block at the top of the file.

🤖 Prompt for agents
Code Review: Optimizes Unity Catalog native lineage ingestion from two system-table queries plus paginated lookups to a single streamed query that joins lineage with query history and SQL in one pass, eliminating the unbounded map caching and expensive per-edge resolution. Comprehensive test coverage (99% of changed lines, 84 tests passing) validates edge cases including path-based sources, merged column mappings, and graceful degradation when query history is unavailable.
  
  Consider extending the `system.query.history` probe query to reference `start_time` alongside the statement columns, so its guarantee that the main query won't fail covers all columns the join actually uses.

1. 💡 Edge Case: Probe does not validate history.start_time used by main query
   Files: ingestion/src/metadata/ingestion/source/database/unitycatalog/queries.py:55-64, ingestion/src/metadata/ingestion/source/database/unitycatalog/queries.py:128

   The probe query (`UNITY_CATALOG_QUERY_HISTORY_PROBE`) exercises `statement_id`, `workspace_id` and `statement_text` on `system.query.history`, but the actual join in `UNITY_CATALOG_QUERY_HISTORY_JOIN` additionally references `history.start_time`. The stated purpose of the probe is to guarantee the main query won't fail on the history join and take the lineage down with it; since `start_time` is not covered, a schema mismatch on that column would pass the probe yet fail the whole native-lineage query, losing all lineage rather than just the SQL text. Consider referencing `start_time` (and statement columns) in the probe so its guarantee matches what the main query actually needs.

Options

Display: compact → Counting what did not apply, without listing it.

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

Compact
gitar display:verbose         

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

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

Labels

Ingestion safe to test Add this label to run secure Github workflows on PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Display SQL Query for Native Unity Catalog Lineage Edges

2 participants