Repository navigation
refactor: add source-neutral discovery graph contract and shared lineage core - #84
matthewmoorcroft wants to merge 19 commits into
Conversation
PR A of a two-PR split: the DISCOVERY side only. Defines the shared, source-neutral standard that both ADF and Airflow map onto, with zero convert/IR-Activity coupling. The convert->package lineage plumbing is deferred to the stacked follow-up PR. - models/ir.py: the five standalone, source-neutral lineage TYPES only -- DataAsset, ControlEdge, DataEdge, MotifAnnotation, Lineage. The Activity-base fields (data_reads/data_writes/motif_id), the MotifActivity base-field change, and Pipeline.lineage are NOT here (deferred). MotifAnnotation's docstring no longer references the deferred Activity.motif_id. - lineage.py: only the neutral primitive cores control_edges_from_calls, _match_assets, data_edges_from_endpoints (task_key strings + DataAsset only, no Activity/Pipeline import). The IR-Activity-facing build_* layer is deferred. - ir_serde.py: only the neutral helpers data_asset_to_dict / data_asset_from_dict / lineage_to_dict. No Pipeline.lineage or Activity-base emission and no MotifActivity change (deferred). - models/discovery.py, discovery_lineage.py, discovery_serde.py: the full shared discovery AST -- SourceGraph/SourceNode/ContainerNode/GapNode + ScheduleSpec/ ParameterSpec/PolicySpec/SourceDependency, the neutrality fields (run_condition, default_policy, run_timeout_seconds, open best-effort data-asset vocabulary), source-neutral graph lineage derivation, and serde. - tests: test_discovery_model.py + test_discovery_lineage.py only. No changes to bundler/dab_writer.py. Rebased onto main so the airflow-loader split (#54/#55) is not spuriously reverted. Co-authored-by: Isaac <no-reply@databricks.com>
…ineage (#61) with_graph_lineage's docstring referenced :func:`flowx.lineage.with_lineage`, which lives only in the stacked conversion PR -- the discovery-only PR's reduced lineage.py has just the neutral cores. Reword to describe the behaviour in place (pure; attaches via dataclasses.replace) so this PR references nothing external to it. Docstring only; no import, call, or behaviour change. Co-authored-by: Isaac <no-reply@databricks.com>
…ract Rename the "shared discovery AST" wording in docstrings to "discovery graph contract" and say plainly that each source keeps its own parser and projects into SourceGraph. Wording only; no symbols change. Co-authored-by: Isaac <no-reply@databricks.com>
source_graphs_document / write_source_graphs build metadata/source_graphs.json
with the same {contract_version, source, graphs} keys Airflow discover already
writes, plus graph_sha256 and document_sha256 over canonical JSON.
read_source_graphs accepts any contract_version "1" file, including one
without hashes, and fails closed when a recorded hash does not match.
Co-authored-by: Isaac <no-reply@databricks.com>
Move the source-agnostic inventory projection into the base PR so ADF and Airflow use one module: motifs_by_pipeline from the ADF discovery work, the inventory_visible filter from the Airflow source-graph work (same signature), and task_key on every activity entry so source layers and motif members can join on it. Carries both test sets. Co-authored-by: Isaac <no-reply@databricks.com>
… inventory Motifs are deterministic facts about the source, so a source now records them on the graph (SourceGraph.lineage.motifs) and they are saved and hashed with it in source_graphs.json. The shared emitter reads them from there instead of a separate argument, keeps the inventory's own lineage block motif slot empty as before, and records the source_graphs document hash as a top-level source_graphs_sha256 when the caller passes it. MotifAnnotation gains an optional source_type_hint, written only when set, so existing IR lineage output stays byte-identical. Co-authored-by: Isaac <no-reply@databricks.com>
… excluded from the block
…to stack/routing-agentic Co-authored-by: Isaac <no-reply@databricks.com>
A signature-tier data edge no longer borrows the one resolved endpoint's identity,
so a weak match never looks physically proven. An empty PolicySpec, serialised as
{}, now comes back as PolicySpec() instead of None, for graph default policies and
node policies alike. Both have regression tests that fail on the previous code.
Co-authored-by: Isaac <no-reply@databricks.com>
… stack/adf-discovery Carries #84's Codex fixes forward: signature-tier data edges claim no identity, and empty policies survive the source-graph round trip. Co-authored-by: Isaac <no-reply@databricks.com>
…ntic-insights Carries #84's and #94's Codex fixes forward: identity-free signature matches, empty policy round trips, grammar-based ARM detection, parameterised file locations, no identity for run-time source or target overrides, and the run-now control-edge skip. Co-authored-by: Isaac <no-reply@databricks.com>
ghanse
left a comment
There was a problem hiding this comment.
Looking good, requested a few changes. The contract and shared lineage core are clean and well tested. Two things should be fixed before merge: a named but missing callee still gets marked as resolved, and the hash check can be skipped by deleting the hash fields. The other comments are smaller.
| target_workflow=target_name, | ||
| via_task_key=via_task_key, | ||
| wait_for_completion=wait, | ||
| resolved=bool(target_name), |
There was a problem hiding this comment.
Any call with a name gets resolved=True, even when that pipeline isn't in the export. So a call to a missing pipeline looks resolved. Could the caller pass the set of known workflows?
resolved=target_name in known_workflows,| if not isinstance(graphs, list): | ||
| raise ValueError("source_graphs document has no 'graphs' list") | ||
| recorded_graph_hashes = document.get("graph_sha256") | ||
| if recorded_graph_hashes is not None: |
There was a problem hiding this comment.
Both hash checks are skipped when the field is missing, so deleting graph_sha256 and document_sha256 gets an edited file past verification. Since v1 always writes them, I think the reader should require them:
if recorded_graph_hashes is None:
raise ValueError("source_graphs document is missing graph_sha256")|
|
||
| edges: list[DataEdge] = [] | ||
| seen: set[tuple[str, str, str, str]] = set() | ||
| for producer_key, producer_asset in producer_list: |
There was a problem hiding this comment.
Non-blocking: this pairs every writer with every reader of the same asset, no matter the run order. So two parallel writers, or a writer that runs after the reader, still get a "proven" hand-off. It's worth a short comment saying so, plus tests for multiple-writer and reversed-order cases.
| if concept: | ||
| common["concept"] = concept | ||
|
|
||
| node_type = raw.get("node_type", "SourceNode") |
There was a problem hiding this comment.
Non-blocking: an unknown node_type quietly becomes a SourceNode and drops any extra fields. Raising a ValueError for anything other than the three known kinds would be safer.
| raise ValueError( | ||
| f"Unsupported source_graphs contract_version {version!r}; expected {SOURCE_GRAPHS_CONTRACT_VERSION!r}" | ||
| ) | ||
| graphs = document.get("graphs") |
There was a problem hiding this comment.
Small suggestion: check that each graph's source matches the document's source. Right now a document labeled adf can hold Airflow graphs and still pass every hash check.
…ode types - control_edges_from_calls / build_graph_control_edges / build_graph_lineage take a keyword-only known_workflows; a named callee missing from the export stays unresolved (same signatures and rule as the ADF discovery layer above). - source_graphs reader requires graph_sha256 and document_sha256 and refuses a graph whose source differs from the document's. - An unknown node_type raises instead of becoming a plain SourceNode. - Note that data hand-offs ignore run order, with tests for parallel writers and a writer that runs after its reader. Co-authored-by: Isaac <no-reply@databricks.com>
source_graphs_document (and so write_source_graphs) now raises the same ValueError the reader raises when a graph's source differs from the document's, so discover cannot save a file a later phase would refuse. Co-authored-by: Isaac <no-reply@databricks.com>
…rom another source
|
The requested changes are applied at fb38c14 (fix commit 6d41efb, plus follow-ups):
Each fix has a test that failed on the previous code, except point 3, whose tests pin the existing behaviour as you asked. |
The only conflict was the lineage.py import block; the merged file keeps both sides' imports. The shared core (control_edges_from_calls, data_edges_from_endpoints, _match_assets) matches #84's code. Co-authored-by: Isaac <no-reply@databricks.com>
ghanse
left a comment
There was a problem hiding this comment.
Looking good. Found a few additional items we should address before merging.
| target_workflow=target_name, | ||
| via_task_key=via_task_key, | ||
| wait_for_completion=wait, | ||
| resolved=bool(target_name) and (known_workflows is None or target_name in known_workflows), |
There was a problem hiding this comment.
Thanks, this works when known_workflows is passed, but it still defaults to None, and then any named call counts as resolved again. Nothing in src/ passes it yet, so that's the path real code takes. Could we make it required, or treat None as "unknown"?
resolved=bool(target_name) and known_workflows is not None and target_name in known_workflows,| } | ||
| _check_graph_sources(document) | ||
| document["graph_sha256"] = [_canonical_sha256(graph) for graph in document["graphs"]] | ||
| document["document_sha256"] = _canonical_sha256({key: document[key] for key in _HASHED_DOCUMENT_KEYS}) |
There was a problem hiding this comment.
The docstring says document_sha256 covers the whole envelope, but it only hashes contract_version, source and graphs. Extra top-level keys get through without being hashed or rejected. Either reject unknown keys on read or fix the docstring.
doc["extra"] = {"injected": True}
source_graphs_from_document(doc) # still passes| node for node in _flatten_nodes(graph.tasks) if node.properties.get(INVENTORY_VISIBLE_PROPERTY, True) | ||
| ] | ||
| for node in flattened: | ||
| strategy = node.properties.get(STRATEGY_PROPERTY) |
There was a problem hiding this comment.
A node with no strategy, or one we don't recognize, counts toward unsupported_count, but its activity entry still shows the raw value (None or whatever it was). So the summary and the activity list disagree. Could the entry say "unsupported" too?
| Returns: | ||
| A shallow copy of *graph* with :attr:`SourceGraph.lineage` populated. | ||
| """ | ||
| return dataclasses.replace(graph, lineage=build_graph_lineage(graph, known_workflows=known_workflows)) |
There was a problem hiding this comment.
with_graph_lineage swaps in a brand-new Lineage, so any motifs already on the graph are quietly lost. Worth keeping them, plus a test that calls it after motifs are attached.
motifs = graph.lineage.motifs if graph.lineage else []| return inventory | ||
|
|
||
|
|
||
| def _flatten_nodes(nodes: list[SourceNode]) -> list[SourceNode]: |
There was a problem hiding this comment.
Suggestion: _flatten_nodes does the same walk as walk_nodes in discovery_lineage.py. Could this just be list(walk_nodes(nodes)), so the two can't drift apart?
…y strategy - control_edges_from_calls treats known_workflows=None as unknown, so a named callee is resolved only when the export's workflow names confirm it. - The source_graphs reader rejects top-level keys outside the envelope and its two hashes, and the docstrings say exactly what document_sha256 covers. - A node with a missing or unrecognised strategy is listed as "unsupported" in its inventory entry, matching the summary count. - with_graph_lineage keeps motifs already recorded on the graph. - The inventory walks nodes with discovery_lineage.walk_nodes instead of its own copy of the same walk. Co-authored-by: Isaac <no-reply@databricks.com>
Stack-wide review pass against the reviewer's checklist: - an empty identity no longer joins on the identity tier, and a signature match needs the same asset_type when both sides name one; - a non-string invoke target is an empty, unresolved callee, and a non-boolean wait flag is unknown; - source_graphs.json reads a missing or non-boolean resolved as unresolved, rejects branches or reason on the wrong node kind, and raises ValueError for a non-object document or a malformed graph. Co-authored-by: Isaac <no-reply@databricks.com>
|
The second round of changes is applied at bec41c5:
Points 1 to 4 each have a test that failed on the previous code. Point 5 is a refactor covered by the existing ordering test. I also went over the whole diff again, against the themes from both of your reviews here and on #94, and fixed a few more of the same kind:
Each has a test that failed before the change. |
Brings in #84's second review round: fail-closed known_workflows, empty identities as unresolved, the exact asset_type rule, and the strict envelope. discovery_serde conflict: take #94's side, which reads lineage through the shared ir_serde.lineage_from_dict, and keep #84's new asset_type sentence in the module docstring. #84's wait_for_completion rule moves into ir_serde in the next commit. Co-authored-by: Isaac <no-reply@databricks.com>
…workflows #84's final head reads a control edge's wait_for_completion as None unless it is a real boolean. #94 reads lineage through the shared ir_serde.lineage_from_dict, so the rule now lives there, covered by a direct test that fails without it and by #84's source-graph tests. #84 also makes an omitted known_workflows fail closed, so the IR test for a RunJobActivity control edge passes the workflows it expects to resolve. Co-authored-by: Isaac <no-reply@databricks.com>
ghanse
left a comment
There was a problem hiding this comment.
Good groundwork. The lineage rules and hash checks are solid and well tested, and I didn't find any bugs. Add the nested-container tests before the source PRs build on this.
| lost. Every graph and node carries: | ||
|
|
||
| * ``raw`` -- the verbatim source dict (the ADF activity/pipeline JSON, the | ||
| Airflow capture), so a mapper can always fall back to the original; and | ||
| * ``properties`` / ``extensions`` -- a free-form bag for platform-specific | ||
| attributes that have no shared typed field yet. |
There was a problem hiding this comment.
This says every graph and node has properties / extensions, but SourceNode only has properties and raw. Please fix the wording here and in the PR description so later mappers don't go looking for a node extensions field.
| if producer.identity and consumer.identity: | ||
| if producer.identity == consumer.identity: | ||
| return "identity", producer.identity, producer.identity | ||
| return None | ||
| if producer.asset_type and consumer.asset_type and producer.asset_type != consumer.asset_type: | ||
| return None | ||
| if producer.signature and producer.signature == consumer.signature: | ||
| # A signature match proves nothing physical, so it never claims either side's identity. | ||
| return "signature", producer.signature, None |
There was a problem hiding this comment.
An identity match skips the asset_type check that the signature match does, so a "table" and a "file" with the same identity still get linked. Either check the type here too or note why it's fine.
| key = (source_workflow, target_name, via_task_key) | ||
| if key in seen: |
There was a problem hiding this comment.
Suggestion: The dedupe key leaves out wait_for_completion, so two edges that differ only in that setting quietly become one, and the first one wins. Fine for now, but add wait to the key or say that the first one wins.
| def test_data_edges_recurse_into_switch_and_foreach_branches() -> None: | ||
| """A writer buried in a Switch case hands off to a reader in a ForEach body.""" | ||
| writer = _node("writer", writes=[DataAsset(signature="ds", identity="x.y")]) | ||
| reader = _node("reader", reads=[DataAsset(signature="ds", identity="x.y")]) | ||
| switch = ContainerNode( | ||
| source_id="sw", | ||
| task_key="sw", | ||
| concept="switch", | ||
| source=SOURCE_ADF, | ||
| branches={"caseA": [writer], "default": []}, | ||
| ) | ||
| loop = ContainerNode( | ||
| source_id="fe", | ||
| task_key="fe", | ||
| concept="loop", | ||
| source=SOURCE_ADF, | ||
| branches={"body": [reader]}, | ||
| ) | ||
| edges = build_graph_lineage(_graph(switch, loop)).data_edges | ||
| assert [(edge.source_task_key, edge.target_task_key) for edge in edges] == [("writer", "reader")] | ||
|
|
||
|
|
||
| def test_walk_nodes_visits_every_branch_including_default() -> None: | ||
| switch = ContainerNode( | ||
| source_id="sw", | ||
| task_key="sw", | ||
| concept="switch", | ||
| source=SOURCE_ADF, | ||
| branches={"caseA": [_node("in_case")], "default": [_node("in_default")]}, | ||
| ) | ||
| keys = [node.task_key for node in walk_nodes([switch])] | ||
| assert keys == ["sw", "in_case", "in_default"] |
There was a problem hiding this comment.
There are no tests for an invoke inside a nested container, or a container inside a container. The code handles both, so a test should validate the behavior.
| """Source-agnostic projection of the discovery graph contract to ``inventory.json``. | ||
|
|
||
| The discover phase writes ``metadata/inventory.json`` and the reporting layer | ||
| (:mod:`flowx.reporting.coverage`) and MCP surface (:mod:`flowx.mcp.runner`) read | ||
| it back. Historically each source built that JSON straight from its own AST, so | ||
| the shape drifted per source. This module is the single place that turns the | ||
| discovery graph contract (:mod:`flowx.models.discovery`) into the inventory shape, so | ||
| every source that maps onto :class:`~flowx.models.discovery.SourceGraph` emits the | ||
| *same* top-level document -- ``{source, source_dir, pipelines, summary}`` -- from | ||
| one code path. | ||
|
|
||
| The projection is deliberately small and additive over the historical ADF shape: | ||
|
|
||
| * top level gains a ``source`` discriminator (``"adf"`` / ``"airflow"``); | ||
| * each activity keeps its byte-compatible ``name`` / ``type`` / ``strategy`` (and | ||
| ``depends_on`` names when present) and gains the standardised ``task_key`` | ||
| (the node's unique key, which motif ``member_task_keys`` and source-specific | ||
| layers join on), ``original_type``, ``dependencies`` (upstream **with | ||
| conditions**), and the verbatim per-node ``raw``; | ||
| * a node whose ``properties`` mark it ``inventory_visible: False`` (for example an | ||
| Airflow TaskGroup kept only for structure) is left out of the activity list and | ||
| the counts, while its children are still listed; any value other than exactly | ||
| ``False`` keeps the node listed and counted; | ||
| * each pipeline entry gains an additive ``lineage`` block (control + data edges) | ||
| when its :class:`~flowx.models.discovery.SourceGraph` carries derived lineage; | ||
| * each pipeline entry gains an additive ``motifs`` list -- the multi-activity | ||
| patterns a source *detects* at discover time and records on the graph itself | ||
| (``SourceGraph.lineage.motifs``, so they sit inside the hashed source graph), | ||
| surfaced here **without** collapsing the member activities. In the inventory | ||
| they stay **decoupled from the lineage block**: the inventory's | ||
| ``lineage.motifs`` is always empty, as it always has been; the key is omitted | ||
| when a pipeline has no detected motif. Collapse remains a *convert* decision | ||
| (:mod:`flowx.motifs.collapser`), never a discover one, so every member activity | ||
| still appears as its own entry in ``activities``; | ||
| * the ``summary`` keeps the historical count block, and a top-level | ||
| ``source_graphs_sha256`` records which persisted ``source_graphs.json`` the | ||
| inventory was projected from, when the caller passes it. | ||
|
|
||
| Lineage is placed per pipeline -- one block beside that pipeline's ``activities`` | ||
| -- to mirror the shared discovery serde, where lineage is a per-graph field | ||
| (:func:`flowx.discovery_serde.source_graph_to_dict`). The block is produced by the | ||
| one shared serialiser (:func:`flowx.ir_serde.lineage_to_dict`, the same one the | ||
| serde consumes), so its edges have the same shape as the serde's. Unlike the | ||
| serde's block, its ``motifs`` is always empty: motifs appear only under the | ||
| pipeline's own ``motifs`` key, so reading the inventory block back through | ||
| :func:`flowx.discovery_serde.source_graph_from_dict` recovers the edges but not the | ||
| motifs. It is a new key only: a graph with no derived lineage (``graph.lineage is None``) omits it | ||
| entirely, so the historical consumer keys (``source`` / ``pipelines`` / | ||
| ``activities`` / ``summary``) are untouched. | ||
|
|
||
| A node's translation ``strategy`` is a Databricks-*target* classification rather | ||
| than a source concept, so it is not a typed field on the discovery graph. By | ||
| convention a mapper stashes it under ``node.properties["strategy"]`` (see | ||
| :data:`STRATEGY_PROPERTY`); this module reads it there. Detected motifs are | ||
| different: they are deterministic facts about the source, so a source records | ||
| them on the graph as :class:`~flowx.models.ir.MotifAnnotation` entries in | ||
| ``lineage.motifs`` and this module projects them. Anything else a source wants to layer on -- Airflow's | ||
| audited-count block, findings, reconciliation status -- rides additively on top | ||
| of this base and is out of scope here. | ||
| """ |
There was a problem hiding this comment.
Suggestion: This module docstring reads like a design doc. A short summary would do, since the details already live on the functions (same goes for the top of models/discovery.py).
| from dataclasses import dataclass, field | ||
| from typing import Any | ||
|
|
||
| from flowx.models.ir import DataAsset, Lineage |
There was a problem hiding this comment.
Suggestion: Discovery runs before IR but imports DataAsset / Lineage from models.ir. Reusing them is right, but maybe move them to a small shared module so "IR" doesn't end up meaning two things.
… lineage types - Tests for an invoke inside a container inside a container, a data edge between nodes nested in different containers, nested walk order and nested inventory flattening. - The identity tier refuses a match when both sides name different asset_type values, as the signature tier already did. - wait_for_completion is part of the control-edge dedupe key, so calls that differ only in waiting are no longer merged. - DataAsset, ControlEdge, DataEdge, MotifAnnotation and Lineage move to flowx.models.lineage; flowx.models.ir re-exports them. - The discovery model and inventory module docstrings are short summaries, with the details on the functions, and say a node carries raw and properties (only a graph also has extensions). Co-authored-by: Isaac <no-reply@databricks.com>
…raphs Cross-review of the third round: the DataAsset and serde docstrings still said only the signature tier checks asset_type, and Lineage was described as attached to a translated pipeline only. Co-authored-by: Isaac <no-reply@databricks.com>
|
Thanks. All six are addressed at 5ebdfcb:
With these in, could you approve #84 when you have a moment? Then we merge the stack in order: #84 first, then #94 to #97, each with its own round-three fixes. |
Brings in #84's third review round: nested containers, the asset_type check on both lineage tiers, the wait-aware control-edge dedupe, and the lineage types moved to flowx.models.lineage. Resolution, as in #84's carry trial: discovery_serde.py and lineage.py take #94's side (lineage reads back through ir_serde.lineage_from_dict, and the IR builders stay), with #84's new asset_type wording kept and docstring references pointed at flowx.models.lineage. models/ir.py takes #84's side, which drops the moved classes and keeps the re-exports; #94's MotifAnnotation sentence about Activity.motif_id moves into models/lineage.py. Co-authored-by: Isaac <no-reply@databricks.com>
Intent
Define one source-neutral discovery graph contract (
SourceGraph) that ADF and Airflow can both project into before any Databricks IR. It is persisted as a versioned, hashedsource_graphs.json, and one sharedinventory.jsonbuilder serves every source. This is Phase 1 groundwork for lineage-aware routing. It is additive, and nothing in discover, convert or package calls it yet.What changed
models/discovery.py:SourceGraph,SourceNode,ContainerNodeandGapNode, plus schedule, parameter, policy and dependency specs. A graph carriesraw,propertiesandextensions; a node carriesrawandproperties.models/lineage.py,ir_serde.py: source-neutral lineage types (DataAsset,ControlEdge,DataEdge,MotifAnnotation,Lineage), shared by the discovery graph and the IR, and their serialisers.models/ir.pyre-exports them.lineage.py,discovery_lineage.py: shared lineage derivation.resolved=False.discovery_serde.py: the SourceGraph JSON round trip.PolicySpeccomes back asPolicySpec().source_graphs.jsonenvelope has a contract version, a hash per graph and a document hash, all checked on read.discovery_inventory.py:build_source_inventory, the single source-agnosticinventory.jsonemitter. It writes the source, each activity'stask_key,dependenciesandraw, a per-pipeline lineage block, detected motifs (members left in place) and an optionalsource_graphs_sha256.known_workflows; thesource_graphs.jsonreader requires both hashes, and both reader and writer refuse graphs from another source; an unknownnode_typeraises; data edges are documented as matching assets, not run order.How it was tested
PolicySpecround trip. Both are fixed inbbd00f1with regression tests that fail on the previous code. Round 2 found none.Merge order
Merge #84 first, then #94, which then retargets to
main. #95, #96 and #97 follow in order.Peter's #108 rebases onto
mainafterwards. Its only conflicts are the expected add/add ondiscovery_inventory.pyandtests/unit/test_discovery_inventory.py. Take #84's copy ofdiscovery_inventory.py. In the test file, take #84's copy but keep #108'stest_emitter_has_no_source_specific_imports, which #84 does not carry.This pull request and its description were written by Isaac.