Skip to content

Dedup @task sidecar entries by component identity (v0.1.14) - #57

Merged
Volv-G merged 2 commits into
masterfrom
piforge/tangle-pipeline-crud/sidecar-dedup-by-component-ident-08dc329
Sep 16, 2026
Merged

Volv-G merged 2 commits into
masterfrom
piforge/tangle-pipeline-crud/sidecar-dedup-by-component-ident-08dc329

Conversation

@Volv-G

@Volv-G Volv-G commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

(AI-assisted)

Problem

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.

Fix

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 keys on.

  • One plan, two consumers. _plan_task_sidecar returns TaskSidecarPlan(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-qualified identity, not __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__.py exists (package_a/tasks.pypackage_a.tasks, pkg/__init__.pypkg), recording the first non-package ancestor relative to the pipeline source dir. __qualname__ rides along.
  • Readable names where unambiguous. A base with exactly one identity stays run-dbt (or combine--<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.
  • Deterministic, leak-free hashes. SHA-256 prefix over canonical JSON; never 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

case before after
same function, same config, N calls 1 entry 1 entry (unchanged)
same function, different image/deps/mode/unwrap 1 entry, wrong image for N−1 tasks N entries, each task on its own
package_a.tasks.run + package_b.tasks.run hard CompileError 2 entries, both emitted

The 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_components is 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_sidecar requires identity_root explicitly, 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 real compile_pipeline surface: same-image dedup; different images → two entries + correct refs + no leakage; image_id resolving 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:

run-dbt--b196ad73a4   image: python:3.12-slim   <- daily_orders, hourly_sessions
run-dbt--6024a73044   image: python:3.12        <- backfill_orders

Recompiling into a different output directory yields the same two fragment names.

Downstream

Discovery's tangle_deploy imports and re-exports the deleted _build_local_from_python_components, and has a test asserting the removed CompileError. 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__.py fallback, tests/test_packaging.py, uv.lock). Full suite: 1424 passed.

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
@Volv-G
Volv-G requested a review from Ark-kun as a code owner September 16, 2026 17:25
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
@Volv-G
Volv-G merged commit 1a7582e into master Sep 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant