Skip to content

Fixes 31278: Extract lineage from SQL-defined Databricks DLT pipelines - #31654

Merged
mohittilala merged 17 commits into
mainfrom
fix/databricks-sql-dlt-lineage
Sep 1, 2026
Merged

Fixes 31278: Extract lineage from SQL-defined Databricks DLT pipelines#31654
mohittilala merged 17 commits into
mainfrom
fix/databricks-sql-dlt-lineage

Conversation

@mohittilala

@mohittilala mohittilala commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Describe your changes:

Fixes #31278

The Databricks Pipeline connector discovers DLT lineage by exporting each pipeline's source and parsing it in kafka_parser.py. That parser only understood the Python DLT API, so a pipeline whose transformations are .sql files produced no lineage at all. Every file was skipped with Skipping lineage for this notebook - no DLT tables found, even though the SQL contained fully qualified, resolvable upstream references and both endpoints already existed in OpenMetadata.

This adds SQL support and fixes two neighbouring gaps in source discovery.

dlt_parsers.py (new)

  • A DltSourceParser protocol plus a dlt_parser_registry, following the registry convention in docs/design-patterns.md rather than an if/elif chain. Each language declares whether it handles a source and how to extract datasets from it, so a future language is one new class with no edits to the dispatch.
  • Parsers carry an explicit priority instead of relying on registration order. Python is checked first because a @dlt. decorator is unambiguous, whereas SQL keywords can appear inside a string literal in a Python notebook that calls spark.sql(...).
  • SqlDltParser recognises CREATE [OR REFRESH] [TEMPORARY|PRIVATE] MATERIALIZED VIEW, STREAMING TABLE, the legacy LIVE TABLE, and APPLY CHANGES INTO for CDC. Statements are split with sqlparse and handed to the existing LineageParser, so dialect handling, masking and timeouts are reused rather than reimplemented.
  • Three Databricks-specific shapes are normalised: STREAM(table) is unwrapped (a DLT marker, otherwise reported as a table called stream), the LIVE. prefix is stripped (a pipeline namespace, not a schema), and table-valued functions such as read_files and cloud_files are dropped since no entity backs them.
  • PythonDltParser is the existing implementation moved unchanged behind the same contract.
  • The lineage imports stay function-local to keep import cost unchanged for pipelines that never touch SQL.

kafka_parser.py

  • Reduced to Kafka source configuration, which is what its name says. get_pipeline_libraries now handles notebook, file and glob in one place and is finally used by the connector, replacing a duplicated inline copy that was previously dead test-only code.

models.py

  • KafkaSourceConfig and DLTTableDependency moved here, matching the convention that connector types live in models.py.

metadata.py

  • _qualify_dlt_table_name() splits a dataset reference into catalog, schema and table. SQL upstreams are either bare (a sibling dataset in the same pipeline, resolved against the pipeline's target catalog and schema) or fully qualified (resolved where they actually live). Previously a qualified reference was nested under the pipeline target, producing an unresolvable FQN like service.pipeline_catalog.pipeline_schema."raw_catalog.raw_schema.orders_raw".
  • spec.libraries entries of the form {"file": {"path": ...}} are now read. Pipelines sourced from Git folders or Asset Bundles declare files rather than notebooks, and those were previously ignored entirely.
  • _expand_workspace_directory() walks subdirectories. workspace/list returns immediate children only, so a glob such as /Repos/project/transformations/** previously missed anything nested. Recursion is depth-capped.

Type of change:

  • Bug fix

High-level design:

The parsing work is delegated to the existing LineageParser rather than a new regex. LineageParser already dispatches across the SqlFluff, SqlGlot and SqlParse analyzers and is used by other pipeline connectors (dbtcloud, airflow), so this extends the established pattern instead of adding a parallel one. sqlparse.split() handles files that declare several datasets, giving one target plus its upstreams per statement, which preserves the per-dataset dependency mapping that a whole-file parse would flatten.

Qualifier resolution is deliberately a small pure helper (_qualify_dlt_table_name) so it is directly testable and so the Python path, which only ever yields bare names, keeps its existing behaviour.

Backward compatibility: the SQL path is a fallback that only fires when the Python parser returns nothing, and the added library and directory handling are additive. No schema changes, no migrations.

Tests:

Use cases covered

  • A SQL DLT pipeline whose transformations are .sql files now produces table-to-table lineage with the pipeline attached in lineageDetails.
  • A bare upstream reference resolves against the pipeline's own catalog and schema.
  • A three-part qualified upstream keeps its own catalog and schema instead of being nested under the pipeline target.
  • The dataset name may appear on the line after CREATE OR REFRESH MATERIALIZED VIEW.
  • An existing Python DLT pipeline is unaffected.

Unit tests

  • I added unit tests for the new/changed logic.
  • Files added: ingestion/tests/unit/topology/pipeline/test_databricks_dlt_sql_lineage.py (25 tests)
  • The end-to-end test pins the set of tables that exist in OpenMetadata, so a wrongly built FQN drops the edge rather than silently resolving. Verified the assertions have teeth by reverting the qualifier fix behaviourally: edges drop from 6 to 3 and the two qualified-name tests fail.
  • 105 passed across the Databricks pipeline suite (80 pre-existing plus 25 new).
  • Because the refactor moved code between modules, a differential check ran the pre-refactor and post-refactor extractors over every Python DLT input the existing tests use plus the real notebook that motivated this work: 51/51 identical for dataset dependencies and 51/51 identical for Kafka sources.
  • 802 passed across all pipeline connector tests. Three test_airflow_connection.py failures and 13 collection errors across the wider unit suite are pre-existing in this environment (missing airflow, paramiko, pydoris and a missing generated openlineage module). The erroring module set is byte-identical with and without this change.

Backend integration tests

  • Not applicable (no backend API changes).

Ingestion integration tests

  • Not applicable. Covered by unit tests at the connector boundary, since exercising this end to end needs a live Databricks workspace with a SQL DLT pipeline.

Playwright (UI) tests

  • Not applicable (no UI changes).

Manual testing performed

Reproduced against payloads captured verbatim from a real failing agent run, replaying the recorded pipeline spec, workspace/list response and exported sources through the shipped connector with only the Databricks client stubbed:

  1. Before the change, the SQL DLT pipeline yielded 0 lineage edges, matching production exactly.
  2. After the change it yields 11 edges: 6 crossing from the upstream catalog into the pipeline's output schema, and 5 between sibling datasets in the same pipeline. Every endpoint was confirmed to exist.
    Edge cases were probed against real Databricks SQL rather than assumed: CDC via APPLY CHANGES INTO, the LIVE. prefix, Auto Loader table functions, backticked identifiers, CTEs, UNION, multi-statement files, and unparseable input. Each is covered by a test.
  3. The Python DLT pipeline in the same workspace still yields exactly 1 topic-to-table edge, unchanged.

Also ran make py_format_check (clean) and make static-checks. Static checks report the same 38 errors, 75 warnings with and without this change, so no new type errors are introduced.

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 changes.
  • For UI changes: not applicable.
  • 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

The PR adds SQL-defined Databricks DLT lineage extraction, consolidates pipeline-library discovery, qualifies SQL table references, and recursively discovers workspace sources.

  • Adds registry-based Python and SQL DLT source parsers.
  • Supports notebook, file, glob, and fallback source discovery.
  • Adds bounded workspace traversal, table lookup caching, and expanded connector tests.

Confidence Score: 3/5

The PR is not yet safe to merge because selective globs can add lineage from unrelated transformations and question-mark globs can omit all selected lineage.

The current normalization discards selective * suffixes and never recognizes ?, while directory expansion applies no original-pattern filter; these previously reported source-selection failures therefore remain at HEAD.

Files Needing Attention: ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/kafka_parser.py and ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py

Important Files Changed

Filename Overview
ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/dlt_parsers.py Introduces registry-dispatched Python and SQL DLT dependency extraction with Databricks-specific SQL normalization.
ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/kafka_parser.py Consolidates pipeline-library parsing, but selective and question-mark glob behavior remains incorrect.
ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py Integrates source discovery, recursive workspace expansion, qualified table resolution, and bounded lookup caches.
ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/models.py Centralizes DLT library, dependency, reference, and Kafka source models.
ingestion/tests/unit/topology/pipeline/test_databricks_dlt_sql_lineage.py Provides broad SQL lineage and source-discovery coverage, while codifying whole-tree expansion for selective wildcard inputs.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Spec[Databricks pipeline spec] --> Libraries[Resolve notebook, file, glob, or source_path]
  Libraries --> Expand[Expand workspace directories]
  Expand --> Export[Export source code]
  Export --> Dispatch{Recognized source language}
  Dispatch -->|Python DLT| Python[PythonDltParser]
  Dispatch -->|SQL DLT| SQL[SqlDltParser and LineageParser]
  Python --> Dependencies[DLT table dependencies]
  SQL --> Dependencies
  Dependencies --> Qualify[Qualify catalog, schema, and table]
  Qualify --> Lineage[Publish OpenMetadata lineage]
Loading

Reviews (13): Last reviewed commit: "fix(databricks): read a null libraries v..." | Re-trigger Greptile

SQL DLT transformations parsed to nothing because kafka_parser only
understood the Python DLT API, so those pipelines produced no lineage.
Parse CREATE [OR REFRESH] MATERIALIZED VIEW / STREAMING TABLE through
the existing LineageParser, resolve qualified upstreams against their
own catalog and schema, read `file` libraries, and recurse into
workspace subdirectories.
Adding SQL support as a fallback inside extract_dlt_table_dependencies
left an if/elif chain that dispatched on failure rather than language,
against the registry convention in docs/design-patterns.md.

Introduce dlt_parsers.py with a DltSourceParser protocol and registry.
Python and SQL each declare handles/extract and an explicit priority, so
a Python notebook embedding SQL strings is not misread and a new language
needs no dispatch edit. Move the parse-result dataclasses to models.py and
reduce kafka_parser.py to Kafka configuration.

Fix three Databricks SQL shapes found while probing real constructs:
APPLY CHANGES INTO lost its source, the LIVE. prefix was resolved as a
schema, and table-valued functions were returned as tables. Also use the
previously dead get_pipeline_libraries helper instead of a duplicated
inline copy.
@mohittilala mohittilala self-assigned this Aug 17, 2026
Copilot AI lite review requested due to automatic review settings August 17, 2026 18:17
@mohittilala
mohittilala requested a review from a team as a code owner August 17, 2026 18:17
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

✅ PR checks passed

The linked issue has a description and all required Shipping project fields set. Thanks!

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 extends the Databricks DLT (Delta Live Tables) pipeline ingestion to extract lineage from SQL-authored DLT pipelines (e.g., transformations defined as .sql files) while refactoring the existing Kafka/DLT parsing code into clearer, more modular components within the ingestion framework.

Changes:

  • Added a new DLT parser registry with dedicated SQL and Python parsers, enabling SQL-defined DLT dataset dependency extraction via the shared LineageParser.
  • Refactored Databricks pipeline lineage extraction to correctly resolve qualified upstream table references and to discover pipeline sources declared via file and recursive glob library entries.
  • Moved connector-specific dataclasses (KafkaSourceConfig, DLTTableDependency) into models.py and updated unit tests, including a new SQL lineage test suite.

Reviewed changes

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

Show a summary per file
File Description
ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/dlt_parsers.py Introduces parser registry + SQL DLT parsing (via LineageParser) and migrates the Python DLT parsing behind the same interface.
ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/kafka_parser.py Narrows to Kafka source parsing + consolidates pipeline library path extraction (notebook/file/glob).
ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py Uses new DLT dependency extraction, fixes dataset qualification for qualified upstreams, and expands workspace directory traversal recursively for glob sources.
ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/models.py Houses Kafka/DLT dataclasses alongside existing pipeline models for consistent connector structure.
ingestion/tests/unit/topology/pipeline/test_databricks_kafka_parser.py Updates tests to use the new parser module structure (e.g., PythonDltParser).
ingestion/tests/unit/topology/pipeline/test_databricks_dlt_sql_lineage.py Adds a focused unit test suite validating SQL DLT dependency extraction + end-to-end lineage edge emission.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py Outdated
Glob includes were only normalised for the `/**` form, so `*.sql` and
`**/*.sql` kept their wildcard and were exported as literal paths, missing
every transformation. Reduce any include to the directory before its first
wildcard, always ending in a slash.

Stripping the LIVE. prefix could collapse two references onto one dataset,
so de-duplicate upstreams while preserving order. Drop table-valued
functions only when the statement invokes them, so a dataset named `range`
still resolves. Strip whitespace and stray dots when qualifying a name.
Remove the dead DLT_TABLE_PATTERN and the unused get_pipeline_libraries
client parameter.
_qualify_dlt_table_name returned a bare (catalog, schema, table) tuple, so
callers had to know what each position meant. Return a DLTTableReference
instead, alongside the other connector models.

Replace the parser registry, the Protocol priority field and the per-call
sort with an explicit ordered DLT_PARSERS list. Two implementations did not
justify the indirection, and the previous approach also misused
enum_register, which everywhere else keys functions by an enum value rather
than classes by an ad-hoc string. Order is now stated where the list is
defined, and a test asserts it rather than assuming it.

Bound the entity caches with LRUCache, which CLAUDE.md requires and the
sibling Databricks ownership source already uses. Typing the cache surfaced
that fqn.build can return None and the old code cached under a None key, so
collapse the two duplicated cache blocks into a single _lookup_table helper
that guards it once.

Build the source through the real create() in tests instead of assigning its
internals. The hand-assigned caches had already drifted from production once
without any test failing. Cache bounds, eviction, miss caching and the None
guard are now asserted against the real object.
Copilot AI review requested due to automatic review settings August 18, 2026 00:57
@github-actions github-actions Bot added Ingestion safe to test Add this label to run secure Github workflows on PRs labels Aug 18, 2026

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 7 out of 7 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/dlt_parsers.py:92

  • SqlDltParser.handles() relies on patterns that match CREATE ... / APPLY CHANGES INTO anywhere in the source. This can misclassify non-SQL sources (e.g., a Python notebook containing those keywords inside a spark.sql("...") string) as SQL and attempt lineage extraction. Anchoring these patterns to the start of a statement (allowing leading SQL comments) reduces false positives without affecting real SQL transformations.
SQL_DLT_CREATE_PATTERN = re.compile(
    r"\bCREATE\s+(?:OR\s+REFRESH\s+)?(?:TEMPORARY\s+|PRIVATE\s+)?"
    r"(?:MATERIALIZED\s+VIEW|STREAMING\s+(?:LIVE\s+)?TABLE|LIVE\s+TABLE)\b",
    re.IGNORECASE,
)
SQL_DLT_APPLY_CHANGES_PATTERN = re.compile(r"\bAPPLY\s+CHANGES\s+INTO\b", re.IGNORECASE)

glob_base_directory reduced an include to its base directory and the caller
then expanded that whole tree, so a selective include such as
/transformations/*.sql pulled in unrelated files and attributed their
lineage to the pipeline. That is worse than the wildcard bug it replaced,
because the edges are wrong rather than absent.

Model a library entry as DLTLibrarySource, carrying the directory to list
and the pattern its contents must match. Recurse only for **, and check
every candidate with glob_matches, where * stays inside a path segment and
** spans them. fnmatch is unsuitable because its * also crosses /.

Tests now assert which files an include selects rather than which directory
it reduces to, which is what the earlier assertion missed.
Copilot AI review requested due to automatic review settings August 18, 2026 01:34
Comment thread ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py Outdated

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 7 out of 7 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/kafka_parser.py:211

  • glob_matches() supports ? wildcards, but glob_base_directory() only treats * as a wildcard. A pattern like /tx/file?.sql would be treated as a concrete file path (so no directory expansion/filtering), which is inconsistent with the matcher and the docstring (“first wildcard onward”). Consider treating ? as a wildcard too when computing the base directory.
    if "*" not in include:
        return include
    base = include.split("*", 1)[0]

ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/dlt_parsers.py:522

  • PR description mentions parsers carrying an explicit priority, but dispatch is currently order-dependent via the hard-coded DLT_PARSERS list. That’s fine functionally, but it contradicts the stated design and makes future additions rely on list ordering (which can be easy to miss in review). Either implement an explicit priority+sort in extract_dlt_table_dependencies, or update the design/description to match the order-based dispatch.
# Checked in order. Python comes first because a `@dlt.` decorator is unambiguous,
# while the SQL keywords can also appear inside a string literal in a Python
# notebook that calls spark.sql(...).
DLT_PARSERS: List[Type[DltSourceParser]] = [PythonDltParser, SqlDltParser]  # noqa: UP006

Comment thread ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py Outdated
get_pipeline_libraries returns DLTLibrarySource, but the spec.configuration
and spec.development source_path fallback still appended a raw string, so
iterating the list hit AttributeError on library.is_directory and lineage
died for pipelines that use those fields.

glob_base_directory only looked for *, while glob_matches also honours ?, so
an include such as /transformations/file_?.sql was treated as a concrete
path and sent to the export API literally. Both now share GLOB_WILDCARDS so
they cannot disagree again.
Copilot AI review requested due to automatic review settings August 18, 2026 01:53

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 7 out of 7 changed files in this pull request and generated 2 comments.

Comment thread ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py Outdated
Comment thread ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py Outdated
…neage test

The test stubbed _table_lookup_cache with a plain dict, which satisfied main's
implementation but not this branch's LRUCache. The lookup raised, the exception
was swallowed, and the two tests expecting an edge failed once main was merged in.
…ging

The libraries key can be present and null, and testing for the key rather than
reading it made len() raise before the source paths were collected, which cost the
pipeline its lineage. Per-path discovery also logged at INFO, which buries the run
summary now that expansion is recursive.
@gitar-bot

gitar-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown
Code Review ✅ Approved 5 resolved / 5 findings

Adds SQL-defined Databricks DLT lineage extraction by introducing a parser registry that handles both Python and SQL sources, with Databricks-specific normalizations for STREAM() unwrapping, LIVE. prefix stripping, and table-valued function filtering. Consolidates source-library discovery to handle notebook, file, and glob sources with recursive workspace expansion and proper qualification of bare and fully-qualified table references. All five issues—TVF filtering, unused parameters, glob pattern handling, source path fallback typing, and bare directory glob normalization—have been resolved. Verified against 25 new unit tests plus 51 differential checks against existing Python DLT inputs, with manual testing confirming 11 lineage edges from a previously failing SQL pipeline. No issues remain.

✅ 5 resolved
Edge Case: Real tables named like TVFs are dropped as upstreams

📄 ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/dlt_parsers.py:112 📄 ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/dlt_parsers.py:133-147
In dlt_parsers.py, _normalise drops any upstream whose name lowercases to a member of SQL_TABLE_VALUED_FUNCTIONS ({stream, read_files, cloud_files, read_kafka, read_kinesis, range}). This is name-only, so a legitimate DLT dataset literally named range or stream (unqualified) would be silently discarded from depends_on, losing that lineage edge. Consider only dropping these when they appear in call form (followed by () rather than as bare identifiers, since a resolvable entity reference never carries arguments.

Quality: Unused client parameter in get_pipeline_libraries

📄 ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/kafka_parser.py:197
get_pipeline_libraries(pipeline_config, client=None) never uses client; glob directories are expanded later by the caller via _expand_workspace_directory. The vestigial parameter is misleading (suggests the function itself expands globs). Drop the parameter or wire it through if inline expansion was intended.

Edge Case: Glob normalization mishandles nested file-extension patterns

📄 ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/kafka_parser.py:228-235 📄 ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py:744-752
get_pipeline_libraries normalizes a glob via include.replace('/**','/').replace('**',''). For a simple .../transformations/** this yields a trailing-slash directory that the caller expands. But an Asset Bundle/Git-folder glob like .../transformations/**/*.sql becomes .../transformations//*.sql, which does not end in /, so the caller (metadata.py:746) treats it as a direct file path and calls export_notebook_source on a non-existent path — the files are missed. If such patterns are possible, strip the pattern down to the base directory (everything before the first **) and append /.

Bug: source_path fallback appends raw str into DLTLibrarySource list

📄 ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py:753-755 📄 ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/metadata.py:775-783
When get_pipeline_libraries returns nothing, the spec.configuration.source_path / spec.development.source_path fallback appends a bare string to notebook_paths (line 755). This delta changed the consumer loop (line 775) to expect DLTLibrarySource, calling library.is_directory/library.path. A raw string has no is_directory, so the loop raises AttributeError, which is swallowed by the outer try/except, silently dropping all Kafka/DLT lineage for pipelines that declare their source via source_path instead of libraries — a regression from the pre-refactor string-based list. Wrap the fallback path in a DLTLibrarySource.

Edge Case: Glob include naming a bare directory (no wildcard, no slash) treated as a file

📄 ingestion/src/metadata/ingestion/source/pipeline/databrickspipeline/kafka_parser.py:301-311
For a glob include with no wildcard and no trailing slash (e.g. /Repos/project/transformations), is_glob_pattern is False so pattern=None, glob_base_directory returns the path unchanged, and is_directory = bool(None) or include.endswith('/') evaluates to False. The connector then treats it as a concrete file and calls export_notebook_source on a directory, so no transformation sources are discovered and lineage is silently dropped. The source_path path already normalises this via rstrip('/') + '/' and is_directory=True; consider the same normalisation, or verifying directory-ness via workspace/list, for slashless glob includes. Note this only bites if Databricks actually emits such a glob shape.

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 👍 / 👎 | Powered by Gitar — free for open source

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 8 out of 8 changed files in this pull request and generated no new comments.

@sonarqubecloud

Copy link
Copy Markdown

@mohittilala
mohittilala added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit 4490aef Sep 1, 2026
107 checks passed
@mohittilala
mohittilala deleted the fix/databricks-sql-dlt-lineage branch September 1, 2026 08:45
shrabantipaul-collate pushed a commit that referenced this pull request Sep 1, 2026
#31654)

* Fixes 31278: Extract lineage from SQL-defined Databricks DLT pipelines

SQL DLT transformations parsed to nothing because kafka_parser only
understood the Python DLT API, so those pipelines produced no lineage.
Parse CREATE [OR REFRESH] MATERIALIZED VIEW / STREAMING TABLE through
the existing LineageParser, resolve qualified upstreams against their
own catalog and schema, read `file` libraries, and recurse into
workspace subdirectories.

* refactor(databricks): dispatch DLT parsing through a language registry

Adding SQL support as a fallback inside extract_dlt_table_dependencies
left an if/elif chain that dispatched on failure rather than language,
against the registry convention in docs/design-patterns.md.

Introduce dlt_parsers.py with a DltSourceParser protocol and registry.
Python and SQL each declare handles/extract and an explicit priority, so
a Python notebook embedding SQL strings is not misread and a new language
needs no dispatch edit. Move the parse-result dataclasses to models.py and
reduce kafka_parser.py to Kafka configuration.

Fix three Databricks SQL shapes found while probing real constructs:
APPLY CHANGES INTO lost its source, the LIVE. prefix was resolved as a
schema, and table-valued functions were returned as tables. Also use the
previously dead get_pipeline_libraries helper instead of a duplicated
inline copy.

* fix(databricks): address PR review on DLT source discovery

Glob includes were only normalised for the `/**` form, so `*.sql` and
`**/*.sql` kept their wildcard and were exported as literal paths, missing
every transformation. Reduce any include to the directory before its first
wildcard, always ending in a slash.

Stripping the LIVE. prefix could collapse two references onto one dataset,
so de-duplicate upstreams while preserving order. Drop table-valued
functions only when the statement invokes them, so a dataset named `range`
still resolves. Strip whitespace and stray dots when qualifying a name.
Remove the dead DLT_TABLE_PATTERN and the unused get_pipeline_libraries
client parameter.

* refactor(databricks): model DLT references and simplify parser dispatch

_qualify_dlt_table_name returned a bare (catalog, schema, table) tuple, so
callers had to know what each position meant. Return a DLTTableReference
instead, alongside the other connector models.

Replace the parser registry, the Protocol priority field and the per-call
sort with an explicit ordered DLT_PARSERS list. Two implementations did not
justify the indirection, and the previous approach also misused
enum_register, which everywhere else keys functions by an enum value rather
than classes by an ad-hoc string. Order is now stated where the list is
defined, and a test asserts it rather than assuming it.

Bound the entity caches with LRUCache, which CLAUDE.md requires and the
sibling Databricks ownership source already uses. Typing the cache surfaced
that fqn.build can return None and the old code cached under a None key, so
collapse the two duplicated cache blocks into a single _lookup_table helper
that guards it once.

Build the source through the real create() in tests instead of assigning its
internals. The hand-assigned caches had already drifted from production once
without any test failing. Cache bounds, eviction, miss caching and the None
guard are now asserted against the real object.

* fix(databricks): select only the files a DLT glob actually matches

glob_base_directory reduced an include to its base directory and the caller
then expanded that whole tree, so a selective include such as
/transformations/*.sql pulled in unrelated files and attributed their
lineage to the pipeline. That is worse than the wildcard bug it replaced,
because the edges are wrong rather than absent.

Model a library entry as DLTLibrarySource, carrying the directory to list
and the pattern its contents must match. Recurse only for **, and check
every candidate with glob_matches, where * stays inside a path segment and
** spans them. fnmatch is unsuitable because its * also crosses /.

Tests now assert which files an include selects rather than which directory
it reduces to, which is what the earlier assertion missed.

* fix(databricks): keep library entries modelled and treat ? as a wildcard

get_pipeline_libraries returns DLTLibrarySource, but the spec.configuration
and spec.development source_path fallback still appended a raw string, so
iterating the list hit AttributeError on library.is_directory and lineage
died for pipelines that use those fields.

glob_base_directory only looked for *, while glob_matches also honours ?, so
an include such as /transformations/file_?.sql was treated as a concrete
path and sent to the export API literally. Both now share GLOB_WILDCARDS so
they cannot disagree again.

* fix(databricks): expand a pattern-less library directory in full

A configuration or development source_path produces a library with no
pattern, and treating that as non-recursive skipped every nested
transformation. No pattern means no filter, so the whole tree is in scope.

An include that names a bare directory was also stored as its own pattern,
which then matched nothing and dropped the directory entirely. Only attach
a pattern when the include actually contains a wildcard.

Cover every spec.libraries shape end to end so the reduction and the
matching cannot drift apart again.

* fix(databricks): state directory-ness instead of inferring it from the path

is_directory was read off a trailing slash, so a source_path that Databricks
spells without one was exported as a file and its transformations never
listed. The producer knows what each entry is, so it now says so: notebook
and file entries are files, a wildcard include is always a directory, and a
source_path is normalised to a directory.

is_recursive only looked for **, so a wildcard in a directory segment such as
/tx/2024_?/file.sql reduced to /tx/ and then refused to descend, selecting
nothing. Decide from the part of the pattern below the base directory, where
either ** or a / means traversal is required.

* fix(databricks): let the workspace settle an ambiguous glob include

A glob include with neither a wildcard nor a trailing slash may name a file
or a directory, and the string cannot tell them apart: assuming a file leaves
a directory unlisted, assuming a directory swallows a concrete .sql. Model
the gap instead, with is_directory None meaning the spec did not say, and
resolve it by listing the path. Anything that lists is a directory, anything
that does not is read as a single source.

* fix(databricks): read the legacy and private DLT create forms

CREATE LIVE TABLE, CREATE STREAMING LIVE TABLE and the PRIVATE modifier are
spellings DLT still accepts but the query parser cannot read. It falls back to a
bare command and reports no tables, so the dataset was dropped with neither an
error nor a warning.

Rewrite them to the modern equivalent before parsing, anchored to the CREATE
clause so a dataset whose own name contains "live" is left alone.

* refactor(databricks): drop the glob machinery Databricks cannot produce

The pipelines API accepts only an exact file, a directory, or a directory with a
trailing **, and rejects every other wildcard form outright. The pattern matcher,
the wildcard set and the recursive flag were therefore unreachable, along with the
tests asserting on them.

A library is now just a file or a directory taken in full.

* fix(databricks): describe library discovery as it behaves and lift the depth cap

The get_pipeline_libraries docstring still promised pattern filtering that no
longer exists, and the expansion cap of 5 silently dropped transformations nested
deeper than that, which costs the lineage depending on them.

Correct the docstring and raise the cap to 20. A directory that is not there costs
nothing to look for.

* fix(databricks): use the real lookup cache in the self-referencing lineage test

The test stubbed _table_lookup_cache with a plain dict, which satisfied main's
implementation but not this branch's LRUCache. The lookup raised, the exception
was swallowed, and the two tests expecting an edge failed once main was merged in.

* fix(databricks): read a null libraries value and quieten per-file logging

The libraries key can be present and null, and testing for the key rather than
reading it made len() raise before the source paths were collected, which cost the
pipeline its lineage. Per-path discovery also logged at INFO, which buries the run
summary now that expansion is recursive.
mohittilala added a commit that referenced this pull request Sep 2, 2026
#31654)

* Fixes 31278: Extract lineage from SQL-defined Databricks DLT pipelines

SQL DLT transformations parsed to nothing because kafka_parser only
understood the Python DLT API, so those pipelines produced no lineage.
Parse CREATE [OR REFRESH] MATERIALIZED VIEW / STREAMING TABLE through
the existing LineageParser, resolve qualified upstreams against their
own catalog and schema, read `file` libraries, and recurse into
workspace subdirectories.

* refactor(databricks): dispatch DLT parsing through a language registry

Adding SQL support as a fallback inside extract_dlt_table_dependencies
left an if/elif chain that dispatched on failure rather than language,
against the registry convention in docs/design-patterns.md.

Introduce dlt_parsers.py with a DltSourceParser protocol and registry.
Python and SQL each declare handles/extract and an explicit priority, so
a Python notebook embedding SQL strings is not misread and a new language
needs no dispatch edit. Move the parse-result dataclasses to models.py and
reduce kafka_parser.py to Kafka configuration.

Fix three Databricks SQL shapes found while probing real constructs:
APPLY CHANGES INTO lost its source, the LIVE. prefix was resolved as a
schema, and table-valued functions were returned as tables. Also use the
previously dead get_pipeline_libraries helper instead of a duplicated
inline copy.

* fix(databricks): address PR review on DLT source discovery

Glob includes were only normalised for the `/**` form, so `*.sql` and
`**/*.sql` kept their wildcard and were exported as literal paths, missing
every transformation. Reduce any include to the directory before its first
wildcard, always ending in a slash.

Stripping the LIVE. prefix could collapse two references onto one dataset,
so de-duplicate upstreams while preserving order. Drop table-valued
functions only when the statement invokes them, so a dataset named `range`
still resolves. Strip whitespace and stray dots when qualifying a name.
Remove the dead DLT_TABLE_PATTERN and the unused get_pipeline_libraries
client parameter.

* refactor(databricks): model DLT references and simplify parser dispatch

_qualify_dlt_table_name returned a bare (catalog, schema, table) tuple, so
callers had to know what each position meant. Return a DLTTableReference
instead, alongside the other connector models.

Replace the parser registry, the Protocol priority field and the per-call
sort with an explicit ordered DLT_PARSERS list. Two implementations did not
justify the indirection, and the previous approach also misused
enum_register, which everywhere else keys functions by an enum value rather
than classes by an ad-hoc string. Order is now stated where the list is
defined, and a test asserts it rather than assuming it.

Bound the entity caches with LRUCache, which CLAUDE.md requires and the
sibling Databricks ownership source already uses. Typing the cache surfaced
that fqn.build can return None and the old code cached under a None key, so
collapse the two duplicated cache blocks into a single _lookup_table helper
that guards it once.

Build the source through the real create() in tests instead of assigning its
internals. The hand-assigned caches had already drifted from production once
without any test failing. Cache bounds, eviction, miss caching and the None
guard are now asserted against the real object.

* fix(databricks): select only the files a DLT glob actually matches

glob_base_directory reduced an include to its base directory and the caller
then expanded that whole tree, so a selective include such as
/transformations/*.sql pulled in unrelated files and attributed their
lineage to the pipeline. That is worse than the wildcard bug it replaced,
because the edges are wrong rather than absent.

Model a library entry as DLTLibrarySource, carrying the directory to list
and the pattern its contents must match. Recurse only for **, and check
every candidate with glob_matches, where * stays inside a path segment and
** spans them. fnmatch is unsuitable because its * also crosses /.

Tests now assert which files an include selects rather than which directory
it reduces to, which is what the earlier assertion missed.

* fix(databricks): keep library entries modelled and treat ? as a wildcard

get_pipeline_libraries returns DLTLibrarySource, but the spec.configuration
and spec.development source_path fallback still appended a raw string, so
iterating the list hit AttributeError on library.is_directory and lineage
died for pipelines that use those fields.

glob_base_directory only looked for *, while glob_matches also honours ?, so
an include such as /transformations/file_?.sql was treated as a concrete
path and sent to the export API literally. Both now share GLOB_WILDCARDS so
they cannot disagree again.

* fix(databricks): expand a pattern-less library directory in full

A configuration or development source_path produces a library with no
pattern, and treating that as non-recursive skipped every nested
transformation. No pattern means no filter, so the whole tree is in scope.

An include that names a bare directory was also stored as its own pattern,
which then matched nothing and dropped the directory entirely. Only attach
a pattern when the include actually contains a wildcard.

Cover every spec.libraries shape end to end so the reduction and the
matching cannot drift apart again.

* fix(databricks): state directory-ness instead of inferring it from the path

is_directory was read off a trailing slash, so a source_path that Databricks
spells without one was exported as a file and its transformations never
listed. The producer knows what each entry is, so it now says so: notebook
and file entries are files, a wildcard include is always a directory, and a
source_path is normalised to a directory.

is_recursive only looked for **, so a wildcard in a directory segment such as
/tx/2024_?/file.sql reduced to /tx/ and then refused to descend, selecting
nothing. Decide from the part of the pattern below the base directory, where
either ** or a / means traversal is required.

* fix(databricks): let the workspace settle an ambiguous glob include

A glob include with neither a wildcard nor a trailing slash may name a file
or a directory, and the string cannot tell them apart: assuming a file leaves
a directory unlisted, assuming a directory swallows a concrete .sql. Model
the gap instead, with is_directory None meaning the spec did not say, and
resolve it by listing the path. Anything that lists is a directory, anything
that does not is read as a single source.

* fix(databricks): read the legacy and private DLT create forms

CREATE LIVE TABLE, CREATE STREAMING LIVE TABLE and the PRIVATE modifier are
spellings DLT still accepts but the query parser cannot read. It falls back to a
bare command and reports no tables, so the dataset was dropped with neither an
error nor a warning.

Rewrite them to the modern equivalent before parsing, anchored to the CREATE
clause so a dataset whose own name contains "live" is left alone.

* refactor(databricks): drop the glob machinery Databricks cannot produce

The pipelines API accepts only an exact file, a directory, or a directory with a
trailing **, and rejects every other wildcard form outright. The pattern matcher,
the wildcard set and the recursive flag were therefore unreachable, along with the
tests asserting on them.

A library is now just a file or a directory taken in full.

* fix(databricks): describe library discovery as it behaves and lift the depth cap

The get_pipeline_libraries docstring still promised pattern filtering that no
longer exists, and the expansion cap of 5 silently dropped transformations nested
deeper than that, which costs the lineage depending on them.

Correct the docstring and raise the cap to 20. A directory that is not there costs
nothing to look for.

* fix(databricks): use the real lookup cache in the self-referencing lineage test

The test stubbed _table_lookup_cache with a plain dict, which satisfied main's
implementation but not this branch's LRUCache. The lookup raised, the exception
was swallowed, and the two tests expecting an edge failed once main was merged in.

* fix(databricks): read a null libraries value and quieten per-file logging

The libraries key can be present and null, and testing for the key rather than
reading it made len() raise before the source paths were collected, which cost the
pipeline its lineage. Per-path discovery also logged at INFO, which buries the run
summary now that expansion is recursive.
Rohit0301 pushed a commit that referenced this pull request Sep 3, 2026
#31654)

* Fixes 31278: Extract lineage from SQL-defined Databricks DLT pipelines

SQL DLT transformations parsed to nothing because kafka_parser only
understood the Python DLT API, so those pipelines produced no lineage.
Parse CREATE [OR REFRESH] MATERIALIZED VIEW / STREAMING TABLE through
the existing LineageParser, resolve qualified upstreams against their
own catalog and schema, read `file` libraries, and recurse into
workspace subdirectories.

* refactor(databricks): dispatch DLT parsing through a language registry

Adding SQL support as a fallback inside extract_dlt_table_dependencies
left an if/elif chain that dispatched on failure rather than language,
against the registry convention in docs/design-patterns.md.

Introduce dlt_parsers.py with a DltSourceParser protocol and registry.
Python and SQL each declare handles/extract and an explicit priority, so
a Python notebook embedding SQL strings is not misread and a new language
needs no dispatch edit. Move the parse-result dataclasses to models.py and
reduce kafka_parser.py to Kafka configuration.

Fix three Databricks SQL shapes found while probing real constructs:
APPLY CHANGES INTO lost its source, the LIVE. prefix was resolved as a
schema, and table-valued functions were returned as tables. Also use the
previously dead get_pipeline_libraries helper instead of a duplicated
inline copy.

* fix(databricks): address PR review on DLT source discovery

Glob includes were only normalised for the `/**` form, so `*.sql` and
`**/*.sql` kept their wildcard and were exported as literal paths, missing
every transformation. Reduce any include to the directory before its first
wildcard, always ending in a slash.

Stripping the LIVE. prefix could collapse two references onto one dataset,
so de-duplicate upstreams while preserving order. Drop table-valued
functions only when the statement invokes them, so a dataset named `range`
still resolves. Strip whitespace and stray dots when qualifying a name.
Remove the dead DLT_TABLE_PATTERN and the unused get_pipeline_libraries
client parameter.

* refactor(databricks): model DLT references and simplify parser dispatch

_qualify_dlt_table_name returned a bare (catalog, schema, table) tuple, so
callers had to know what each position meant. Return a DLTTableReference
instead, alongside the other connector models.

Replace the parser registry, the Protocol priority field and the per-call
sort with an explicit ordered DLT_PARSERS list. Two implementations did not
justify the indirection, and the previous approach also misused
enum_register, which everywhere else keys functions by an enum value rather
than classes by an ad-hoc string. Order is now stated where the list is
defined, and a test asserts it rather than assuming it.

Bound the entity caches with LRUCache, which CLAUDE.md requires and the
sibling Databricks ownership source already uses. Typing the cache surfaced
that fqn.build can return None and the old code cached under a None key, so
collapse the two duplicated cache blocks into a single _lookup_table helper
that guards it once.

Build the source through the real create() in tests instead of assigning its
internals. The hand-assigned caches had already drifted from production once
without any test failing. Cache bounds, eviction, miss caching and the None
guard are now asserted against the real object.

* fix(databricks): select only the files a DLT glob actually matches

glob_base_directory reduced an include to its base directory and the caller
then expanded that whole tree, so a selective include such as
/transformations/*.sql pulled in unrelated files and attributed their
lineage to the pipeline. That is worse than the wildcard bug it replaced,
because the edges are wrong rather than absent.

Model a library entry as DLTLibrarySource, carrying the directory to list
and the pattern its contents must match. Recurse only for **, and check
every candidate with glob_matches, where * stays inside a path segment and
** spans them. fnmatch is unsuitable because its * also crosses /.

Tests now assert which files an include selects rather than which directory
it reduces to, which is what the earlier assertion missed.

* fix(databricks): keep library entries modelled and treat ? as a wildcard

get_pipeline_libraries returns DLTLibrarySource, but the spec.configuration
and spec.development source_path fallback still appended a raw string, so
iterating the list hit AttributeError on library.is_directory and lineage
died for pipelines that use those fields.

glob_base_directory only looked for *, while glob_matches also honours ?, so
an include such as /transformations/file_?.sql was treated as a concrete
path and sent to the export API literally. Both now share GLOB_WILDCARDS so
they cannot disagree again.

* fix(databricks): expand a pattern-less library directory in full

A configuration or development source_path produces a library with no
pattern, and treating that as non-recursive skipped every nested
transformation. No pattern means no filter, so the whole tree is in scope.

An include that names a bare directory was also stored as its own pattern,
which then matched nothing and dropped the directory entirely. Only attach
a pattern when the include actually contains a wildcard.

Cover every spec.libraries shape end to end so the reduction and the
matching cannot drift apart again.

* fix(databricks): state directory-ness instead of inferring it from the path

is_directory was read off a trailing slash, so a source_path that Databricks
spells without one was exported as a file and its transformations never
listed. The producer knows what each entry is, so it now says so: notebook
and file entries are files, a wildcard include is always a directory, and a
source_path is normalised to a directory.

is_recursive only looked for **, so a wildcard in a directory segment such as
/tx/2024_?/file.sql reduced to /tx/ and then refused to descend, selecting
nothing. Decide from the part of the pattern below the base directory, where
either ** or a / means traversal is required.

* fix(databricks): let the workspace settle an ambiguous glob include

A glob include with neither a wildcard nor a trailing slash may name a file
or a directory, and the string cannot tell them apart: assuming a file leaves
a directory unlisted, assuming a directory swallows a concrete .sql. Model
the gap instead, with is_directory None meaning the spec did not say, and
resolve it by listing the path. Anything that lists is a directory, anything
that does not is read as a single source.

* fix(databricks): read the legacy and private DLT create forms

CREATE LIVE TABLE, CREATE STREAMING LIVE TABLE and the PRIVATE modifier are
spellings DLT still accepts but the query parser cannot read. It falls back to a
bare command and reports no tables, so the dataset was dropped with neither an
error nor a warning.

Rewrite them to the modern equivalent before parsing, anchored to the CREATE
clause so a dataset whose own name contains "live" is left alone.

* refactor(databricks): drop the glob machinery Databricks cannot produce

The pipelines API accepts only an exact file, a directory, or a directory with a
trailing **, and rejects every other wildcard form outright. The pattern matcher,
the wildcard set and the recursive flag were therefore unreachable, along with the
tests asserting on them.

A library is now just a file or a directory taken in full.

* fix(databricks): describe library discovery as it behaves and lift the depth cap

The get_pipeline_libraries docstring still promised pattern filtering that no
longer exists, and the expansion cap of 5 silently dropped transformations nested
deeper than that, which costs the lineage depending on them.

Correct the docstring and raise the cap to 20. A directory that is not there costs
nothing to look for.

* fix(databricks): use the real lookup cache in the self-referencing lineage test

The test stubbed _table_lookup_cache with a plain dict, which satisfied main's
implementation but not this branch's LRUCache. The lookup raised, the exception
was swallowed, and the two tests expecting an edge failed once main was merged in.

* fix(databricks): read a null libraries value and quieten per-file logging

The libraries key can be present and null, and testing for the key rather than
reading it made len() raise before the source paths were collected, which cost the
pipeline its lineage. Per-path discovery also logged at INFO, which buries the run
summary now that expansion is recursive.
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.

Databricks Pipeline: SQL-defined DLT pipelines yield no lineage and are skipped silently

4 participants