Conversation
A pipeline that reaches one shared `@task` function from several call sites with different task-level options silently ran every one of them on the FIRST call site's configuration. `_build_local_from_python_components` keyed the generated `<stem>.components.yaml` by the hyphenated FUNCTION NAME, so a shared `run_dbt` decorated once with a slim image and once with a fat image produced a single `run-dbt` entry, and `_rewrite_task_componentref_urls` recomputed that same name per task -- pointing every task at the survivor. Nothing failed: the pipeline compiled, validated, and ran, on the wrong image. The name was never an identity. What hydrate regenerates from an entry is determined by the whole `local_from_python` block -- image (explicit or resolved `image_id`), function, mode, resolve_root, dependencies_from, the persisted unwrap schema, and the source file -- so that block, plus the module-qualified function identity, is what dedup must key on. `_plan_task_sidecar` now computes both halves at once and returns a `TaskSidecarPlan(entries, fragment_by_task)`. Sidecar emission and componentRef rewriting consume the SAME map, so the two cannot drift: a fragment depends on how many distinct components a function generates pipeline-wide, which no per-call-site recomputation can know. Identity is module-qualified, not path-shaped and not `__module__`. The compile driver imports pipeline scripts as `_tangle_user_pipeline_<uuid>`, so runtime `__module__` is a fresh string every compile for a script-defined task while being a real dotted name for an imported one -- unusable. The namespace is derived from the source LAYOUT instead, which is what an import would have produced anyway: walk up while `__init__.py` exists (`package_a/tasks.py` -> `package_a.tasks`, `pkg/__init__.py` -> `pkg`), and record the first non-package ancestor relative to the pipeline source directory. `__qualname__` rides along to separate same-named functions nested in different scopes. Consequences: * Repeated identical calls still dedup to exactly one entry. * One function with two configurations emits two entries, and each graph task is rewritten to its own fragment. * `package_a.tasks.run` and `package_b.tasks.run` are two components and both are emitted. The old hard `CompileError` for "two distinct @task source files map to the same sidecar fragment" is removed: it existed to prevent one file silently shadowing the other, and distinct fragments satisfy that intent without rejecting legitimate authoring. Fragment naming keeps the readable name where it is unambiguous. A base with exactly ONE identity stays `run-dbt` (or `combine--<schema-hash>` for an unwrapped task), so existing single-component pipelines emit byte-identical sidecars. Once a base collides, EVERY variant is suffixed `<base>--<hash>` -- no arbitrary first-traced variant keeps the bare name -- and colliding variants are emitted in sorted order so the sidecar text does not depend on trace order. The hash is a SHA-256 prefix over canonical JSON of the identity payload: never `hash()`, never dict or API ordering. Identity paths are anchored at the pipeline's own source directory rather than the output directory, so fragment names survive relocating the project and compiling into a different output directory (the hidden submit bundle compiles elsewhere by design). Emitted `local_from_python` paths stay relative to the sidecar as before. No image, registry, tag, digest, or source-path text reaches a fragment name; the diagnostic for an internal digest collision renders `module::qualname` only. `_build_local_from_python_components` is deleted rather than kept as a wrapper. After this change it had no production caller, and an underscore-private function is not an API worth preserving for downstream tests. `_plan_task_sidecar` requires `identity_root` explicitly for the same reason: defaulting it to the output directory would quietly reintroduce output-dir-dependent fragment names. `examples/python_pipeline/dedup_image_variants/` is a runnable manual check -- one shared helper decorated three times, two images -- whose docstring states the compile command and the exact fragments, task refs, and no-leakage assertions to expect. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Assisted-By: devx/43e17814-42d0-4ca6-91b1-567962ce0219
Review found one identity-equivalence gap in the dedup key: `@task()` omits
`mode` from the emitted block while `@task(mode="inline")` writes it, so the
two hashed apart and produced two sidecar entries for what is provably ONE
component. The hydrator reads `gen_config.get("mode", "inline")` and
`CallableRef` generates with `self._task_mode or "inline"`, and an end-to-end
check confirmed both spellings regenerate the same component digest. The
practical cost was a spurious second entry plus loss of the readable
unsuffixed fragment for a pipeline that only has one component; runtime
behaviour was already correct.
Normalize `mode` to `ref._task_mode or "inline"` inside the identity payload
only. The EMITTED block is untouched, so a pipeline that omits the default
still writes no `mode:` key.
When two spellings of one component do meet, the emitted representative is now
chosen canonically instead of by trace order: rank by key count, then by
canonical JSON. Key count first means the leanest spelling wins, so a pipeline
that already omits the redundant default keeps its existing sidecar bytes when
a sibling call site spells it out.
Audited the other optional fields for the same implicit-default pattern and
deliberately left them alone, with the reasoning recorded beside the
normalization:
* `dependencies_from` — omission means "auto-discover next to the source AT
HYDRATE TIME". Compile-time discovery could disagree with the hydrate-time
layout, so an explicit path is not provably the same component.
* `resolve_root` — not inert in inline mode: `component_from_func` emits a
`tangle_cli_generation_resolve_root` annotation whenever it is set, so it
changes the generated component regardless of mode.
* `image` — omission means "whatever the generator defaults to", not a value
this layer can canonicalize.
Three regressions: omitted vs explicit inline dedup to one legacy `run`
fragment with both refs on it; both call orders emit byte-identical sidecars
with the lean representative; and `mode="bundle"` still splits, so
normalization does not blur a real difference.
The example's quoted digests move with the identity change and are refreshed;
its "observed output" listing now matches the sorted emission order.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Assisted-By: devx/43e17814-42d0-4ca6-91b1-567962ce0219
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
(AI-assisted)
Problem
A pipeline that reaches one shared
@taskfunction from several call sites with different task-level options silently ran every one of them on the first call site's configuration._build_local_from_python_componentskeyed the generated<stem>.components.yamlby the hyphenated function name, so a sharedrun_dbtdecorated once with a slim image and once with a fat image produced a singlerun-dbtentry, and_rewrite_task_componentref_urlsrecomputed that same name per task — pointing every task at the survivor. Nothing failed: the pipeline compiled, validated, and ran, on the wrong image.Fix
The name was never an identity. What hydrate regenerates from an entry is determined by the whole
local_from_pythonblock — image (explicit or resolvedimage_id), function, mode,resolve_root,dependencies_from, the persisted unwrap schema, and the source file — so that block, plus the module-qualified function identity, is what dedup keys on._plan_task_sidecarreturnsTaskSidecarPlan(entries, fragment_by_task); sidecar emission and componentRef rewriting consume the same map, so they cannot drift. A fragment depends on how many distinct components a function generates pipeline-wide, which no per-call-site recomputation can know.__module__. The driver imports pipeline scripts as_tangle_user_pipeline_<uuid>, so runtime__module__is a fresh string every compile for script-defined tasks and a real dotted name for imported ones — unusable. The namespace is derived from the source layout instead: walk up while__init__.pyexists (package_a/tasks.py→package_a.tasks,pkg/__init__.py→pkg), recording the first non-package ancestor relative to the pipeline source dir.__qualname__rides along.run-dbt(orcombine--<schema-hash>when unwrapped), so existing single-component pipelines emit byte-identical sidecars. Once a base collides, every variant is suffixed<base>--<hash>— no arbitrary first-traced winner — and variants are emitted in sorted order so sidecar text is trace-order independent.hash(), never dict/API ordering. Identity paths are anchored at the pipeline source dir, not the output dir, so fragment names survive project relocation and compiling elsewhere (the hidden submit bundle compiles outside the source tree by design). No image, registry, tag, digest, or path text reaches a fragment name.Behaviour changes
package_a.tasks.run+package_b.tasks.runCompileErrorThe removed
CompileError("two distinct @task source files map to the same sidecar fragment") existed to stop one file silently shadowing another; distinct fragments satisfy that intent without rejecting legitimate authoring._build_local_from_python_componentsis deleted, not kept as a wrapper: after this change it had no production caller, and an underscore-private function is not an API worth preserving for downstream tests._plan_task_sidecarrequiresidentity_rootexplicitly, because defaulting it to the output directory would quietly reintroduce output-dir-dependent fragment names.Tests
12 focused tests in
tests/test_pipeline_compiler.py, all through the realcompile_pipelinesurface: same-image dedup; different images → two entries + correct refs + no leakage;image_idresolving to the same ref dedups;dependencies_from;mode/resolve_root; unwrap × image composition; two packages sharing a function name; relocation and different-output-dir fragment stability; call-order determinism (same-file and cross-file); single-variant legacy-name stability.Manual check
examples/python_pipeline/dedup_image_variants/— one shared helper decorated three times (slim ×2 identical, fat ×1), with the compile command and expected assertions in its docstring. Verified:Recompiling into a different output directory yields the same two fragment names.
Downstream
Discovery's
tangle_deployimports and re-exports the deleted_build_local_from_python_components, and has a test asserting the removedCompileError. Both need cleanup in the submodule pin-bump change, not here — they are correct against the currently pinned commit. The replacement surface is_plan_task_sidecar(...) -> TaskSidecarPlan(entries, fragment_by_task). Coordinated with the Discovery-side owner.Release
Patch bump 0.1.13 → 0.1.14 (
pyproject.toml,tangle_cli/__init__.pyfallback,tests/test_packaging.py,uv.lock). Full suite: 1424 passed.