Skip to content

feat(adf): save source graphs with dataset lineage at ADF discover - #94

Open
matthewmoorcroft wants to merge 72 commits into
discovery/shared-ast-standardfrom
stack/adf-discovery
Open

matthewmoorcroft wants to merge 72 commits into
discovery/shared-ast-standardfrom
stack/adf-discovery

Conversation

@matthewmoorcroft

@matthewmoorcroft matthewmoorcroft commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Intent

Put ADF discovery on the source-neutral discovery graph contract from #84. The ADF loader projects each pipeline into a SourceGraph, saves source_graphs.json first (versioned, hashed, motifs inside), and builds inventory.json through the shared emitter, which records the graphs' hash. ADF data lineage is derived conservatively: only provable physical values become dataset identities.

What changed

  • ADF export fields (21 Oct): discover drops the fields an ADF REST GET adds without meaning (a pipeline's REST envelope id, type and etag (a new etag on every publish); its lastPublishTime; empty annotations, parameters and variables on a pipeline; empty dependsOn and userProperties on any activity; an empty annotations on a trigger), so a live export and a Git or ARM export of the same factory give the same source graphs and source_graphs_sha256.
  • Review fixes, round three (20 Oct): only a real boolean waitOnCompletion is recorded (null or a string leaves the wait unknown); an Expression override with an empty value no longer wipes the dataset identity; two Switch cases with the same value both keep their activities (the parser no longer replaces the first, the graph gives each case its own branch, and the bundle gives each its own condition task key), and the repeat is reported on the Switch node (duplicate_switch_cases) and logged; a bracket-quoted table such as [Sales (2024)] keeps its identity, because only ARM's own functions make an ARM expression; Blob and ADLS Gen2 datasets share one abfss:// identity on purpose, since both reach the same storage account; @dataset()?.x resolves like @dataset().x; the dataset lineage module is split into identity, store and signature parts (a pure move). refactor: add source-neutral discovery graph contract and shared lineage core #84's round three is merged in with a merge commit.
  • Review fixes, round two (16-18 Oct): the signature rule below; non-map parameters are ignored instead of crashing discover; a null or non-name ExecutePipeline reference stays unresolved; a control edge read back without resolved: true is unresolved, and a wait flag that is not true or false reads back as unset; an ARM-expression linked service name never proves a store; Fabric Lakehouse and ADLS Gen1 locations are qualified by their plain linked service name, like SFTP; repeated motif detections are counted once, on the inventory's rule (same motif id and same activities, in any order), so the profile, the saved graph and the inventory agree; the --debug pipeline dump includes the pipeline's lineage block.
  • Merged refactor: add source-neutral discovery graph contract and shared lineage core #84's final head with a merge commit (no rebase). This PR reads lineage through the shared ir_serde.lineage_from_dict, which now also carries refactor: add source-neutral discovery graph contract and shared lineage core #84's wait-flag rule.
  • Review fixes (14-15 Oct): a linked service name must be a plain name before it qualifies an identity; the S3 / GCS bucketName now names the file store, so two buckets with the same folder layout no longer link; an ExecutePipeline edge is resolved only when its child pipeline is in the export, including under --pipeline, matched case-insensitively as ADF does and recorded under the exported spelling; a test checks that running discover twice gives byte-identical inventory.json with no generated_at. In the same style: an unevaluated ARM or {{ placeholder folderPath no longer anchors a path signature, an unevaluated ARM fileName is kept as written so different ones never link, SFTP, file server, FTP, Azure Files, HDFS and HTTP locations are qualified by their plain linked service name, a literal container or bucket whose store cannot be proven gets no signature, and the IR-side build_control_edges takes the same known-pipelines check.
  • sources/adf/loader.py:
    • Attaches detected motifs to each graph's lineage.
    • Writes source_graphs.json, then inventory.json through build_source_inventory with source_graphs_sha256.
    • inventory.json deliberately has no top-level generated_at, so the same export, given the same --source-dir, always gives the same bytes. This is documented in skills/flowx-discover/sources/adf.md.
  • sources/adf/discovery_mapping.py: maps the ADF AST to SourceGraph one to one.
    • Every declared branch is kept, including Switch cases with no activities.
    • A case whose value is literally default is keyed case:default (kept unique), so it never collides with the Switch's own default branch.
  • sources/adf/dataset_lineage.py (with dataset_identity.py, dataset_store.py and path_signature.py, split in round three): dataset identities.
    • Tables are qualified by the linked service's literal server (and database). Paths run down to the literal file name.
    • @dataset().x location parts resolve against the call-site bindings.
    • Anything parameterised or unresolved gives no identity: ADF expressions, DAB placeholders, unevaluated ARM expressions, and parameterised linked services, databases or storage accounts. Bracket-quoted SQL names such as [dbo].[Orders] stay valid.
    • Path signature rule (round two): an unresolved path gets a weak signature only when the activity passes a folderPath with literal text at the call site; the dataset's own location is not enough on its own. The folder and file name are then read from the dataset's location with the call-site values filled in (the call-site value is used when the location sets none). Only provably literal text forms the path (quoted text, numbers, text outside @{...}); every other value is one <P> slot where it sits, and a function over a bound dataset() value is a slot keyed by that value (<P:tolower('orders')>). So nothing vanishes, no argument text leaks, and no link is made from a non-literal value. An interpolated call-site folder links like its @concat spelling. tests/unit/test_adf_path_signature_corpus.py guards the rule.
    • An unresolved path's weak signature names the dataset's store when it is known, so datasets on different stores never join on folder shape alone. The store is <container>@<account>, the linked service name in place of an account hidden in a secret (only for a linked service with no parameters), or the table store. An unknown store is never guessed; that signature stays unqualified.
    • Run-time override rule: a read or write whose activity overrides the dataset's physical source or target at run time gets no identity and no signature, so it never forms a data edge. That covers storeSettings wildcard, file-list or prefix paths; a source query, *Query or *StoredProcedureName; and a sink *StoredProcedureName.
  • Groundwork for cross-pipeline deterministic conversion, not called yet (models/ir.py, lineage.py, ir_serde.py, bundler/dab_writer.py):
    • The IR lineage fields (Activity.data_reads, data_writes and motif_id, and Pipeline.lineage), the build_* and with_lineage functions, their IR serialisation, and package's read-back of them in dab_writer.py.
    • Nothing in Phase 1 populates or calls them yet, so output is unchanged.
    • Within that layer, a run-now of an existing job by ID is not a cross-workflow edge, and motif annotations carry source_type_hint.
    • The one shared lineage parser, ir_serde.lineage_from_dict, is used in production by discovery_serde.
  • Discover console (rides along): discover prints a loud warning when it parses 0 pipelines (naming --adf-source-path), and labels the activity count "raw, incl. nested". This affects console output only; nothing on disk changes.
  • Docs: AGENTS.md, the README output tree and the flowx-discover skill describe source_graphs.json and the inventory hash.

How it was tested

  • Unit tests cover the loader, the mapping, dataset lineage and the lineage layer. Dataset lineage tests cover identity tiers, servers, file names, ARM expressions, bracket-quoted names, parameterised files and every override kind. The full unit and integration suites, ruff and mypy pass in CI.
  • ADF and Airflow discover, convert and package on the fixture corpora: bundles and translation reports are byte-identical to the merge-base with main (b8d0766). A scratch merge of refactor: add source-neutral discovery graph contract and shared lineage core #84 and feat(adf): save source graphs with dataset lineage at ADF discover #94 onto current main (858550e) is byte-identical to main too, with the full unit suite passing. Only the ADF inventory changes, gaining the shared emitter's fields, source_graphs_sha256 and each activity's task_key.
  • Codex cross-review, read-only, twice. Round 1 found 4 issues and round 2 found 3. All are fixed here, along with the review-found follow-ons: the Switch default label clash and the override-rule siblings. An independent commit-by-commit review then found one more, the cross-store signature edge, which is also fixed here. Every fix has a regression test that fails on the previous code.

Merge order

Merge after #84. This PR targets discovery/shared-ast-standard; retarget it to main once #84 has merged. The retarget needs a one-line import resolution in bundler/dab_writer.py: keep both from flowx.ir_serde import data_asset_from_dict, lineage_from_dict and main's from flowx.models.dab import DabNotebook, SecretInstruction, SetupTask. #95, #96 and #97 follow in order.

This pull request and its description were written by Isaac.

matthewmoorcroft and others added 3 commits September 16, 2026 17:24
Map ADF exports onto the shared source-neutral discovery AST and emit a
source-agnostic inventory, populate control/data lineage (Switch-aware),
surface detected ADF motifs additively in the inventory, and carry the
lineage/motif tags on the IR (data_reads/data_writes, motif_id on the
Activity base; pipeline lineage block) with matching serialization.

Consolidates PRs #79 (ADF -> shared source AST + inventory emitter),
#80 (data/control lineage + Switch-aware walking), #81 (emit lineage into
inventory.json) and #90/#64 (motif detection + exact-duplicate dedupe),
plus the coverage golden.

Co-authored-by: Isaac <no-reply@databricks.com>
FIX 1: When ADF discover parses zero pipelines (e.g. --source-dir pointed at a
directory of ARM templates with no recognized pipelines/ layout), emit a loud
stderr warning instead of finishing with a success-shaped "Loaded 0
pipeline(s)". The warning steers the operator to pass the ARM template as the
file path or supply the expected export layout. Exit code for the 0-pipeline
case is unchanged (still 0); behaviour for valid dirs/files is untouched.

FIX 7: Label the discover summary's "Total activities" count as raw activities
including nested, and note that convert reports top-level task-units after motif
collapse, so the smaller convert number is not mistaken for a discrepancy.
Console-output only -- inventory.json keys are byte-compatible (unchanged).

Co-authored-by: Isaac <no-reply@databricks.com>
Cross-review (non-blocking): the loud 0-pipeline warning referenced the loader's
internal --source-dir flag, but that is not what a user passes -- the discover
runner takes --adf-source-path (the generic --source-path alias normalises to
--source-dir internally). Name --adf-source-path in the guidance so the operator
is pointed at the flag they actually use. Test asserts the user-facing flag is
named and the internal one is not.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft and others added 6 commits October 5, 2026 12:09
…elope, graph-contract naming) into stack/adf-discovery

The emitter and its tests now live in the base PR as a superset of this
branch's copy, so both take the base version.

Co-authored-by: Isaac <no-reply@databricks.com>
ADF discover built SourceGraphs only in memory and wrote the lossy inventory
projection. It now also writes metadata/source_graphs.json with
write_source_graphs (contract_version "1", per-graph and document hashes),
the same file Airflow discover writes, so a later phase can read exactly what
discover saw without parsing the source again. Convert does not read it yet.

Co-authored-by: Isaac <no-reply@databricks.com>
Docstring and comment wording only.

Co-authored-by: Isaac <no-reply@databricks.com>
…ntory records its hash) into stack/adf-discovery

Co-authored-by: Isaac <no-reply@databricks.com>
…he saved file

ADF discover now records each pipeline's detected motifs on its source graph
before writing source_graphs.json, so they are saved and hashed with the
graph, then projects inventory.json from those graphs and records the file's
document hash as source_graphs_sha256. Detection still runs once on the ADF
definitions and the profile report is unchanged.

Co-authored-by: Isaac <no-reply@databricks.com>
…e docstring fix) into stack/adf-discovery

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft matthewmoorcroft changed the title Phase 1 stack #2: ADF discovery — inventory + lineage + motifs Phase 1 stack #2: ADF discovery on the graph contract (persisted source graphs with motifs, inventory and lineage) Oct 5, 2026
@matthewmoorcroft matthewmoorcroft changed the title Phase 1 stack #2: ADF discovery on the graph contract (persisted source graphs with motifs, inventory and lineage) feat(adf): save ADF source graphs with motifs and build the inventory from them Oct 6, 2026
matthewmoorcroft added a commit that referenced this pull request Oct 6, 2026
…ntic-insights

Carries the #94 review fixes forward: ADF dataset identities qualified by file name
and literal server, parameterised parts never form an identity, the shared lineage
parser in ir_serde, and the source_graphs.json docs. The flowx-discover output row
for source_graphs.json conflicted with this branch's wording commit; the resolution
keeps both wordings.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 6, 2026
…agentic-component

Carries #94 and #95 forward: dataset identity fixes, the shared lineage parser, one
shared canonical hash (discovery_serde.canonical_sha256), hardened insights validation,
and the enrich wording and docs.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 7, 2026
…/routing-agentic

Carries #94, #95 and #96 forward: dataset identity fixes, the shared lineage parser,
one shared canonical hash (discovery_serde.canonical_sha256), hardened insights
validation and enrich wording, and the agentic component resource-key checks.

Conflicts kept both wordings: the AGENTS.md discover line matches phase1-integration,
and the MCP tool description takes #95's corrected enrich text with this branch's
route and fill_agentic entries. README listed source_insights.json twice after the
auto-merge; the two lines are combined into one that keeps both descriptions.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 7, 2026
…tegration

Brings in the #94 review fixes: ADF dataset identities qualified by file name and
literal server, parameterised parts never form an identity, the shared lineage parser
in ir_serde, and the source_graphs.json docs. The AGENTS.md discover line and the
flowx-discover output row conflicted with the later wording commits; both keep the
wording from each side.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft and others added 7 commits October 7, 2026 15:12
Unevaluated ARM template expressions ([parameters(...)], [concat(...)]) are now
treated as unresolved, so they never become dataset identities or false data edges.
File locations written as @dataset().x resolve against the call-site bindings, as
table names already did, so parameterised file datasets keep their valid edges; an
unbound part still gives no identity. A run-now of an existing job by ID is no longer
emitted as a cross-workflow control edge to the caller's own task key. The ADF
discover docs record that inventory.json deliberately has no generated_at timestamp.
Each fix has a regression test that fails 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>
@matthewmoorcroft

Copy link
Copy Markdown
Member Author

The changes from the second review are applied at commit 846b51a. Rather than one more patch per case, the path signature now follows one rule.

What was wrong with the old approach. The signature kept quoted text plus a fixed list of known run-time values (pipeline(), item(), variables(), dataset(), activity()) and silently dropped everything else. A dropped value doesn't make the signature weaker; it makes a run-specific folder look literal. So @concat('landing/', rand(1,100)), pipeline()?.TriggerTime or item()['zone'] read as plain landing or raw/zone and linked to folders they aren't. Each review round found the next missing value, because adding one value to the list fixes only that value.

The rule now. Only provably literal text forms the path: quoted text in an expression, or text outside the @{...} blocks of an interpolated value. Any other value is one <P> slot where it sits, whatever function or reference it is, so nothing vanishes and no argument text leaks into the path. A function over a dataset() value the call site bound to a literal, such as toLower(dataset().folderPath), becomes a slot keyed by that value (<P:toLower('orders')>). It is still never path text, but orders and customers stay apart. The folder and file name are read from the dataset's own location, with the passed-in values spliced in raw and the slashes normalised once.

A1. An interpolated call-site folder such as landing/@{item().run} now gets a signature and links exactly like its @concat('landing/', ...) spelling. Those are correct links, so we accept them. The earlier note that no reference gains a signature is corrected accordingly: these interpolated folders gain one, and nothing else does.

Accuracy, and the guard. On 17 real examples (the false links found in review, the links that must keep working, including the unknown-store watermark tier, and your two cases), the result is 16 right with no invented link. 9ef2ab0 got 8 right with 5 invented. The one miss is a folder made only of a formatted date, which no longer links on its format string. tests/unit/test_adf_path_signature_corpus.py guards the rule: it builds 216 expressions from literal and run-time pieces in four spellings, plus bound-value cases. It asserts that a run-time value never reads as a literal folder and that no argument text leaks into the path. A new construct gets added to the corpus, not patched per value.

Your eight points:

  1. Folder signature ignored the dataset's folder: the shape is now read from the dataset's own location.folderPath with the passed-in values filled in, so bronze/orders and silver/orders no longer link. The call-site value is used only when the location sets no folder.
  2. Slot positions lost: each run-time value stays in place as <P>, so landing/<P> and <P>/landing differ. The |slots=N count is gone.
  3. parameters that are not a map, on the dataset or at the call site, are ignored instead of crashing discover.
  4. "pipeline": null, or any reference that is not an object or a name, records an empty, unresolved child. Before, it could resolve to an exported pipeline called None.
  5. _resolve_dataset_store now returns the store and whether the location names one, and _location_names_store is gone.
  6. A missing file name is written FN[].
  7. The doc says byte-identical inventory.json holds for the same --source-dir.
  8. "or the dataset name" is dropped from the test docstring.

Each fix has a regression test that fails before it.

I also went through the whole diff against #84 with your review points in mind, and fixed what that turned up: a control edge read back without resolved: true is unresolved, and a wait flag that is not true or false reads back as unset; an ARM-expression linked service name never proves a store; Fabric Lakehouse and ADLS Gen1 locations are qualified by their linked service, like SFTP, so two lakehouses with the same folder layout no longer link; repeated motif detections are counted once, on the inventory's rule, so the profile and the inventory agree; and the --debug pipeline dump includes the lineage block. #84's final head is merged in with a merge commit, not a rebase.

@ghanse ghanse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The ADF-to-source-graph work is solid and well tested overall. Fix the wait-flag handling and the empty-Expression case before merging.

  • tests/unit/test_adf_discovery_mapping.py: Add tests for a null or non-boolean waitOnCompletion, and for a ForEach/Until with a Switch inside, so the bugs above can't come back unnoticed.

reference = reference.get("referenceName")
callee = reference if isinstance(reference, str) else ""
properties[INVOKES_WORKFLOW_PROPERTY] = callee
properties[INVOKES_WAIT_PROPERTY] = bool(type_properties.get("waitOnCompletion", True))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: bool(...) turns a null wait flag into False and the string "false" into True. Only keep real booleans and leave the rest unset, which matches how the shared reader already works.

wait = type_properties.get("waitOnCompletion", True)
if isinstance(wait, bool):
    properties[INVOKES_WAIT_PROPERTY] = wait

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in cb4ddbc with the suggested snippet: only a real boolean waitOnCompletion is recorded, and null, "false", numbers and Expression objects leave it unknown. Tests cover each value, plus a Switch inside a ForEach and an Until.

Comment on lines +164 to +166
def _names_any(settings: object, is_override_key: Callable[[str], bool]) -> bool:
"""``True`` when *settings* is a dict with a non-empty value under a key *is_override_key* accepts."""
return isinstance(settings, dict) and any(value and is_override_key(key) for key, value in settings.items())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Blocking: An Expression object with an empty value ({"type": "Expression", "value": ""}) counts as an override and wipes out the dataset's identity, while a bare "" correctly doesn't. Unwrap the expression before checking whether it's empty.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in a0d0931: the override check unwraps an Expression first, so {"type": "Expression", "value": ""} overrides nothing, like a bare "". 8f4f6bd keeps any other non-empty query object, such as a Key Vault reference, as an override.

Comment on lines +749 to +765
The folder and file name are then read from the dataset's own ``location``, with
each ``dataset().<name>`` in them filled in from the reference's parameters
(call-site values first, then the dataset's declared defaults); a value that does
not read as text, such as ``null``, is left a run-time slot. So
``@concat('bronze/', dataset().folderPath)`` and ``@concat('silver/',
dataset().folderPath)`` stay apart even when both are called with ``folderPath:
"orders"``. When the location sets no folder or file name, or the dataset is not
in the export, the ``folderPath`` / ``fileName`` the call site passes stands in for
it. A file name with no literal text gives an empty ``FN[]``.

``None`` when the reference passes no anchored ``folderPath``, or the dataset's
own folder has no literal segment to anchor on.
"""
call_site = _call_site_parameters(dataset_ref)
passed_folder = _path_shape(call_site.get("folderPath"), {}, require_physical=True)
if passed_folder is None or not _has_literal_anchor(passed_folder):
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Requiring a literal call-site folderPath looks intentional (there's a test for it), but this docstring and the PR description make it sound like the dataset's own location is enough. Make the wording match the rule.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 2f03836: the _path_signature and module docstrings say a signature needs both a call-site folderPath with literal text and a literal segment in the folder it resolves to, and the description says the same.

Comment on lines +382 to +384
for case_value in declared_values:
case_label = default_case_label if case_value == "default" else case_value
branches[case_label] = [_activity_to_node(child, definitions) for child in parsed_cases.get(case_value, [])]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Two Switch cases with the same value write to the same branch key, so the first case's activities get dropped without a warning. Rare, but worth a finding or a check that fails loudly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in cb4ddbc, 38a0469 and 337d2d6: every case's activities are kept, each declared case stays its own branch, the Switch lists a repeated value under duplicate_switch_cases with a warning, and the bundle gives a repeat its own condition task key.

Comment on lines +653 to +660
file_system, account = _resolve_file_store(location, linked_service, dataset_params, context)
folder_path = _resolved_part(location.get("folderPath"))
file_name = _resolved_part(location.get("fileName"))
if file_system is None or account is None or folder_path is None or file_name is None:
return None

relative_path = "/".join(part.strip("/") for part in (folder_path, file_name) if part.strip("/"))
return f"abfss://{file_system}@{account}.dfs.core.windows.net/{relative_path}".rstrip("/")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Every file identity is written as abfss://…dfs.core.windows.net/…, including Azure Blob. So a Blob dataset and an ADLS Gen2 dataset with the same path would get linked. Use a prefix that matches the connector.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Kept on purpose in 3ff83e4, with the reason in the docstring and a test: a storage account name is unique across Azure and both connectors reach that one account, so the same container and path are the same files either way. A different account or container still doesn't link.


_ACCOUNT_NAME_RE = re.compile(r"AccountName=([A-Za-z0-9]+)", re.IGNORECASE)
_DATASET_PARAM_RE = re.compile(r"^@dataset\(\)\.([A-Za-z_][A-Za-z0-9_]*)$")
_ARM_EXPRESSION_RE = re.compile(r"^\[\s*[A-Za-z_][A-Za-z0-9_]*\s*\(.*\]$", re.DOTALL)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This regex treats [Sales (2024)] as an ARM expression, so that table loses its identity. Only [Order Details] and [dbo].[Orders] are tested.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 936db3b and 8f4f6bd: a bracketed value is read as ARM only when it calls an ARM template function, a list* or namespaced function, or has quoted or nested-call arguments, so [Sales (2024)] keeps its identity. It is tested next to [Order Details] and [dbo].[Orders].

Comment on lines +1 to +44
"""Resolve ADF dataset references into source-neutral :class:`DataAsset` values.

This is the ADF half of data-lineage population (#62b). It re-homes the
dataset-identity resolver and the path-signature logic first written for the
closed #36 ADF-only lineage attempt, but emits the shared #61 two-tier
:class:`~flowx.models.ir.DataAsset` (``identity`` + ``signature``) instead of a
bespoke edge type, so the source-neutral join in :mod:`flowx.lineage` /
:mod:`flowx.discovery_lineage` does the matching.

Two tiers, exactly as #36 established them:

* **identity** -- the resolved physical location of the asset (a store-qualified
``<server>/schema.table`` or a concrete ``abfss://`` path down to the literal
file name). Present only when it resolves *deterministically*
from literals; a parameterised reference is never guessed at and leaves
``identity`` unset. This is the strong join key.
* **signature** -- the *path-derived* weak key. For an asset with a resolved
identity the signature mirrors that identity, so the weak tier can never join a
resolved asset to an unresolved one on a coincidence. For an unresolved reference
the signature is the *structural path signature* (#36's "expression" tier: the
dataset's folder and file name with their literal text kept in place -- quoted
text, numbers, ``concat`` pieces, and text outside ``@{...}`` -- and exactly one ``<P>`` slot
for every other run-time value or function call, wherever it sits, keyed by any
bound ``dataset()`` value it uses) when the folder has a literal anchor, prefixed
``ST[<store>]/`` when the dataset's store is known (see
:func:`_resolve_dataset_store`), so two datasets on different stores that merely
share a folder shape never join. An unknown store is
never guessed: the signature stays unqualified, and a qualified and an unqualified
signature never join. A dataset that names its store (a literal container or
bucket, or a server, share, lakehouse or account set on its linked service) but
cannot prove the rest of it gets no signature, since the folder shape alone cannot
tell two such stores apart.
It is **never** the bare dataset reference name: two unrelated opaque
references that merely share a name must not join (#36's explicit rule), so when
neither a physical identity nor a path-anchored signature is available the
signature is left empty and the asset cannot participate in signature matching.
The signature is also left empty when the activity overrides the dataset's
location at run time (a source query, stored procedure or ``storeSettings`` path
override on a read; a sink stored procedure on a write), because the dataset's
path is then not what the activity touches.

Only literal, provable values ever become an ``identity`` -- the resolver returns
``None`` rather than guessing, which is what stopped #36's spurious edges.
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggestion: At about 1,000 lines, much of it docstrings, this file is hard to follow. Consider splitting identity, store and signature into separate parts, merging _DATASET_PARAM_RE and _DATASET_PARAMETER_RE, cutting the issue-history docs down to short summaries, and shortening any verbose docstrings.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in a519535 and 0145059: the two regexes are merged, the issue history is cut to short summaries, and the module is split into dataset_identity.py, dataset_store.py, path_signature.py and dataset_lineage.py with every definition's body unchanged.

matthewmoorcroft and others added 11 commits October 20, 2026 05:55
{"type": "Expression", "value": ""} under a source query, a sink stored
procedure or a storeSettings path key counted as an override and wiped the
dataset identity, while a bare "" did not. The override check now unwraps an
Expression to its value first, through one small helper also used by the
other places that unwrapped by hand.

Co-authored-by: Isaac <no-reply@databricks.com>
An ExecutePipeline waitOnCompletion is recorded only when it is a real
boolean, so null no longer reads as False and "false" no longer as True; the
wait stays unknown, matching the shared control-edge reader. A missing flag
still takes the translator's default, True.

Two Switch cases with one value can keep only one branch (the AST keeps the
last). The Switch node now lists each lost case and its activities under
duplicate_switch_cases, and discover logs a warning naming the pipeline.
Tests also cover a Switch nested in a ForEach or Until.

Co-authored-by: Isaac <no-reply@databricks.com>
The ARM check matched any "[name (" so a bracket-quoted SQL name such as
[Sales (2024)] read as an unevaluated ARM expression and lost its identity.
A bracketed value is now ARM only when it calls one of ARM's own template
functions (case-insensitive, any spacing), a list* function, or a
namespace.function the template defines. The namespaced form was missed
before and is now caught too.

Co-authored-by: Isaac <no-reply@databricks.com>
A storage account name is unique across Azure and the Blob and ADLS Gen2
connectors are two endpoints onto that one account, so a Blob writer and an
ADLS Gen2 reader of the same container and path touch the same file. The
shared abfss:// identity is kept on purpose; the docstrings now say so, and a
test pins that the same account and container link through either connector
while a different account or container does not.

Co-authored-by: Isaac <no-reply@databricks.com>
The _path_signature docstring and the module docstring read as if the
dataset's own location were enough for a signature. The rule, already tested,
needs both: the reference passes a folderPath with literal text, and the
folder it resolves to has a literal segment. The docstrings now state both.

Co-authored-by: Isaac <no-reply@databricks.com>
One _DATASET_PARAMETER_RE now serves both the whole-value @dataset().x
lookup and the path shapes, so @dataset()?.x (ADF's safe navigation) also
resolves to its bound value, with a test. The module docstring and the four
longest function docstrings are cut to what the code does today, without the
history of earlier issues.

Co-authored-by: Isaac <no-reply@databricks.com>
A move only: every function and constant keeps its exact body (checked by
comparing the AST of each definition before and after). dataset_identity
holds the physical identity and the value helpers it shares, dataset_store
the store that qualifies a signature, path_signature the structural
signature, and dataset_lineage puts them together for each read and write.
activity_data_assets and resolve_dataset_identity are still imported from
dataset_lineage, the only names #95 to #97 use.

Co-authored-by: Isaac <no-reply@databricks.com>
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>
Cross-review found two ways the round-three fixes failed open. Unwrapping
every object to its "value" made a non-empty, non-Expression query object
(a Key Vault reference, say) read as empty, so it no longer overrode the
read; only an Expression-shaped object is unwrapped now. And an ARM call to a
function missing from the list (distinct, or a newer one) read as a literal
name; a bracketed call whose arguments hold a quoted string or another call
is now ARM too, which [Sales (2024)] still is not.

Co-authored-by: Isaac <no-reply@databricks.com>
@matthewmoorcroft

Copy link
Copy Markdown
Member Author

Thanks, Greg. Round three is applied up to 337d2d6, one line per comment:

  1. Wait flag (blocking): only a real boolean waitOnCompletion is recorded, using your snippet; null, "false", numbers and Expression objects leave the wait unknown, as the shared reader expects. A missing flag still takes the translator's default, True. Tests cover each value, plus a Switch inside a ForEach and an Until. (cb4ddbc)
  2. Empty Expression override (blocking): the override check unwraps an Expression to its value first, so {"type": "Expression", "value": ""} overrides nothing, like a bare "", for a source query, a sink stored procedure and a storeSettings path. A non-empty Expression, or any other query object such as a Key Vault reference, still overrides. (a0d0931, 8f4f6bd)
  3. Call-site folderPath wording: the _path_signature and module docstrings now say a signature needs both a call-site folderPath with literal text and a literal segment in the folder it resolves to. The PR description says the same. (2f03836)
  4. Duplicate Switch case values: nothing is dropped now. Conversion already converted every case, so the parser now adds a repeated value's activities to the same list instead of replacing them, and the graph keeps each declared case as its own branch (full, then case:full). So the AST, the profile count, the graph and conversion all keep every activity. The Switch node lists the repeated value and its branch labels under duplicate_switch_cases, and discover logs a warning naming the pipeline. The bundle also gives a repeated value its own condition task key, which used to collide. Tested, including an empty last duplicate. (cb4ddbc, 38a0469, 337d2d6)
  5. Blob vs ADLS Gen2 prefix: I kept one abfss:// identity for both, on purpose. A storage account name is unique across Azure, and the Blob and ADLS Gen2 connectors are two endpoints onto the same account, so the same container and path are the same files through either one. A Copy writing through the Blob connector and a notebook reading the same container through ADLS should link, and a wasbs:// prefix for Blob would break that link. The docstring says this now, and a test pins it: the same account and container link through either connector; a different account or container does not. If you'd still rather have wasbs:// for Blob, I'm happy to switch. (3ff83e4)
  6. [Sales (2024)]: a bracketed value is read as ARM when it calls one of ARM's own template functions (any case or spacing), a list* function or a namespaced user function, or when its arguments hold a quoted string or another call, which only ARM's do. So [Sales (2024)] keeps its identity, tested alongside [Order Details] and [dbo].[Orders], while [ns.fn(...)] (missed before) and an ARM function missing from the list still read as ARM. (936db3b, 8f4f6bd)
  7. File size: _DATASET_PARAM_RE and _DATASET_PARAMETER_RE are merged, so @dataset()?.x now resolves too (tested). The issue-history docs are cut to short summaries, and the longest docstrings are shortened (a519535). The module is then split into dataset_identity.py, dataset_store.py, path_signature.py and dataset_lineage.py, which puts them together. That commit only moves code: every definition's body is unchanged, checked by comparing each one's AST before and after (0145059).

The behaviour fixes (1, 2, 4, 6 and the ?. lookup) each have a regression test that fails before them; the tests for 5 pin the current behaviour. #84's round three is merged in with a merge commit (0813081), and the full unit suite, ruff and mypy pass.

matthewmoorcroft added a commit that referenced this pull request Oct 20, 2026
Brings in #96's round three with #95 6ed0b4b, #94 337d2d6 and #84 5ebdfcb
merged in. No conflicts.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 21, 2026
Co-authored-by: Isaac <no-reply@databricks.com>

# Conflicts:
#	src/flowx/discovery_serde.py
#	src/flowx/lineage.py
matthewmoorcroft and others added 3 commits October 21, 2026 15:44
An ADF REST GET adds a pipeline's lastPublishTime and empty annotations,
parameters, variables, dependsOn and userProperties that a Git or ARM export
leaves out. The ADF mapping kept them in each graph's and node's raw dict and
in the trigger properties, so the same factory exported another way, or
republished with no real change, got a new source_graphs_sha256, and enrich
insights and conversion plans bound to the old hash were refused.

The mapping now drops exactly the set main's ADF corpus already treats as
meaningless: the pipeline's lastPublishTime and its empty annotations,
parameters and variables; empty dependsOn and userProperties on every
activity, nested ones included; and empty annotations on each trigger copy.
Non-empty values, and every other field, are kept as written. Tests compare
a live-GET form with the export form of one factory (same graphs, same hash)
and check that non-empty values stay.

Co-authored-by: Isaac <no-reply@databricks.com>
A pipeline from an ADF REST GET is wrapped as {id, name, type, etag,
properties}, and ADF issues a new etag on every publish. The mapping kept
id, type and etag in each graph's raw dict, so a republish with no real
change still moved source_graphs_sha256, and a REST export still differed
from a Git or ARM export of the same factory. Nothing in flowx reads those
three fields. They are now dropped; name and properties stay as before.
Tests check that a REST envelope and the Git form give the same graphs and
hash, and that a changed etag alone keeps the hash.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 21, 2026
Brings #94's fix (c38da87) up through #95 (0e7ad99) and #96: discover drops
the fields an ADF REST GET adds without meaning before it keeps the source,
so a live export and a Git or ARM export give the same graphs and hash.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 21, 2026
Co-authored-by: Isaac <no-reply@databricks.com>

@ghanse ghanse left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for working through the earlier comments. They're all fixed or explained now, and splitting dataset_lineage.py into separate identity, store and signature modules reads much better.

One thing left before merge: server, database, url and sasUri need to be unwrapped from ADF Expression objects the same way connectionString already is. It's a small fix and makes the handling consistent.

return None
connection_fields = _connection_string_fields(connection_string) if isinstance(connection_string, str) else {}

server = type_props.get("server") or connection_fields.get("server") or connection_fields.get("data source")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

connectionString gets unwrapped from an ADF Expression object, but server and database don't. So a linked service with "server": {"type": "Expression", "value": "sql.example.com"} gets no table identity at all, which is worse than what a plain string or even a Key Vault secret gets. Run each one through _expression_value:

server = _expression_value(type_props.get("server")) or connection_fields.get("server") or connection_fields.get("data source")

url and sasUri in _resolve_storage_account (around lines 379 and 387) have the same gap.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks, this is in at 7002d89 (fix in fe636eb): server, database, url and sasUri now go through _expression_value like connectionString, so a wrapped literal gets the same identity as a plain string, and a wrapped parameter or ARM expression still gets none. One extra guard came with it, tightened in f04a6ff, ddb10cd and 7be38b9: exports mask SecureString values as **********, so a server, database, container or storage account name containing * is rejected rather than becoming a store name every masked linked service would share.

@ghanse ghanse mentioned this pull request Oct 26, 2026
1 task done
matthewmoorcroft and others added 5 commits October 26, 2026 14:45
A linked service can wrap these fields in an Expression (or SecureString)
object, as it already can connectionString. Each is now unwrapped the same
way and then checked like a plain string, so a wrapped literal gets its
table or path identity while a wrapped parameter reference still gets none.
A masked SecureString server or database (**********) names no store, so
masked servers never share one identity.

Co-authored-by: Isaac <no-reply@databricks.com>
…names

An object of any other type carrying a value is now left whole, so its
value never becomes a server, database or storage account (it names no
store, still overrides a read, and is not a literal in a signature). A
server or database containing * is a masked secret, not a store name, and
a storage account taken from url, sasUri or AccountName= must be letters
and digits only, so two masked stores no longer share a signature.

Co-authored-by: Isaac <no-reply@databricks.com>
A dataset folderPath or fileName the code cannot read (a list, or an
object that is not an Expression) used to fall back to the call-site
folder, so it could join a real path with the same shape; since the
stricter unwrap this included unknown typed objects. It now gives no
signature. A file system, container or bucket containing * is masked,
not a store name, like a server or database. Docstrings now mention the
untyped value object the unwrap also accepts.

Co-authored-by: Isaac <no-reply@databricks.com>
Rejecting a file system holding * left the location looking as if it
named no store, so two masked containers on different accounts fell back
to the same unqualified folder signature and linked; before this round
their account-qualified signatures differed. A masked file system now
names a store that cannot be proven, so it gets no signature.

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 26, 2026
…entic-component

Co-authored-by: Isaac <no-reply@databricks.com>
matthewmoorcroft added a commit that referenced this pull request Oct 26, 2026
Brings #96's three fixes (authored component tasks declare and check the
variables and files they name, a reloaded component field of the wrong
shape is refused, parameter approximations reach SETUP.md), #95's enrich
lock fix and #94's round-four fix up into #97, on top of #97's own fixes.

Co-authored-by: Isaac <no-reply@databricks.com>

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants