From b65cad1a56ee85e593dbcca6d77dcc88feb0231d Mon Sep 17 00:00:00 2001 From: Levi Jiang Date: Wed, 10 Jun 2026 17:52:00 -0700 Subject: [PATCH] Add lightweight tableExists endpoint to tables service Adds GET /v0|v1/databases/{databaseId}/tables/{tableId}/exists which returns HTTP 200 if the table exists and HTTP 404 otherwise. Existence is determined via a HouseTable reference lookup (findTableRefById) that does not parse metadata.json or read from HDFS, making it cheaper than GET Table. Authorization is enforced via @Secured(GET_TABLE_METADATA). Adds service/handler/controller plumbing plus e2e and mock controller tests, including a test that verifies existence is reported even when metadata.json is corrupted (since GET Table would fail in that case). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tables/api/handler/TablesApiHandler.java | 10 ++++++ .../impl/OpenHouseTablesApiHandler.java | 9 +++++ .../tables/controller/TablesController.java | 32 +++++++++++++++++ .../tables/services/TablesService.java | 10 ++++++ .../tables/services/TablesServiceImpl.java | 10 ++++++ .../tables/e2e/h2/TablesServiceTest.java | 36 +++++++++++++++++++ .../tables/mock/MockTablesApiHandler.java | 14 ++++++++ .../mock/controller/TablesControllerTest.java | 31 ++++++++++++++++ 8 files changed, 152 insertions(+) diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/TablesApiHandler.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/TablesApiHandler.java index 8249a688d..4351d35c4 100644 --- a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/TablesApiHandler.java +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/TablesApiHandler.java @@ -26,6 +26,16 @@ public interface TablesApiHandler { ApiResponse getTable( String databaseId, String tableId, String actingPrincipal); + /** + * Function to check whether a table exists for the given databaseId and tableId. Uses a + * lightweight HouseTable reference lookup (no metadata.json/HDFS read). + * + * @param databaseId + * @param tableId + * @return an empty-bodied response with HTTP 200 if the table exists, HTTP 404 otherwise. + */ + ApiResponse tableExists(String databaseId, String tableId); + /** * Function to Get all Table Resources in a given databaseId by filters and return requested * columns. If no columns are specified only identifier columns are returned. diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/impl/OpenHouseTablesApiHandler.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/impl/OpenHouseTablesApiHandler.java index 691691173..b55e1c561 100644 --- a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/impl/OpenHouseTablesApiHandler.java +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/handler/impl/OpenHouseTablesApiHandler.java @@ -46,6 +46,15 @@ public ApiResponse getTable( .build(); } + @Override + public ApiResponse tableExists(String databaseId, String tableId) { + tablesApiValidator.validateGetTable(databaseId, tableId); + return ApiResponse.builder() + .httpStatus( + tableService.tableExists(databaseId, tableId) ? HttpStatus.OK : HttpStatus.NOT_FOUND) + .build(); + } + @Override public ApiResponse searchTables(String databaseId) { tablesApiValidator.validateSearchTables(databaseId); diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/controller/TablesController.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/controller/TablesController.java index 29db397e2..a1efbfcef 100644 --- a/services/tables/src/main/java/com/linkedin/openhouse/tables/controller/TablesController.java +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/controller/TablesController.java @@ -69,6 +69,38 @@ public ResponseEntity getTable( apiResponse.getResponseBody(), apiResponse.getHttpHeaders(), apiResponse.getHttpStatus()); } + @Operation( + summary = "Check if a Table exists in a Database", + description = + "Returns HTTP 200 if the Table identified by tableId exists in the database identified by " + + "databaseId, and HTTP 404 otherwise. Uses a lightweight HouseTable lookup that does " + + "not read metadata from storage, making it cheaper than GET Table.", + tags = {"Table"}) + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "Table EXISTS: OK"), + @ApiResponse(responseCode = "401", description = "Table EXISTS: UNAUTHORIZED"), + @ApiResponse(responseCode = "403", description = "Table EXISTS: FORBIDDEN"), + @ApiResponse(responseCode = "404", description = "Table EXISTS: NOT_FOUND") + }) + @GetMapping( + value = { + "/v0/databases/{databaseId}/tables/{tableId}/exists", + "/v1/databases/{databaseId}/tables/{tableId}/exists" + }, + produces = {"application/json"}) + @Secured(value = Privileges.Privilege.GET_TABLE_METADATA) + public ResponseEntity tableExists( + @Parameter(description = "Database ID", required = true) @PathVariable String databaseId, + @Parameter(description = "Table ID", required = true) @PathVariable String tableId) { + + com.linkedin.openhouse.common.api.spec.ApiResponse apiResponse = + tablesApiHandler.tableExists(databaseId, tableId); + + return new ResponseEntity<>( + apiResponse.getResponseBody(), apiResponse.getHttpHeaders(), apiResponse.getHttpStatus()); + } + @Operation( summary = "Search Tables in a Database", description = diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/services/TablesService.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/services/TablesService.java index 363ec0cdd..e8dac86d1 100644 --- a/services/tables/src/main/java/com/linkedin/openhouse/tables/services/TablesService.java +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/services/TablesService.java @@ -24,6 +24,16 @@ public interface TablesService { */ TableDto getTable(String databaseId, String tableId, String actingPrincipal); + /** + * Check whether a table identified by databaseId and tableId exists, using a lightweight + * HouseTable reference lookup that does not parse metadata.json (i.e. no HDFS read). + * + * @param databaseId + * @param tableId + * @return true iff the table exists in the catalog. + */ + boolean tableExists(String databaseId, String tableId); + /** * Given a databaseId, prepare list of {@link TableDto}s. * diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/services/TablesServiceImpl.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/services/TablesServiceImpl.java index 2c6bf611b..883ed94a7 100644 --- a/services/tables/src/main/java/com/linkedin/openhouse/tables/services/TablesServiceImpl.java +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/services/TablesServiceImpl.java @@ -75,6 +75,16 @@ public TableDto getTable(String databaseId, String tableId, String actingPrincip return tableDto; } + @Override + public boolean tableExists(String databaseId, String tableId) { + // Lightweight HouseTable reference lookup (no metadata.json parse / HDFS read) is enough to + // determine existence. Authorization is enforced at the controller via @Secured. + return openHouseInternalRepository + .findTableRefById( + TableDtoPrimaryKey.builder().databaseId(databaseId).tableId(tableId).build()) + .isPresent(); + } + @Override public List searchTables(String databaseId) { return openHouseInternalRepository.searchTables(databaseId); diff --git a/services/tables/src/test/java/com/linkedin/openhouse/tables/e2e/h2/TablesServiceTest.java b/services/tables/src/test/java/com/linkedin/openhouse/tables/e2e/h2/TablesServiceTest.java index 3a5998ebb..aee8ced4b 100644 --- a/services/tables/src/test/java/com/linkedin/openhouse/tables/e2e/h2/TablesServiceTest.java +++ b/services/tables/src/test/java/com/linkedin/openhouse/tables/e2e/h2/TablesServiceTest.java @@ -305,6 +305,42 @@ public void testTableDeleteSucceedsWhenMetadataJsonIsCorrupted() throws IOExcept TABLE_DTO.getDatabaseId(), TABLE_DTO.getTableId(), TEST_USER)); } + @Test + public void testTableExists() { + Assertions.assertFalse( + tablesService.tableExists(TABLE_DTO.getDatabaseId(), TABLE_DTO.getTableId()), + "tableExists should be false before the table is created"); + + verifyPutTableRequest(TABLE_DTO, null, true); + Assertions.assertTrue( + tablesService.tableExists(TABLE_DTO.getDatabaseId(), TABLE_DTO.getTableId()), + "tableExists should be true after the table is created"); + + tablesService.deleteTable(TABLE_DTO.getDatabaseId(), TABLE_DTO.getTableId(), TEST_USER); + Assertions.assertFalse( + tablesService.tableExists(TABLE_DTO.getDatabaseId(), TABLE_DTO.getTableId()), + "tableExists should be false after the table is deleted"); + } + + /** + * tableExists relies on the HTS-only findTableRefById lookup, so it must report existence without + * parsing metadata.json — even when metadata.json is corrupted and loadTable would throw. + */ + @Test + public void testTableExistsWhenMetadataJsonIsCorrupted() throws IOException { + TableDto created = verifyPutTableRequest(TABLE_DTO, null, true); + + Path metadataPath = Paths.get(URI.create(created.getTableLocation())); + Files.write(metadataPath, "{\"not\":\"valid iceberg metadata\"}".getBytes()); + + // getTable parses metadata.json and fails, but tableExists only consults HTS and succeeds. + Assertions.assertThrows( + Exception.class, + () -> tablesService.getTable(TABLE_DTO.getDatabaseId(), TABLE_DTO.getTableId(), TEST_USER)); + Assertions.assertTrue( + tablesService.tableExists(TABLE_DTO.getDatabaseId(), TABLE_DTO.getTableId())); + } + @Test public void testTimePartitioning() { Schema schema = diff --git a/services/tables/src/test/java/com/linkedin/openhouse/tables/mock/MockTablesApiHandler.java b/services/tables/src/test/java/com/linkedin/openhouse/tables/mock/MockTablesApiHandler.java index b7268dfdb..a4edae59d 100644 --- a/services/tables/src/test/java/com/linkedin/openhouse/tables/mock/MockTablesApiHandler.java +++ b/services/tables/src/test/java/com/linkedin/openhouse/tables/mock/MockTablesApiHandler.java @@ -47,6 +47,20 @@ public ApiResponse getTable( } } + @Override + public ApiResponse tableExists(String databaseId, String tableId) { + switch (databaseId) { + case "d200": + return ApiResponse.builder().httpStatus(HttpStatus.OK).build(); + case "d404": + return ApiResponse.builder().httpStatus(HttpStatus.NOT_FOUND).build(); + case "dnullpointer": + throw new NullPointerException(); // test for exception handler + default: + return null; + } + } + @Override public ApiResponse searchTables(String databaseId) { switch (databaseId) { diff --git a/services/tables/src/test/java/com/linkedin/openhouse/tables/mock/controller/TablesControllerTest.java b/services/tables/src/test/java/com/linkedin/openhouse/tables/mock/controller/TablesControllerTest.java index 9f0d25134..08526c2a7 100644 --- a/services/tables/src/test/java/com/linkedin/openhouse/tables/mock/controller/TablesControllerTest.java +++ b/services/tables/src/test/java/com/linkedin/openhouse/tables/mock/controller/TablesControllerTest.java @@ -137,6 +137,37 @@ public void findTableById401() throws Exception { .andExpect(status().isUnauthorized()); } + @Test + public void tableExists200() throws Exception { + mvc.perform( + MockMvcRequestBuilders.get( + CURRENT_MAJOR_VERSION_PREFIX + "/databases/d200/tables/t1/exists") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + jwtAccessToken)) + .andExpect(status().isOk()) + .andExpect(content().string("")); + } + + @Test + public void tableExists404() throws Exception { + mvc.perform( + MockMvcRequestBuilders.get( + CURRENT_MAJOR_VERSION_PREFIX + "/databases/d404/tables/t1/exists") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "Bearer " + jwtAccessToken)) + .andExpect(status().isNotFound()) + .andExpect(content().string("")); + } + + @Test + public void tableExists401() throws Exception { + mvc.perform( + MockMvcRequestBuilders.get( + CURRENT_MAJOR_VERSION_PREFIX + "/databases/d200/tables/t1/exists") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isUnauthorized()); + } + @Test @MockUnauthenticatedUser public void findTableById403() throws Exception {