Fix dataset_fields duplication for NULL type causing column_lineage explosion - #3113
Open
kalra-mohit wants to merge 1 commit into
Open
Conversation
…xplosion (MarquezProject#3083) DatasetFieldDao.upsert inserts into dataset_fields with an ON CONFLICT(dataset_uuid, name, type) target, relying on the (dataset_uuid, name, type) unique constraint to turn repeated upserts of the same field into an UPDATE. In standard SQL, NULL is never considered equal to another NULL for uniqueness purposes, so a field with an unknown/omitted type (a common, legitimate case for OpenLineage events that don't report column types) never matches an existing row: a brand new dataset_fields row - with a new UUID - is inserted on every single upsert instead of updating the existing one. Because each distinct dataset_fields row independently accumulates its own column_lineage edges (column_lineage references output/input dataset_field_uuid), this turns a linear ingestion process into a combinatorial one: N duplicate field rows for a null-typed field produce N column_lineage rows for what should be a single relationship, exactly as described in the issue (observed 1000x+ row bloat in column_lineage). I independently reproduced and verified this bug and its fix against a real Postgres 16 instance (matching the version pinned by this repo's tests) using the actual INSERT/ON CONFLICT SQL, outside of Gradle/Testcontainers: - Before fix: two upserts of the same (dataset_uuid, name) with type = NULL produced 2 distinct dataset_fields rows. - After fix: the same two upserts produced exactly 1 row, with the UUID from the first insert preserved (confirming the second upsert performed an UPDATE, not an INSERT). Fix: normalize a NULL type to the sentinel literal 'UNKNOWN' before insertion (COALESCE(:type, 'UNKNOWN')), so repeated upserts of a field with no known type reliably collide with the previously inserted row on the existing unique constraint. This matches the fix suggested directly in the issue report and avoids any change to the dataset_fields schema (no migration needed), at the cost of a dataset field's type now surfacing as the literal string "UNKNOWN" via the API instead of null when the type was never reported - updated testDatasetWithUnknownFieldType in MarquezAppIntegrationTest to reflect this. Tests added: - api/src/test/java/marquez/db/DatasetFieldDaoTest.java (new): DAO-level regression tests asserting repeated upserts of a null-typed field resolve to the same row/UUID, that the stored type becomes "UNKNOWN", and that real (non-null) types are unaffected. - MarquezAppIntegrationTest#testDatasetWithUnknownFieldType_repeatedUpsertsDoNotDuplicateField (new): full-stack regression test emitting the same dataset with a null-typed field 5 times and asserting only a single field is returned. - MarquezAppIntegrationTest#testDatasetWithUnknownFieldType: updated to expect the new "UNKNOWN" sentinel value. Test evidence: I was unable to execute these Postgres-backed DAO/ integration tests locally via Gradle - Testcontainers in this sandbox fails to negotiate with the local Docker Engine (an old docker-java client bundled in the pinned Testcontainers version defaults to Docker API v1.32, while the local engine requires >= v1.40). This affects every DB-backed test in the suite (verified the same failure on pre-existing, unmodified tests like RunDaoTest), so it is an environment limitation unrelated to this change. In lieu of that, I validated the exact INSERT/ON CONFLICT SQL directly against a real Postgres 16 container via psql (see PR description for the full before/after transcript). ./gradlew :api:compileTestJava and :api:testUnit both pass. Fixes MarquezProject#3083 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Mohit Kalra <mohit2494@gmail.com>
kalra-mohit
force-pushed
the
fix/3083-dataset-fields-null-type-explosion
branch
from
July 22, 2026 01:35
3a77f7b to
3672683
Compare
kalra-mohit
marked this pull request as ready for review
July 22, 2026 02:30
Author
|
@merobi-hub whenever you get a chance to take a look, happy to address any feedback! |
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.
DatasetFieldDao.upsertrelies on the(dataset_uuid, name, type)unique constraint to turn a repeat upsert of the same field into anUPDATE:Problem is standard SQL never treats
NULL = NULLas true for uniqueness/ON CONFLICTpurposes. Plenty of OpenLineage producers don't report a column type, so any field upserted withtype = NULLnever matches its own previously-inserted row — a fresh row with a new UUID gets inserted every time. Each of those rows independently accumulates its owncolumn_lineageedges, so what should be one relationship turns into N, and N grows unbounded with every event. That's the "explosion" from #3083 — the reporter saw 1000x+ row bloat.flowchart TD U["upsert dataset_fields (dataset_uuid, name, type = NULL)"] U --> C{"ON CONFLICT (dataset_uuid, name, type)"} C --> Old["Before: type stored as raw NULL"] Old --> N1["NULL <> NULL, conflict never matches"] N1 --> R1["New row inserted every upsert N rows for 1 field -> N x column_lineage rows"] C --> New["After: type stored as COALESCE(:type, 'UNKNOWN')"] New --> N2["'UNKNOWN' = 'UNKNOWN', conflict matches"] N2 --> R2["Existing row updated single dataset_fields row retained"]Fix: coalesce a
NULLtype to the sentinel'UNKNOWN'before insert. Repeated upserts of an unknown-type field now reliably collide with the row already there. No schema/migration change, no risk to rows that already have a real type — this is the fix suggested in the issue itself. One visible side effect: a field'stypenow shows up as the literal string"UNKNOWN"via the API instead ofnullwhen nothing was reported. UpdatedMarquezAppIntegrationTest#testDatasetWithUnknownFieldTypeto match.Verification: ran the actual
INSERT ... ON CONFLICTSQL by hand against a real Postgres 16 instance viapsql, outside Gradle/Testcontainers (see below for why). Two upserts of the same(dataset_uuid, name)withtype = NULL: before the fix,count = 2; after,count = 1, and the returneduuidwas identical across both calls, confirming the second call updated rather than inserted.Correction on that verification, since I got it wrong in an earlier draft of this description and want to correct it plainly rather than leave it: I'd said the Postgres 16 instance I tested against matched "the version pinned by this repo's own tests." That's not accurate. I went and checked
api/src/test/java/marquez/PostgresContainer.java(which backs the Testcontainers extension this PR's own newDatasetFieldDaoTestuses), plusdocker-compose.ymlanddocker-compose.db.yml— they all still pin Postgres 14. There's an incomplete, unmergedpostgres-16branch on the remote, and only two unrelated test files (DbRetentionTest,StatsTest) touch Postgres 16 today. So my manual verification used Postgres 16 while this repo's actual pinned test infrastructure runs Postgres 14 — a real version mismatch on my part. It doesn't change the fix's correctness though: the relevant SQL semantics here (NULLnever equalsNULLforON CONFLICT/unique-constraint purposes) are identical across Postgres 14 and 16.Tests:
DatasetFieldDaoTest(new) — DAO-level: repeated upserts of a null-typed field resolve to the same row/UUID, the stored type becomes"UNKNOWN", and real (non-null) types are unaffected.MarquezAppIntegrationTest#testDatasetWithUnknownFieldType_repeatedUpsertsDoNotDuplicateField(new) — emits the same dataset with a null-typed field 5 times, mirroring the issue's repro steps, and asserts only one field comes back, not five.testDatasetWithUnknownFieldType— updated to expect the"UNKNOWN"sentinel.Couldn't run these locally through Gradle — same Testcontainers/Docker API mismatch I keep hitting in this sandbox (bundled
docker-javadefaults to API v1.32, local engine needs >= v1.40; confirmed it's pre-existing by hitting the identical failure on unmodifiedRunDaoTest)../gradlew :api:compileTestJavaand:api:testUnit(118 tests) both pass. Given the local DB test gap, the manualpsqlverification above is what I'm actually leaning on to confirm correctness — would appreciate CI running the full DB-backed suite on this one.Fixes #3083.