Skip to content

fix(ingest/adf): recurse into ForEach/IfCondition/Until container activities for lineage extraction - #19488

Open
brock-acryl wants to merge 2 commits into
masterfrom
fix/adf-nested-container-activities-lineage
Open

fix(ingest/adf): recurse into ForEach/IfCondition/Until container activities for lineage extraction#19488
brock-acryl wants to merge 2 commits into
masterfrom
fix/adf-nested-container-activities-lineage

Conversation

@brock-acryl

@brock-acryl brock-acryl commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Root Cause: _process_pipelines() only iterated top-level pipeline activities. Container activity types (ForEach, IfCondition, Until, Switch) got a DataJob node but their nested activities list was never visited — so any CopyActivity/LookupActivity nested inside a ForEach produced no lineage edges.
  • Second issue: _extract_activity_inputs() had no handling for LookupActivity, which exposes its dataset via typeProperties.dataset.referenceName rather than the inputs attribute used by CopyActivity.

What changed:

  • Added recursive (BFS) processing: when activity.type is ForEach or Until, recurse into activity.activities; for IfCondition, recurse into both if_true_activities and if_false_activities; for Switch, recurse into each case's activities plus default_activities.
  • Added LookupActivity input extraction via dataset.reference_name.
  • Regenerated adf_foreach_golden.json and adf_branching_golden.json — both now include the previously-invisible inner DataJob nodes and their lineage edges.

Before: A Databricks → ForEach[Copy] → MSSQL pipeline showed no lineage (the CopyActivity inside ForEach was never processed)
After: CopyTableToStaging DataJob is emitted with full input/output lineage

Test plan

  • test_adf_source.py: 14/14 passed
  • test_complex_pipelines.py: 13/13 passed
  • adf_foreach_golden.json and adf_branching_golden.json updated to reflect new lineage emissions
  • ./gradlew :metadata-ingestion:lintFix — clean, no additional changes

Checklist

  • The PR conforms to DataHub's Contributing Guideline (PR Title Format)
  • Tests for the changes have been added/updated
  • Docs related to the changes have been added/updated (not applicable — internal bug fix, no user-facing config/behavior change beyond corrected lineage)

🤖 Generated with Claude Code


Summary by cubic

Fixes Azure Data Factory lineage extraction so Copy and Lookup activities nested inside container activities (ForEach, IfCondition, Until, Switch) now emit DataJob nodes with lineage edges. Previously only top-level pipeline activities were processed, so any nested activity produced no lineage.

  • Recurses into child activity lists per container type: ForEach/Until via activities, IfCondition via if_true_activities/if_false_activities, and Switch via each case's activities plus default_activities.
  • Traverses activities with a deque BFS and a visited-set guard, so malformed or self-referencing pipeline definitions can't loop forever.
  • Extracts Lookup inputs from dataset.reference_name instead of the inputs attribute used by Copy activities.
  • Regenerates adf_foreach_golden.json and adf_branching_golden.json to include the previously missing inner DataJobs.

Written for commit 6e6630d. Summary will update on new commits.

Review in cubic

…ner activities for lineage extraction

Container activities got a DataJob node but their nested activities list
was never visited, so any Copy/Lookup activity nested inside a ForEach,
IfCondition, Until, or Switch produced no lineage edges. Also add Lookup
activity input extraction, which exposes its dataset via
typeProperties.dataset.referenceName rather than the inputs attribute
used by Copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the ingestion PR or Issue related to the ingestion of metadata label Aug 27, 2026
@cursor

cursor Bot commented Aug 27, 2026

Copy link
Copy Markdown

PR Summary

Overview
Fixes incomplete Azure Data Factory lineage when Copy, Lookup, and similar activities live inside ForEach, IfCondition, Until, or Switch containers.

Pipeline processing no longer stops at top-level activities: it walks nested children with a BFS queue, enqueues children from a new _get_nested_activities helper per container type, and skips revisiting the same activity object to avoid infinite loops on bad definitions. Lookup activities now contribute input dataset URNs via dataset.reference_name, which the prior inputs path did not cover.

Integration golden files adf_foreach_golden.json and adf_branching_golden.json are updated so nested jobs (e.g. CopyTableToStaging, FullLoad, ProcessUSData) and their dataJobInputOutput edges appear in expected metadata.

Reviewed by Cursor Bugbot for commit 6e6630d. Bugbot is set up for automated code reviews on this repo. Configure here.

@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 6.45161% with 29 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
.../ingestion/source/azure_data_factory/adf_source.py 6.45% 29 Missing ⚠️

❌ Your patch check has failed because the patch coverage (6.45%) is below the target coverage (75.00%). You can increase the patch coverage or adjust the target coverage.

📢 Thoughts on this report? Let us know!

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

2 issues found across 3 files

You’re at about 90% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="metadata-ingestion/tests/integration/azure_data_factory/adf_foreach_golden.json">

<violation number="1" location="metadata-ingestion/tests/integration/azure_data_factory/adf_foreach_golden.json:227">
P2: The new GetTableList dataJobInputOutput aspect records systemMetadata runId 'adf-test-foreach', while the other three aspects of the same dataJob (dataPlatformInstance, dataJobInfo, subTypes) and the test's configured run_id all use 'adf-foreach-test'. Fix the added aspect to use 'adf-foreach-test' so system metadata stays consistent within the job and matches the golden snapshot's run.</violation>
</file>

<file name="metadata-ingestion/tests/integration/azure_data_factory/adf_branching_golden.json">

<violation number="1" location="metadata-ingestion/tests/integration/azure_data_factory/adf_branching_golden.json:239">
P2: The new nested-activity DataJob entities are recorded with runId "adf-test-branching", but this golden file is also validated by test_branching_pipeline in test_complex_pipelines.py, which runs with run_id "adf-branching-test". After this PR that test also recurses into the Switch/IfCondition branches, so its regenerated nested entities will carry "adf-branching-test" and will not match the entries added here. The golden appears to have been regenerated from only one of the two tests that share it. Verify that test_branching_pipeline still passes against this fixture (its output nested DataJobs carry a different run id) rather than relying on a stale regeneration.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

},
"systemMetadata": {
"lastObserved": 1705320000000,
"runId": "adf-test-foreach",

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 new GetTableList dataJobInputOutput aspect records systemMetadata runId 'adf-test-foreach', while the other three aspects of the same dataJob (dataPlatformInstance, dataJobInfo, subTypes) and the test's configured run_id all use 'adf-foreach-test'. Fix the added aspect to use 'adf-foreach-test' so system metadata stays consistent within the job and matches the golden snapshot's run.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At metadata-ingestion/tests/integration/azure_data_factory/adf_foreach_golden.json, line 227:

<comment>The new GetTableList dataJobInputOutput aspect records systemMetadata runId 'adf-test-foreach', while the other three aspects of the same dataJob (dataPlatformInstance, dataJobInfo, subTypes) and the test's configured run_id all use 'adf-foreach-test'. Fix the added aspect to use 'adf-foreach-test' so system metadata stays consistent within the job and matches the golden snapshot's run.</comment>

<file context>
@@ -209,6 +209,25 @@
+    },
+    "systemMetadata": {
+        "lastObserved": 1705320000000,
+        "runId": "adf-test-foreach",
+        "lastRunId": "no-run-id-provided"
+    }
</file context>
Suggested change
"runId": "adf-test-foreach",
"runId": "adf-foreach-test",

},
"systemMetadata": {
"lastObserved": 1705320000000,
"runId": "adf-test-branching",

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 new nested-activity DataJob entities are recorded with runId "adf-test-branching", but this golden file is also validated by test_branching_pipeline in test_complex_pipelines.py, which runs with run_id "adf-branching-test". After this PR that test also recurses into the Switch/IfCondition branches, so its regenerated nested entities will carry "adf-branching-test" and will not match the entries added here. The golden appears to have been regenerated from only one of the two tests that share it. Verify that test_branching_pipeline still passes against this fixture (its output nested DataJobs carry a different run id) rather than relying on a stale regeneration.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At metadata-ingestion/tests/integration/azure_data_factory/adf_branching_golden.json, line 239:

<comment>The new nested-activity DataJob entities are recorded with runId "adf-test-branching", but this golden file is also validated by test_branching_pipeline in test_complex_pipelines.py, which runs with run_id "adf-branching-test". After this PR that test also recurses into the Switch/IfCondition branches, so its regenerated nested entities will carry "adf-branching-test" and will not match the entries added here. The golden appears to have been regenerated from only one of the two tests that share it. Verify that test_branching_pipeline still passes against this fixture (its output nested DataJobs carry a different run id) rather than relying on a stale regeneration.</comment>

<file context>
@@ -221,6 +221,25 @@
+    },
+    "systemMetadata": {
+        "lastObserved": 1705320000000,
+        "runId": "adf-test-branching",
+        "lastRunId": "no-run-id-provided"
+    }
</file context>

Comment thread metadata-ingestion/src/datahub/ingestion/source/azure_data_factory/adf_source.py Outdated
@datahub-connector-tests

datahub-connector-tests Bot commented Aug 27, 2026

Copy link
Copy Markdown

Connector Tests Results

All connector tests passed for commit 6e6630d

View full test logs →

To skip connector tests, add the skip-connector-tests label (org members only).

Autogenerated by the connector-tests CI pipeline.

for activity in pipeline.activities or []:
# Emit activities as DataJobs, using BFS to recurse into container
# activities (ForEach, IfCondition, Until, Switch) so nested activities
# like Copy also get DataJobs with proper lineage.

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.

🟡 W1: list.pop(0) is O(n) per element — use collections.deque

BFS with list.pop(0) shifts the entire list left on each iteration, making this O(n²) for pipelines with many activities. deque.popleft() is O(1).

Suggested change
# like Copy also get DataJobs with proper lineage.
activities_to_process: deque[Activity] = deque(pipeline.activities or [])
while activities_to_process:
activity = activities_to_process.popleft()

(Add from collections import deque at the top of the file.)

ref_name = getattr(dataset_ref, "reference_name", None)
if ref_name:
dataset_urn = self._resolve_dataset_urn(ref_name, factory_key)
if dataset_urn:

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.

🟡 W2: Expression-based dataset references produce phantom URNs

The Lookup extraction (and the existing Copy extraction) resolves reference_name literally. When ADF datasets use parameterized references like @dataset().table_name (a very common ADF pattern), the resulting URN is:

urn:li:dataset:(urn:li:dataPlatform:mssql,{'value': '@dataset().table_name', 'type': 'Expression'},PROD)

These phantom datasets do not match any real ingested datasets, so the lineage graph shows edges to nonexistent entities — the UI renders no lineage.

Consider detecting expressions (reference names containing @ or {) and either:

  1. Skipping lineage emission with a self.report.warning(title="Expression-based dataset reference", message="...", context=f"dataset={ref_name}"), or
  2. Attempting to resolve the expression against the ADF dataset definitions to find the actual backing table name.


Returns a flat list of child activities.
"""
nested: list[Activity] = []

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.

ℹ️ S1: No cycle protection in BFS

If the Azure SDK ever returns a malformed pipeline where a container activity references itself (or two containers reference each other), this loop runs forever. A visited set is cheap defensive programming:

Suggested change
nested: list[Activity] = []
def _get_nested_activities(self, activity: Activity) -> list[Activity]:
"""Extract nested child activities from container activities."""

And in the BFS loop:

visited: set[str] = set()
while activities_to_process:
    activity = activities_to_process.popleft()
    activity_id = f"{activity.name}:{activity.type}"
    if activity_id in visited:
        continue
    visited.add(activity_id)
    ...

@maggiehays maggiehays added the pending-submitter-response Issue/request has been reviewed but requires a response from the submitter label Aug 27, 2026
…versal

Addresses review feedback on PR #19488: list.pop(0) made the BFS queue
O(n^2) for pipelines with many nested activities, and there was no
protection against a malformed/self-referencing pipeline definition
looping forever. Switches to collections.deque with popleft() (O(1))
and adds a visited-activities guard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ingestion PR or Issue related to the ingestion of metadata needs-review Label for PRs that need review from a maintainer.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants