Skip to content

Fixes #30886: send the weak If-Match validator on ingestion column patches - #30887

Open
ulixius9 wants to merge 3 commits into
mainfrom
amsterdam
Open

Fixes #30886: send the weak If-Match validator on ingestion column patches#30887
ulixius9 wants to merge 3 commits into
mainfrom
amsterdam

Conversation

@ulixius9

@ulixius9 ulixius9 commented Aug 3, 2026

Copy link
Copy Markdown
Member

Describe your changes:

Fixes #30886

patch_mixin._entity_etag recomputed the server's pre-#30497 strong ETag locally — SHA-256("<version>-<updatedAt>") — which EntityETag.generateETag now keeps only as its serialization-failure fallback. So every conditional column tag/description patch failed its precondition, retried, and then rewrote non-conditionally: two wasted HTTP round trips per entity plus a WARN on both sides, and the optimistic locking the header exists for was silently absent, leaving the wrong-column hazard live. This sends W/"<version>" instead — the validator validateETag accepts via isWeakMatch — which, unlike the strong ETag, is independent of the caller's fields projection and so is the only validator a client can reproduce.

Type of change:

  • Bug fix

High-level design:

Why the strong ETag cannot work for writes. ETagResponseFilter hashes the response entity — the caller's fields projection. EntityRepository.patch validates If-Match against original = get(null, id, patchFields, …) — the repository's projection (TableRepository.PATCH_FIELDS = tableConstraints,tablePartition,columns + tags). Same entity, same version, different bytes, different hash. No client can satisfy it, however faithfully it stores the ETag.

Why weak is the right validator. A cache validator must vary with the response body (that's #30497, correct and untouched here); a write validator must not vary with the projection. One ETag can't do both — which is what HTTP's strong/weak distinction is for. W/"<version>" is projection-independent, is what the row-level CAS in storeEntityDAO.updateWithVersion already keys on, and has been accepted since #22291, so older servers either honour it or ignore If-Match entirely. EntityUtil.nextVersion rounds to one decimal place, which is what lets Python reproduce the Java Double rendering.

Alternative rejected: echoing the ETag from the client's own GET — the textbook-correct approach — still 412s for the projection reason above, and would need raw=True plumbing through the shared REST client to reach the header. The server-side contract fix that would make it viable is filed separately as #30885; this PR is deliberately client-side plus documentation, with no change to server behaviour.

Scope note: the three server-side LOG.infoLOG.debug downgrades are an isolated second commit (fefd883c41), revertable without touching the fix. The signal-bearing lines are untouched: a failed precondition still WARNs, and a successful conditional write still logs store()'s "Updated … with version check" at INFO.

Tests:

Use cases covered

  • Auto Classification tags a column on an unmodified table → the conditional PATCH succeeds on the first attempt (previously: 412 → refetch → non-conditional rewrite, on 100% of patches)
  • Another writer bumps the entity between the client's read and its write → 412, refetch, retry conditionally against the new version (real optimistic locking, restored)
  • The precondition is genuinely unsatisfiable → one retry, then a non-conditional write so the tag is never dropped, with a log line that names the validator instead of blaming "concurrent modification"
  • Same three behaviours on the column-description path, which carries its own copy of the retry loop

Unit tests

  • I added unit tests for the new/changed logic.
  • Added: ingestion/tests/unit/ometa/test_patch_mixin_etag.py (11 tests) — pins the wire format across 0.1 / 0.4 / 1.0 / 2.3 / 10.0, plus the retry/fallback cycle
  • Added: EntityETagTest.weakETagIsTheContractForNonJavaClients — asserts the same literals as the Python test, so the two sides are pinned independently and a rename on either fails a test
  • Added: EntityETagTest.weakETagValidatesAcrossProjectionsWhereStrongETagCannot — encodes the root cause: same entity + version under two projections, strong throws PreconditionFailedException, weak validates
  • Evidence: pytest … -q11 passed; the same tests run against the pre-fix implementation → 9 failed (genuine RED→GREEN). mvn test -pl openmetadata-service -Dtest=EntityETagTest11 tests, 0 failures
  • Coverage: patch_mixin.py 18% → 25% module-wide from these tests. The module figure is low because the file holds ~30 unrelated patch helpers exercised only by integration tests; every branch this PR changes on the tag path is unit-covered, and on the description path the conditional-write branch is unit-covered with its 412 branches covered by the pre-existing test_patch_column_falls_back_when_etag_unusable

Backend integration tests

  • I added integration tests in openmetadata-integration-tests/.
  • Added to OptimisticLockingColumnPatchIT: patchWithWeakVersionIfMatch_columnTag_persists and patchWithStaleWeakVersionIfMatch_isRejected. Every pre-existing If-Match test in that class used *, which matches any ETag — so they cover the Optimistic-locking (If-Match) PATCH silently drops nested column tag/description changes #28876 updater delegation but structurally cannot catch a validator mismatch. These build the header the way the client does. Compiles clean (mvn test-compile); they need a live stack, so they first execute in CI.

Ingestion integration tests

  • Not applicable — no connector changes. Note that tests/integration/ometa/test_ometa_patch.py::test_patch_column_tags_retries_on_concurrent_modification was previously vacuous (it injected a concurrent writer, but every If-Match 412'd regardless, so it could not tell the injected bump from the formula mismatch) and now tests what its name says.

Playwright (UI) tests

  • Not applicable — no UI changes. The UI never sends If-Match; etagInterceptor.ts only issues If-None-Match conditional GETs.

Manual testing performed

Diagnosed from an Auto Classification agent run against a Redshift service (29 tables): 14 assets had a tag to write, and all 14 logged the 412-then-fallback pair with zero conditional writes succeeding — the 1:1 ratio is what identified this as a deterministic formula mismatch rather than contention. Post-fix behaviour is asserted by the tests above rather than re-run by hand; mvn spotless:check and ruff check/ruff format are clean on all changed files.

UI screen recording / screenshots:

Not applicable.

Checklist:

  • I have read the CONTRIBUTING document.
  • My PR title is Fixes <issue-number>: <short explanation>
  • My PR is linked to a GitHub issue via Fixes #<issue-number> above.
  • I have commented on my code, particularly in hard-to-understand areas.
  • For JSON Schema changes: not applicable — no schema changes.
  • For UI changes: not applicable.
  • I have added tests (unit / integration) and listed them above.
  • I have added a test that covers the exact scenario we are fixing. For complex issues, comment the issue number in the test for future reference.

🤖 Generated with Claude Code

ulixius9 and others added 3 commits August 3, 2026 21:52
patch_mixin._entity_etag recomputed the server's pre-#30497 strong ETag
(SHA-256 of "<version>-<updatedAt>"), which the server now keeps only as
generateETag's serialization-failure fallback. Every conditional column
tag/description patch therefore failed its precondition, retried, and
rewrote non-conditionally: two wasted round trips per entity and no
optimistic locking, so a concurrent column add/remove/reorder could still
send an index-based patch to the wrong column.

Send W/"<version>" instead - the form validateETag accepts via isWeakMatch.
Unlike the strong ETag it does not depend on the caller's `fields`
projection, so it is the only validator a client can reproduce; it is what
the row-level compare-and-swap already keys on, and it has been accepted
since ETag support landed in #22291, so older servers either honour it or
ignore If-Match entirely.

The strong ETag remains correct for If-None-Match/304 caching and is
untouched. Why it cannot serve conditional writes is now documented on
EntityETag.generateWeakETag/validateETag; the server-side half is #30885.

Tests pin the validator format on both sides to the same literals, so a
future change to it fails a test instead of silently degrading every
ingestion to last-write-wins. OptimisticLockingColumnPatchIT previously
only used If-Match: *, which matches any ETag and so could not catch this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three "function called with args" lines fired at INFO on every conditional
PATCH: validateETag's entry + computed-ETag lines and EntityRepository's
pre-validation line (both patch call sites). A first-time classification
over a 10k-table catalog emits ~30k of them.

The two lines that carry signal are untouched, so nothing observable is
lost: a failed precondition still logs EntityETag's "ETag mismatch" WARN,
and a successful conditional write still logs store()'s "Updated ... with
version check" at INFO, which runs only on the version-CAS path.

Separate commit so it can be reverted independently of the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
patch_column_descriptions carries its own copy of the retry loop, so the
weak-validator change lands there too but was only covered transitively.
Assert the header it sends, so the two paths cannot drift apart silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ulixius9
ulixius9 requested review from a team as code owners August 3, 2026 16:26
Copilot AI review requested due to automatic review settings August 3, 2026 16:26
@github-actions github-actions Bot added Ingestion safe to test Add this label to run secure Github workflows on PRs labels Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

❌ PR checklist incomplete

This PR cannot be merged until the following are addressed on its linked issue:

The fields live on the linked issue in the Shipping project (open the issue → right sidebar → Projects). After you set them, re-run this check (or push a commit) — issue/project changes do not re-trigger it automatically.

Maintainers can bypass this check by adding the skip-pr-checks label.

return None
raw = f"{model_str(version)}-{model_str(updated_at)}"
return '"' + hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] + '"'
return f'W/"{float(model_str(version)):.1f}"'

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Edge Case: Weak ETag format relies on versions always being one decimal

Python renders the validator with a fixed :.1f, while the Java server uses Double.toString(version). These agree only because EntityUtil.nextVersion yields exact n/10.0 values today; a version with two decimals (e.g. 1.25) or one large enough to trigger Java scientific notation (>=1e7) would render differently and silently degrade every conditional write to the last-write-wins fallback rather than fail loudly. This is not a data-loss risk (the fallback still persists the change) and both sides pin the current literals in tests, so it is defensive only — but a comment or a shared assertion tying :.1f to the nextVersion rounding invariant would prevent a future version-scheme change from silently disabling optimistic locking for all non-Java clients.

Was this helpful? React with 👍 / 👎

@gitar-bot

gitar-bot Bot commented Aug 3, 2026

Copy link
Copy Markdown
Code Review 👍 Approved with suggestions 0 resolved / 1 findings

Sends the weak ETag validator W/"<version>" on ingestion column patches to fix projection-mismatch 412 failures. Consider addressing the minor finding regarding the version decimal rendering format to ensure robustness.

💡 Edge Case: Weak ETag format relies on versions always being one decimal

📄 ingestion/src/metadata/ingestion/ometa/mixins/patch_mixin.py:103 📄 openmetadata-service/src/main/java/org/openmetadata/service/util/EntityETag.java:108

Python renders the validator with a fixed :.1f, while the Java server uses Double.toString(version). These agree only because EntityUtil.nextVersion yields exact n/10.0 values today; a version with two decimals (e.g. 1.25) or one large enough to trigger Java scientific notation (>=1e7) would render differently and silently degrade every conditional write to the last-write-wins fallback rather than fail loudly. This is not a data-loss risk (the fallback still persists the change) and both sides pin the current literals in tests, so it is defensive only — but a comment or a shared assertion tying :.1f to the nextVersion rounding invariant would prevent a future version-scheme change from silently disabling optimistic locking for all non-Java clients.

🤖 Prompt for agents
Code Review: Sends the weak ETag validator `W/"<version>"` on ingestion column patches to fix projection-mismatch 412 failures. Consider addressing the minor finding regarding the version decimal rendering format to ensure robustness.

1. 💡 Edge Case: Weak ETag format relies on versions always being one decimal
   Files: ingestion/src/metadata/ingestion/ometa/mixins/patch_mixin.py:103, openmetadata-service/src/main/java/org/openmetadata/service/util/EntityETag.java:108

   Python renders the validator with a fixed `:.1f`, while the Java server uses `Double.toString(version)`. These agree only because `EntityUtil.nextVersion` yields exact `n/10.0` values today; a version with two decimals (e.g. `1.25`) or one large enough to trigger Java scientific notation (`>=1e7`) would render differently and silently degrade every conditional write to the last-write-wins fallback rather than fail loudly. This is not a data-loss risk (the fallback still persists the change) and both sides pin the current literals in tests, so it is defensive only — but a comment or a shared assertion tying `:.1f` to the `nextVersion` rounding invariant would prevent a future version-scheme change from silently disabling optimistic locking for all non-Java clients.

Options

Display: compact → Showing less information.

Comment with these commands to change the behavior for this request:

Compact
gitar display:verbose         

Was this helpful? React with 👍 / 👎 | Gitar | Powered by Gitar — free for open source

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes ingestion column tag/description conditional PATCHes always failing If-Match preconditions by switching the Python client’s locally-computed validator to the server-accepted weak ETag form (W/"<version>"), restoring effective optimistic locking and avoiding deterministic 412→retry→unconditional fallback behavior.

Changes:

  • Update ingestion patch_mixin._entity_etag to emit weak version validators (W/"<version>") and improve retry/fallback log messages.
  • Document the strong-vs-weak ETag contract in EntityETag.validateETag/generateWeakETag and add backend tests proving weak validators work across projections where strong ones cannot.
  • Add ingestion unit tests and backend integration tests that pin the weak ETag wire format and validate stale-version rejection.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.

Show a summary per file
File Description
ingestion/src/metadata/ingestion/ometa/mixins/patch_mixin.py Switches client-side If-Match generation to weak version ETag and improves retry/fallback logging.
ingestion/tests/unit/ometa/test_patch_mixin_etag.py Adds unit coverage pinning weak ETag format + retry/fallback behavior for column tag/description patch helpers.
openmetadata-service/src/main/java/org/openmetadata/service/util/EntityETag.java Documents weak ETag as the required optimistic-write validator; reduces verbose INFO logs to DEBUG in validateETag.
openmetadata-service/src/test/java/org/openmetadata/service/util/EntityETagTest.java Adds tests pinning weak ETag rendering and proving weak validation survives projection changes while strong cannot.
openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java Downgrades optimistic-locking PATCH ETag-validation log lines from INFO to DEBUG.
openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OptimisticLockingColumnPatchIT.java Adds integration tests using weak version If-Match to ensure persistence and stale-version rejection.

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🔴 Playwright Results — workflow failed

Validated commit efb3abbbae9ea93efd987499f5f03a7d423b3295 in Playwright run 30832210569, attempt 1.

✅ 607 passed · ❌ 0 failed · 🟡 1 flaky · ⏭️ 3 skipped · 🧰 0 lifecycle flaky

Pipeline and setup failures (1)

  • Playwright performance gate Maximum shard-job elapsed before upload failed (target ≤ 1800 s) — exceeded on 1 shard(s): chromium-02 1917 s.

Performance

Blocking targets: ❌ unmet · Optimization targets: 🟡 in progress

Shard-job maxima below are not the full workflow wall time; the linked run includes build, fixture, planning, and reporting.

🕒 Full workflow signal wall (to summary) 1h 4m 57s

⏱️ Max setup 3m 11s · max shard execution 18m 24s · max shard-job elapsed before upload 31m 57s · reporting 6s

🌐 208.36 requests/attempt · 2.76 app boots/UI scenario · 4.26% common-shard skew

Optimization targets still in progress:

  • Browser traffic was 208.36 requests per attempt (convergence target: fewer than 200).
  • Application boot ratio was 2.76 per UI scenario (1763 boots / 639 scenarios; convergence target: at most 1).
Shard Passed Failed Flaky Skipped Lifecycle failed Lifecycle flaky
🟡 Shard chromium-01 132 0 1 0 0 0
✅ Shard chromium-02 148 0 0 3 0 0
✅ Shard chromium-03 118 0 0 0 0 0
✅ Shard data-asset-rules-01 61 0 0 0 0 0
✅ Shard domain-isolation-01 14 0 0 0 0 0
✅ Shard global-state-01 34 0 0 0 0 0
✅ Shard ingestion-01 31 0 0 0 0 0
✅ Shard ingestion-02 28 0 0 0 0 0
✅ Shard reindex-01 2 0 0 0 0 0
✅ Shard search-01 10 0 0 0 0 0
✅ Shard search-rbac-01 29 0 0 0 0 0
🟡 1 flaky test(s) (passed on retry)
  • Pages/Entity.spec.tsDomain Propagation (shard chromium-01, 1 retry)

📦 Download artifacts

How to debug locally
# Download playwright-test-results-<shard> artifact and unzip
npx playwright show-trace path/to/trace.zip    # view trace

@sonarqubecloud

sonarqubecloud Bot commented Aug 3, 2026

Copy link
Copy Markdown

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

Labels

Ingestion safe to test Add this label to run secure Github workflows on PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

patch_column_tags sends an unsatisfiable If-Match: every column tag/description patch 412s and loses optimistic locking

2 participants