You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
flowx already parses which pipeline calls which (ExecutePipeline) and which dataset each activity reads/writes (Copy source/sink, Lookup, Delete, GetMetadata) — but the discover inventory (metadata/inventory.json) throws that structure away. It records only per-pipeline, intra-pipeline activity DAGs; there is no cross-pipeline call graph and no dataset producer→consumer graph anywhere.
This issue adds a purely deterministic (no LLM) lineage extractor that assembles two graphs from the already-parsed AdfDefinitions and surfaces them in the inventory under a new lineage block:
Control lineage — ExecutePipeline caller→callee call graph.
Data lineage — dataset write→read (producer→consumer) edges, joined on resolved physical identity (schema.table or storage path) so two differently-named datasets pointing at the same table match.
This is the ground-truth substrate that #25 (agentic enrichment) and #24 (cross-pipeline deploy/run ordering) consume. Both are out of scope here.
Proposed solution
Follow repo conventions (AGENTS.md): @dataclass(slots=True, kw_only=True) models, deterministic extraction in the parser layer.
Extractor — new src/flowx/parser/lineage.py with build_lineage(definitions) -> Lineage:
Control edges: walk each pipeline's activity tree (recursing into ForEach/If/Switch) and, for each ExecutePipeline, read the callee referenceName + waitOnCompletion. Resolve callees case-insensitively against the pipeline set; record unresolved callees (partial export) with their raw name rather than dropping them.
Data edges: derive direction-aware (dataset_name, identity, direction) triples — sink/outputs ⇒ producer, source/inputs + Lookup/Delete/GetMetadata ⇒ consumer. Join producers→consumers on resolved identity (fall back to dataset_name when identity is None). Never guess: unresolvable identity ⇒ identity: null.
The physical-identity resolvers already exist in copy.py but run only at convert time. We commit to composition — extract them once into a shared module both phases import — not duplication into lineage.py.
New parser-layer module src/flowx/parser/dataset_resolvers.py becomes the single home for identity resolution: resolve_table_reference, resolve_dataset_path, resolve_dataset_linked_service_name, dataset_props, plus one composition entry point resolve_dataset_identity(dataset_ref, definitions, context=None) -> str | None.
Both translator/copy.py (convert) and the new parser/lineage.py (discover) import it. copy.py's call sites are unchanged apart from imports.
Why this is safe and clean (verified):
No import cycle:parser/ does not import translator/, and copy.py already imports from parser/, so a shared module in the parser layer is importable by both.
Runs at discover time: the resolvers' only convert dependency is TranslationContext, and every field of it has a default, so TranslationContext() constructs empty. Lineage passes an empty context; literals/defaults resolve, runtime-only expressions yield non-literals ⇒ identity: null.
Behavior-preserving: the extraction is a pure relocation; copy.py's existing convert/copy tests staying green is the proof.
Codegen-specific helpers (SinkPathInfo, _resolve_path_info, _LOCATION_URL_TEMPLATE, volume sanitisation) stay in copy.py.
Wiring:build_inventory calls build_lineage; _inventory_to_dict adds a sibling "lineage" key (always emits empty lists, never null) plus a new top-level "schema_version" marker.
In scope: deterministic control + data lineage in inventory.json; the model dataclasses; the extractor module; the shared resolver extraction; wiring into build_inventory/_inventory_to_dict; schema_version; tests.
Control: assert the exact caller→callee edge set from pipeline_execute_pipeline_nested.json (3 ExecutePipeline activities); add a fixture with an ExecutePipeline nested in a ForEach/If to lock in recursion; assert an unresolved callee is recorded.
Data: assert sink=producer / source=consumer direction, and that two differently-named datasets pointing at the same table join on identity; unresolvable identity ⇒ null and still joins on name.
Serialization:_inventory_to_dict emits schema_version + lineage, with empty lists (not null) when there are no edges.
The shared-resolver extraction is behavior-preserving: the existing convert/copy suite stays green.
Notes / risks
Identity resolution can be parameterized (@dataset().X); when not deterministically resolvable, emit identity: null.
Unresolved callees (partial exports) are recorded, not dropped, so the graph surfaces missing dependencies.
Problem
flowx already parses which pipeline calls which (
ExecutePipeline) and which dataset each activity reads/writes (Copy source/sink, Lookup, Delete, GetMetadata) — but the discover inventory (metadata/inventory.json) throws that structure away. It records only per-pipeline, intra-pipeline activity DAGs; there is no cross-pipeline call graph and no dataset producer→consumer graph anywhere.This issue adds a purely deterministic (no LLM) lineage extractor that assembles two graphs from the already-parsed
AdfDefinitionsand surfaces them in the inventory under a newlineageblock:ExecutePipelinecaller→callee call graph.schema.tableor storage path) so two differently-named datasets pointing at the same table match.This is the ground-truth substrate that #25 (agentic enrichment) and #24 (cross-pipeline deploy/run ordering) consume. Both are out of scope here.
Proposed solution
Follow repo conventions (
AGENTS.md):@dataclass(slots=True, kw_only=True)models, deterministic extraction in the parser layer.Models (
src/flowx/models/adf_ast.py, besideInventory):Add
lineage: Lineage | None = NonetoInventory.Extractor — new
src/flowx/parser/lineage.pywithbuild_lineage(definitions) -> Lineage:ForEach/If/Switch) and, for eachExecutePipeline, read the calleereferenceName+waitOnCompletion. Resolve callees case-insensitively against the pipeline set; record unresolved callees (partial export) with their raw name rather than dropping them.(dataset_name, identity, direction)triples — sink/outputs ⇒ producer, source/inputs + Lookup/Delete/GetMetadata ⇒ consumer. Join producers→consumers on resolvedidentity(fall back todataset_namewhen identity isNone). Never guess: unresolvable identity ⇒identity: null.Design decision: shared resolver module (composition) — committed
The physical-identity resolvers already exist in
copy.pybut run only at convert time. We commit to composition — extract them once into a shared module both phases import — not duplication intolineage.py.src/flowx/parser/dataset_resolvers.pybecomes the single home for identity resolution:resolve_table_reference,resolve_dataset_path,resolve_dataset_linked_service_name,dataset_props, plus one composition entry pointresolve_dataset_identity(dataset_ref, definitions, context=None) -> str | None.translator/copy.py(convert) and the newparser/lineage.py(discover) import it.copy.py's call sites are unchanged apart from imports.Why this is safe and clean (verified):
parser/does not importtranslator/, andcopy.pyalready imports fromparser/, so a shared module in the parser layer is importable by both.TranslationContext, and every field of it has a default, soTranslationContext()constructs empty. Lineage passes an empty context; literals/defaults resolve, runtime-only expressions yield non-literals ⇒identity: null.copy.py's existing convert/copy tests staying green is the proof.Codegen-specific helpers (
SinkPathInfo,_resolve_path_info,_LOCATION_URL_TEMPLATE, volume sanitisation) stay incopy.py.Wiring:
build_inventorycallsbuild_lineage;_inventory_to_dictadds a sibling"lineage"key (always emits empty lists, nevernull) plus a new top-level"schema_version"marker.JSON shape:
{ "schema_version": "2", "pipelines": [ /* unchanged */ ], "summary": { /* unchanged */ }, "lineage": { "control_edges": [ {"caller_pipeline": "ParentPipeline", "callee_pipeline": "ChildPipeline_A", "activity_name": "Run Child A", "wait_on_completion": true} ], "data_edges": [ {"dataset_name": "ds_curated_orders", "identity": "curated.orders", "producer_pipeline": "ChildPipeline_A", "producer_activity": "data extraction", "consumer_pipeline": "ParentPipeline", "consumer_activity": "Lookup latest"} ] } }Scope / non-goals
inventory.json; the model dataclasses; the extractor module; the shared resolver extraction; wiring intobuild_inventory/_inventory_to_dict;schema_version; tests.Testing
Deterministic, golden-graph style (matches
tests/unit/test_adf_loader.py):pipeline_execute_pipeline_nested.json(3ExecutePipelineactivities); add a fixture with anExecutePipelinenested in aForEach/Ifto lock in recursion; assert an unresolved callee is recorded.identity; unresolvable identity ⇒nulland still joins on name._inventory_to_dictemitsschema_version+lineage, with empty lists (notnull) when there are no edges.Notes / risks
@dataset().X); when not deterministically resolvable, emitidentity: null.reporting/coverage.pyreads onlypipelines/summaryand is unaffected.Relationship: deterministic foundation for #25 (agentic enrichment) and #24 (deploy ordering); both depend on this and are tracked separately.