fix(persistence): enforce optimistic concurrency in rotatePrincipalSecrets for relational JDBC - #5504
Conversation
…crets for relational JDBC Include main_secret_hash in the WHERE clause of rotatePrincipalSecrets and verify that rowsUpdated == 1. If 0 rows were updated, throw RetryOnConcurrencyException to prevent silent credential overwrites and orphaned ghost secrets under concurrent rotation. Fixes apache#5503
dimas-b
left a comment
There was a problem hiding this comment.
Nice fix 👍 Thanks for your contribution, @aakashofficial-k01 !
| } | ||
|
|
||
| @ParameterizedTest | ||
| @ValueSource(ints = {1, 2}) |
There was a problem hiding this comment.
Why test older schema versions?
#5349 proposes using one DDL file and requiring the latest schema.
In any case, we should test the latest schema version in this PR.
There was a problem hiding this comment.
Agreed, @dimas-b. Updated the test to target the latest schema version (DatabaseType.H2.getLatestSchemaVersion()). Please take another look when you have a moment.
| .stream() | ||
| .toList(), | ||
| params)); | ||
| if (rowsUpdated == 0) { |
There was a problem hiding this comment.
createPrincipal still rotates twice with the same hash (PolarisTestMetaStoreManager ~515). The second call is stale, which causes this exception during catalog setup. The CAS can remain as-is; the fixture should use the hash returned by the first rotation. WDYT ??
There was a problem hiding this comment.
Thanks for checking, @vigneshio. I too Spotted this after the CI run failure. Updated PolarisTestMetaStoreManager to pass the updated hash on the second rotation.
vigneshio
left a comment
There was a problem hiding this comment.
LGTM.. Thanks @aakashofficial-k01
flyingImer
left a comment
There was a problem hiding this comment.
The CAS fix itself looks right. One error-handling gap: expected contention is surfaced as a server failure. Details inline.
| .stream() | ||
| .toList(), | ||
| params)); | ||
| if (rowsUpdated == 0) { |
There was a problem hiding this comment.
The CAS check looks right, but the new conflict exception needs handling before it reaches the API caller. Neither AtomicOperationMetaStoreManager nor the admin rotation path catches or retries RetryOnConcurrencyException, so it falls through IcebergExceptionMapper to HTTP 500 for an ordinary concurrent rotation. Could we handle this conflict at the rotation boundary, for example with the existing CommitConflictException (409), and add an API-level regression test? The new JDBC test verifies that the winning credentials survive, but does not cover the losing caller's response.
There was a problem hiding this comment.
+1 to using 409 on the losing caller's response.
I do not think adding server-side retries justifies the extra complexity. I do not think this failure mode is common.
There was a problem hiding this comment.
Thanks for the feedback @dimas-b and @flyingImer!
I have updated the PR in commit 4e1870d:
- Metastore mapping: In
AtomicOperationMetaStoreManager.rotatePrincipalSecrets, caughtRetryOnConcurrencyExceptionfromms.rotatePrincipalSecrets(...)and mapped it toBaseResult.ReturnStatus.TARGET_ENTITY_CONCURRENTLY_MODIFIED. - HTTP 409 translation: In
PolarisAdminService.rotateOrResetCredentialsHelper, mappedTARGET_ENTITY_CONCURRENTLY_MODIFIEDtoCommitConflictException(which Polaris translates to HTTP 409 Conflict rather than an unhandled 500). - Regression test: Added
testRotateCredentialsConcurrentModificationThrowsCommitConflictExceptioninPolarisAdminServiceTestto verify that a concurrent modification during credential rotation throwsCommitConflictException.
…onflictException Catch RetryOnConcurrencyException in AtomicOperationMetaStoreManager during rotatePrincipalSecrets and return TARGET_ENTITY_CONCURRENTLY_MODIFIED. Handle TARGET_ENTITY_CONCURRENTLY_MODIFIED and RetryOnConcurrencyException in PolarisAdminService to throw CommitConflictException, returning HTTP 409 Conflict instead of unhandled 500. Add regression test in PolarisAdminServiceTest.
| currentPrincipalEntity.getId(), | ||
| shouldReset, | ||
| currentSecrets.getMainSecretHash()); | ||
| } catch (RetryOnConcurrencyException e) { |
There was a problem hiding this comment.
Is this exception possible at this level now? Why do we have to catch it in two places? Here and in AtomicOperationMetaStoreManager
There was a problem hiding this comment.
Acknowledged @dimas-b. I was extra defensive across both layers after the earlier feedback.
…ialsHelper Since AtomicOperationMetaStoreManager already converts RetryOnConcurrencyException to TARGET_ENTITY_CONCURRENTLY_MODIFIED, remove the redundant try-catch and unused import in PolarisAdminService.
|
@aakashofficial-k01 : Please fix CI errors. It looks like they are related to code format (spotless). |
|
@dimas-b Fixed the spotless formatting issue in PolarisAdminServiceTest. Could you please approve and trigger the CI run when you have a chance? Thank you. |
|
As far as I can tell, previous review comments have been addressed... merging. If someone was missed, let's address in follow-up PRs. |
Rationale
In
JdbcBasePersistenceImpl.rotatePrincipalSecrets, the SQL update againstPRINCIPAL_AUTHENTICATION_DATAomittedmain_secret_hashfrom theWHEREclause and ignored the affected row count fromdatasourceOperations.executeUpdate(...). Under concurrent rotations for the same principal, a slower transaction could silently overwrite a faster transaction's credentials, causing the faster transaction to return HTTP 200 OK with unpersisted, orphaned secrets and resulting in unexpected 401 Unauthorized authentication failures.Fixes #5503
Changes
"main_secret_hash", oldSecretHashto theWHEREclause parameters inJdbcBasePersistenceImpl.rotatePrincipalSecrets.datasourceOperations.executeUpdate(...). IfrowsUpdated == 0, throwRetryOnConcurrencyException, matching the optimistic concurrency pattern used inwriteEntity.rotatePrincipalSecrets_concurrentCollision_throwsRetryOnConcurrencyExceptioncovering both schema version 1 and 2 to verify that concurrent collisions throwRetryOnConcurrencyExceptionand preserve the committed credential in the database.Checklist
JdbcBasePersistenceImplTestcovering schema v1 & v2.writeEntity.CHANGELOG.md(if needed): not needed for internal persistence bugfix.site/content/in-dev/unreleased(if needed): no API documentation changes required.