Skip to content

Fix column lineage transformation details always appearing as null - #3114

Open
kalra-mohit wants to merge 2 commits into
MarquezProject:mainfrom
kalra-mohit:fix/3100-column-lineage-transformation-details
Open

Fix column lineage transformation details always appearing as null#3114
kalra-mohit wants to merge 2 commits into
MarquezProject:mainfrom
kalra-mohit:fix/3100-column-lineage-transformation-details

Conversation

@kalra-mohit

@kalra-mohit kalra-mohit commented Jul 22, 2026

Copy link
Copy Markdown

Marquez's LineageEvent.ColumnLineageInputField (used to deserialize the OpenLineage ColumnLineageDatasetFacet) only ever captured namespace/name/field — no transformations property existed on it at all. Meanwhile the write path, OpenLineageDao.upsertColumnLineage, only read transformationDescription/transformationType off the output column (ColumnLineageOutputColumn), which the current OpenLineage spec marks deprecated: true.

Modern producers report transformation details per input field instead, nested under inputFields[].transformations[] — exactly what's in the issue's example:

"customer_full": {
  "inputFields": [
    {
      "namespace": "bookstore2", "name": "customers", "field": "customer_email",
      "transformations": [{ "type": "DIRECT", "subtype": "TRANSFORMATION", "description": "concat(...)" }]
    }
  ]
}

Since the model had nowhere to deserialize that array into, and the write path never looked at the deprecated output-column fields for producers that only emit the new shape, transformation_description/transformation_type landed as null for anyone using the current facet format — reproducing the bug as reported (a second user hit the same thing in the issue thread).

flowchart TD
    E["OpenLineage event:
inputFields[].transformations[] (current spec)"]
    E --> Old["Before: ColumnLineageInputField has no transformations field"]
    Old --> O1["Write path only reads deprecated
outputColumn.transformationDescription/Type"]
    O1 --> R1["null/null persisted whenever producer
only emits the new per-field format"]
    E --> New["After: transformationOf(inputField, outputColumn)"]
    New --> O2{"inputField.transformations present?"}
    O2 -->|yes| R2["Use that field's own transformation
description/type"]
    O2 -->|no - legacy producer| R3["Fall back to deprecated
outputColumn description/type"]
Loading

Fix:

  • Added transformations (List<Transformation>) to ColumnLineageInputField, with a new Transformation class (type/subtype/description/masking) mirroring the spec. Kept the existing 3-arg constructor working unchanged so no call site needed to move — verified by compiling all 8 existing usages untouched.
  • Added OpenLineageDao.transformationOf(inputField, outputColumn) — a small pure function that prefers the first entry of that specific input field's own transformations list, and falls back to the deprecated output-column-level fields for producers still on the old format.
  • upsertColumnLineage now resolves the transformation per matched input field via transformationOf and groups input fields by their resolved (description, type) before calling the existing batch ColumnLineageDao.upsertColumnLineageRow(...) once per group. That persists a distinct transformation per input/output edge, which column_lineage and the read path already supported — without touching ColumnLineageDao's public signature, so ColumnLineageDaoTest didn't need to change either.

Testing: new OpenLineageDaoColumnLineageTransformationTest (unit, no DB) exercises transformationOf directly against the issue's exact scenario, plus multi-field, fallback-to-deprecated, both-formats-absent, and multiple-transformations-on-one-field cases. Did the fail-then-pass check by hand: temporarily reverted transformationOf to always read the deprecated fields, saw 3 of 5 tests fail with null description/type, restored the fix, all 5 passed.

Also added OpenLineageDaoTest#testUpdateMarquezModelDatasetWithColumnLineageFacet_usesNewTransformationsFormat, a full DB-backed test that builds a facet using only the new inputFields[].transformations[] format (deprecated fields deliberately left unset, matching real modern producers) and checks the persisted row has a non-null description/type.

./gradlew :api:testUnit passes (123 tests, including the 5 new ones). Couldn't run the Postgres-backed OpenLineageDaoTest locally — same Testcontainers/Docker API mismatch I keep hitting in this sandbox (bundled docker-java wants API v1.32, the local engine needs >= v1.40; confirmed pre-existing by hitting it on unmodified RunDaoTest too). :api:compileTestJava passes, so the new integration test at least compiles clean against the real classes. Would appreciate CI running the DB suite here.

Known gap I'm leaving in (marked with a comment in the code)

While working on this I found a real edge case in upsertColumnLineage that's worth surfacing outside a code comment. When a single output column's input fields span 2+ distinct transformation groups, upsertColumnLineageRow(...) gets called once per group — but that method's existing implementation always returns all column_lineage rows currently persisted for that (outputDatasetVersionUuid, outputDatasetFieldUuid) pair, not just the rows the current call wrote. So across multiple groups, the same row can show up more than once in the List<ColumnLineageRow> this method returns.

It has no production impact right now: the only consumer of that return value, DatasetRecord#getColumnLineageRows(), isn't read anywhere in production code — only in tests, and this PR's own new test only exercises the single-group case, so it doesn't trigger the duplication. The rows actually persisted in column_lineage are correct either way; this only affects the in-memory list handed back up the call stack.

I left a // NOTE: comment right above the relevant code explaining this, in case someone adds a real consumer of getColumnLineageRows() later — they'd want to either dedupe or change upsertColumnLineageRow's contract to return only newly-written rows. Not fixing it here since it's inert today and out of scope for the null-transformation bug this PR is actually about.

Fixes #3100.

@boring-cyborg boring-cyborg Bot added the api API layer changes label Jul 22, 2026
…arquezProject#3100)

Marquez's internal LineageEvent.ColumnLineageInputField model (used
to deserialize the OpenLineage ColumnLineageDatasetFacet on
ingestion) only captured namespace/name/field for each input field -
it had no `transformations` property at all. The write path
(OpenLineageDao.upsertColumnLineage) only ever read
transformationDescription/transformationType off the *output
column* object (ColumnLineageOutputColumn), which per the current
OpenLineage spec are explicitly deprecated:

  https://github.com/OpenLineage/OpenLineage/blob/main/spec/facets/ColumnLineageDatasetFacet.json

Modern producers report transformation details per input field
instead, via a `transformations` array nested under each entry of
`inputFields[]` (each with type/subtype/description/masking) - this
is exactly the format shown in the issue's example JSON. Since
Marquez's ingestion model didn't have a `transformations` field to
deserialize that array into, and never read the deprecated
output-column-level fields from producers that only emit the new
format, transformation_description/transformation_type were written
as null for any producer using the current (non-deprecated) facet
shape - reproducing the bug exactly as reported.

Fix:
  - Added `transformations` (List<Transformation>) to
    LineageEvent.ColumnLineageInputField, with a new nested
    Transformation class (type, subtype, description, masking)
    mirroring the OpenLineage spec. The existing 3-arg constructor
    (namespace, name, field) is preserved unchanged, so no existing
    call site needed to be touched.
  - Added OpenLineageDao.transformationOf(inputField, outputColumn),
    a small pure function that resolves the (description, type) pair
    for a given input field: prefer the first entry of that specific
    input field's (non-deprecated) `transformations` list; fall back
    to the deprecated whole-output-column
    transformationDescription/transformationType for producers that
    still only report the old format.
  - Updated OpenLineageDao.upsertColumnLineage to resolve the
    transformation per matched input field (via transformationOf)
    and group input fields by their resolved (description, type)
    before calling the existing ColumnLineageDao.upsertColumnLineageRow(...)
    batch API once per group. This correctly persists a distinct
    transformation per input/output field edge - which the
    column_lineage table and the read path (ColumnLineageService,
    which already surfaces per-input-field transformation info) both
    already supported - without changing
    ColumnLineageDao's public method signature, so no existing
    ColumnLineageDaoTest call sites needed to change.

Tests:
  - api/src/test/java/marquez/db/OpenLineageDaoColumnLineageTransformationTest.java
    (new, tagged UnitTests, no DB required): exercises
    OpenLineageDao.transformationOf(...) directly against the exact
    scenario from the issue (an output column with no deprecated
    fields set, fed by input fields that each report their own
    `transformations` entry), plus the multi-input-field, fallback,
    "both formats absent", and "multiple transformations reported"
    cases. I verified these tests explicitly: temporarily reverted
    transformationOf(...) to the old behavior (always read the
    deprecated output-column-level fields) and confirmed 3 of 5
    tests fail with the expected description/type coming back null -
    then restored the fix and confirmed all 5 pass.
  - OpenLineageDaoTest#testUpdateMarquezModelDatasetWithColumnLineageFacet_usesNewTransformationsFormat
    (new): full DB-backed regression test that builds a
    ColumnLineageDatasetFacet using only the new
    inputFields[].transformations[] format (deprecated fields left
    unset, as real modern producers do) and asserts the persisted
    ColumnLineageRow has the expected non-null transformation
    description/type.

Test evidence: ./gradlew :api:testUnit passes (123 tests, including
the 5 new pure-function tests). I was not able to execute
OpenLineageDaoTest (Postgres-backed) locally in this sandbox -
Testcontainers fails to negotiate with the local Docker Engine (old
docker-java client defaults to Docker API v1.32, local engine
requires >= v1.40); this is a pre-existing environment limitation
that affects every DB-backed test in the suite, not something
introduced by this change (verified identically on unmodified tests
like RunDaoTest). ./gradlew :api:compileTestJava passes, confirming
the new integration test at least compiles correctly against the
real DAO/model classes.

Fixes MarquezProject#3100

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Mohit Kalra <mohit2494@gmail.com>
@kalra-mohit
kalra-mohit force-pushed the fix/3100-column-lineage-transformation-details branch from 72de9f6 to 96fb5f2 Compare July 22, 2026 01:35
When a single output field has 2+ distinct transformation groups,
upsertColumnLineageRow(...) is called once per group, and its
implementation always returns ALL column_lineage rows for that
(datasetVersion, outputField) pair rather than just the rows from
the current call. Across multiple groups the same underlying row
can therefore appear duplicated in the returned List<ColumnLineageRow>.

No production impact today: DatasetRecord#getColumnLineageRows(), the
only consumer of this return value, isn't read anywhere in production
code, only in this PR's own test (which only covers the
single-transformation-group case). Documented for future maintainers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Mohit Kalra <mohit2494@gmail.com>
@kalra-mohit
kalra-mohit marked this pull request as ready for review July 22, 2026 02:30
@kalra-mohit

Copy link
Copy Markdown
Author

@merobi-hub whenever you get a chance to take a look, happy to address any feedback!

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

Labels

api API layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Transformation details in column lineage always appear as null

1 participant