|
25 | 25 | import static org.mockito.ArgumentMatchers.any; |
26 | 26 | import static org.mockito.Mockito.doCallRealMethod; |
27 | 27 | import static org.mockito.Mockito.doThrow; |
| 28 | +import static org.mockito.Mockito.never; |
28 | 29 | import static org.mockito.Mockito.verify; |
29 | 30 | import static org.mockito.Mockito.when; |
30 | 31 |
|
31 | 32 | import java.io.IOException; |
32 | 33 | import java.io.InputStream; |
| 34 | +import java.sql.Connection; |
33 | 35 | import java.sql.SQLException; |
34 | 36 | import java.util.List; |
35 | 37 | import java.util.Optional; |
|
43 | 45 | import org.apache.polaris.core.entity.PolarisEntityType; |
44 | 46 | import org.apache.polaris.core.entity.PolarisGrantRecord; |
45 | 47 | import org.h2.jdbcx.JdbcConnectionPool; |
| 48 | +import org.junit.jupiter.api.Test; |
46 | 49 | import org.junit.jupiter.params.ParameterizedTest; |
47 | 50 | import org.junit.jupiter.params.provider.ValueSource; |
48 | 51 | import org.mockito.ArgumentCaptor; |
@@ -289,6 +292,124 @@ private static PolarisBaseEntity newTestEntity( |
289 | 292 | .build(); |
290 | 293 | } |
291 | 294 |
|
| 295 | + /** |
| 296 | + * On the create path (no original entity) {@code writeEntities} must detect an already-created |
| 297 | + * entity with a cheap {@code SELECT 1 ... LIMIT 1} existence check that does not fetch the large |
| 298 | + * JSON property blobs, and a retried create must stay idempotent. |
| 299 | + */ |
| 300 | + @Test |
| 301 | + void writeEntitiesCreatePathUsesExistsCheckNotFullRowFetch() throws SQLException, IOException { |
| 302 | + int schemaVersion = 5; |
| 303 | + JdbcConnectionPool dataSource = |
| 304 | + JdbcConnectionPool.create( |
| 305 | + "jdbc:h2:mem:write_entities_create_v" |
| 306 | + + schemaVersion |
| 307 | + + "_" |
| 308 | + + System.nanoTime() |
| 309 | + + ";DB_CLOSE_DELAY=-1", |
| 310 | + "sa", |
| 311 | + ""); |
| 312 | + DatasourceOperations real = new DatasourceOperations(dataSource, new TestJdbcConfiguration()); |
| 313 | + try (InputStream script = DatabaseType.H2.openInitScriptResource(schemaVersion)) { |
| 314 | + real.executeScript(script); |
| 315 | + } |
| 316 | + DatasourceOperations spy = Mockito.spy(real); |
| 317 | + |
| 318 | + JdbcBasePersistenceImpl impl = |
| 319 | + new JdbcBasePersistenceImpl( |
| 320 | + new PolarisDefaultDiagServiceImpl(), |
| 321 | + spy, |
| 322 | + RANDOM_SECRETS, |
| 323 | + REALM_CONTEXT.getRealmIdentifier(), |
| 324 | + schemaVersion); |
| 325 | + PolarisCallContext callCtx = new PolarisCallContext(REALM_CONTEXT, impl); |
| 326 | + |
| 327 | + PolarisBaseEntity entity = principalEntity(1L, "create-me", 1); |
| 328 | + impl.writeEntities(callCtx, List.of(entity), null); |
| 329 | + |
| 330 | + // The create-path existence check must run as SELECT 1 ... LIMIT 1 (no JSON property columns), |
| 331 | + // on the transaction connection. |
| 332 | + ArgumentCaptor<QueryGenerator.PreparedQuery> captor = |
| 333 | + ArgumentCaptor.forClass(QueryGenerator.PreparedQuery.class); |
| 334 | + verify(spy).executeSelectOverStream(any(Connection.class), captor.capture(), any(), any()); |
| 335 | + String sql = captor.getValue().sql(); |
| 336 | + assertThat(sql).contains("SELECT 1").contains("LIMIT 1"); |
| 337 | + assertThat(sql).doesNotContain("properties"); |
| 338 | + |
| 339 | + // The entity was actually inserted. |
| 340 | + assertThat( |
| 341 | + impl.lookupEntity(callCtx, entity.getCatalogId(), entity.getId(), entity.getTypeCode())) |
| 342 | + .isNotNull(); |
| 343 | + |
| 344 | + // A retried create is idempotent (the existence check short-circuits the insert). |
| 345 | + assertThatCode(() -> impl.writeEntities(callCtx, List.of(entity), null)) |
| 346 | + .doesNotThrowAnyException(); |
| 347 | + } |
| 348 | + |
| 349 | + /** |
| 350 | + * On the update path (original entity supplied) {@code writeEntities} already knows the entity |
| 351 | + * exists, so it must not issue any pre-write existence/lookup SELECT before the CAS update. |
| 352 | + */ |
| 353 | + @Test |
| 354 | + void writeEntitiesUpdatePathSkipsExistenceLookup() throws SQLException, IOException { |
| 355 | + int schemaVersion = 5; |
| 356 | + JdbcConnectionPool dataSource = |
| 357 | + JdbcConnectionPool.create( |
| 358 | + "jdbc:h2:mem:write_entities_update_v" |
| 359 | + + schemaVersion |
| 360 | + + "_" |
| 361 | + + System.nanoTime() |
| 362 | + + ";DB_CLOSE_DELAY=-1", |
| 363 | + "sa", |
| 364 | + ""); |
| 365 | + DatasourceOperations real = new DatasourceOperations(dataSource, new TestJdbcConfiguration()); |
| 366 | + try (InputStream script = DatabaseType.H2.openInitScriptResource(schemaVersion)) { |
| 367 | + real.executeScript(script); |
| 368 | + } |
| 369 | + DatasourceOperations spy = Mockito.spy(real); |
| 370 | + |
| 371 | + JdbcBasePersistenceImpl impl = |
| 372 | + new JdbcBasePersistenceImpl( |
| 373 | + new PolarisDefaultDiagServiceImpl(), |
| 374 | + spy, |
| 375 | + RANDOM_SECRETS, |
| 376 | + REALM_CONTEXT.getRealmIdentifier(), |
| 377 | + schemaVersion); |
| 378 | + PolarisCallContext callCtx = new PolarisCallContext(REALM_CONTEXT, impl); |
| 379 | + |
| 380 | + PolarisBaseEntity original = principalEntity(1L, "update-me", 1); |
| 381 | + impl.writeEntities(callCtx, List.of(original), null); |
| 382 | + |
| 383 | + // Only observe the update below. |
| 384 | + Mockito.clearInvocations(spy); |
| 385 | + |
| 386 | + PolarisBaseEntity updated = principalEntity(1L, "update-me", 2); |
| 387 | + impl.writeEntities(callCtx, List.of(updated), List.of(original)); |
| 388 | + |
| 389 | + // No existence/lookup SELECT is issued before the CAS update. |
| 390 | + verify(spy, never()).executeSelectOverStream(any(Connection.class), any(), any(), any()); |
| 391 | + |
| 392 | + // The CAS update was applied (original v1 -> v2). |
| 393 | + PolarisBaseEntity reloaded = |
| 394 | + impl.lookupEntity(callCtx, updated.getCatalogId(), updated.getId(), updated.getTypeCode()); |
| 395 | + assertThat(reloaded).isNotNull(); |
| 396 | + assertThat(reloaded.getEntityVersion()).isEqualTo(2); |
| 397 | + } |
| 398 | + |
| 399 | + private static PolarisBaseEntity principalEntity(long id, String name, int entityVersion) { |
| 400 | + return new PolarisBaseEntity.Builder() |
| 401 | + .id(id) |
| 402 | + .catalogId(0L) |
| 403 | + .parentId(0L) |
| 404 | + .typeCode(PolarisEntityType.PRINCIPAL.getCode()) |
| 405 | + .subTypeCode(PolarisEntitySubType.NULL_SUBTYPE.getCode()) |
| 406 | + .name(name) |
| 407 | + .entityVersion(entityVersion) |
| 408 | + .grantRecordsVersion(1) |
| 409 | + .createTimestamp(System.currentTimeMillis()) |
| 410 | + .build(); |
| 411 | + } |
| 412 | + |
292 | 413 | private static final class TestJdbcConfiguration implements RelationalJdbcConfiguration { |
293 | 414 | @Override |
294 | 415 | public Optional<Integer> maxRetries() { |
|
0 commit comments