diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/ApiConfig.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/ApiConfig.java index 00408f757..580686da0 100644 --- a/services/tables/src/main/java/com/linkedin/openhouse/tables/api/ApiConfig.java +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/api/ApiConfig.java @@ -4,12 +4,12 @@ import com.linkedin.openhouse.tables.api.handler.impl.OpenHouseTablesApiHandler; import com.linkedin.openhouse.tables.readbridge.ColumnDefaultsSource; import com.linkedin.openhouse.tables.readbridge.ReadBridgeConfigResolver; -import java.util.Collections; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import com.linkedin.openhouse.tables.toggle.TableFeatureToggle; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -/** Class that holds all the Beans related to a controller. */ +/** Beans related to tables API controllers. */ @Configuration public class ApiConfig { @Bean @@ -18,22 +18,13 @@ public TablesApiHandler tablesApiHandler() { } /** - * Open-source default {@link ColumnDefaultsSource}: supplies none, so read-bridge stays inert. + * Prefer {@link ObjectProvider} over a {@code @ConditionalOnMissingBean} noop so a deployment + * {@code @Bean} source cannot collide with an OSS default. */ @Bean - @ConditionalOnMissingBean(ColumnDefaultsSource.class) - public ColumnDefaultsSource columnDefaultsSource() { - return tableDto -> Collections.emptyMap(); - } - - /** - * Server-side encoder that stamps the read-bridge {@code config} from {@link - * ColumnDefaultsSource}. - */ - @Bean - @ConditionalOnMissingBean(ReadBridgeConfigResolver.class) public ReadBridgeConfigResolver readBridgeConfigResolver( - ColumnDefaultsSource columnDefaultsSource) { - return new ReadBridgeConfigResolver(columnDefaultsSource); + ObjectProvider columnDefaultsSource, TableFeatureToggle featureToggle) { + return new ReadBridgeConfigResolver( + columnDefaultsSource.getIfAvailable(() -> ColumnDefaultsSource.NONE), featureToggle); } } 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 8de0d0a49..12091bae1 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 @@ -38,16 +38,9 @@ public class OpenHouseTablesApiHandler implements TablesApiHandler { @Autowired private ReadBridgeConfigResolver readBridgeConfigResolver; - /** - * Stamp the server-resolved, per-table client {@code config} (Iceberg REST {@code - * LoadTableResponse.config} convention) onto a freshly mapped response body. The mapper leaves - * {@code config} null; it is a request-time decision resolved here. - */ - private GetTableResponseBody withConfig( - GetTableResponseBody body, String databaseId, String tableId, TableDto tableDto) { - return body.toBuilder() - .config(readBridgeConfigResolver.resolve(databaseId, tableId, tableDto)) - .build(); + /** Request-time {@code config} stamp; mapper leaves it null. */ + private GetTableResponseBody withConfig(GetTableResponseBody body, TableDto tableDto) { + return body.toBuilder().config(readBridgeConfigResolver.resolve(tableDto)).build(); } @Override @@ -57,9 +50,7 @@ public ApiResponse getTable( TableDto tableDto = tableService.getTable(databaseId, tableId, actingPrincipal); return ApiResponse.builder() .httpStatus(HttpStatus.OK) - .responseBody( - withConfig( - tablesMapper.toGetTableResponseBody(tableDto), databaseId, tableId, tableDto)) + .responseBody(withConfig(tablesMapper.toGetTableResponseBody(tableDto), tableDto)) .build(); } @@ -111,12 +102,7 @@ public ApiResponse createTable( TableDto tableDto = putResult.getFirst(); return ApiResponse.builder() .httpStatus(HttpStatus.CREATED) - .responseBody( - withConfig( - tablesMapper.toGetTableResponseBody(tableDto), - databaseId, - tableDto.getTableId(), - tableDto)) + .responseBody(withConfig(tablesMapper.toGetTableResponseBody(tableDto), tableDto)) .build(); } @@ -134,9 +120,7 @@ public ApiResponse updateTable( TableDto tableDto = putResult.getFirst(); return ApiResponse.builder() .httpStatus(status) - .responseBody( - withConfig( - tablesMapper.toGetTableResponseBody(tableDto), databaseId, tableId, tableDto)) + .responseBody(withConfig(tablesMapper.toGetTableResponseBody(tableDto), tableDto)) .build(); } diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/readbridge/ColumnDefaultsSource.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/readbridge/ColumnDefaultsSource.java index 74444119a..55f468d70 100644 --- a/services/tables/src/main/java/com/linkedin/openhouse/tables/readbridge/ColumnDefaultsSource.java +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/readbridge/ColumnDefaultsSource.java @@ -2,28 +2,21 @@ import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.openhouse.tables.model.TableDto; +import java.util.Collections; import java.util.Map; /** - * Pluggable input to the open-source {@code read-bridge} feature: the per-column initial-defaults - * to overlay at read time, keyed by Iceberg field-id and valued as Iceberg single-value JSON. - * - *

This is the only part of read-bridge a deployment supplies. The open-source default (see - * {@code ApiConfig}) returns nothing, so the feature is wired but inert until a deployment - * overrides this bean (e.g. li-openhouse derives the defaults from the {@code avro.schema.literal} - * table property). - * - *

Called on every table-load/commit response, so implementations must be cheap. An empty map - * means "nothing to bridge for this table" — no default is declared, or a declared default is of a - * kind this source does not support; either way the column keeps reading {@code NULL} as it does - * today. Throw instead when a default is declared but cannot be honored (e.g. it does not - * bind to its column's type): degrading there would leave the column reading {@code NULL} while the - * table claims to be bridged, hiding a real defect. + * Deployment-supplied column defaults (data only). Keyed by Iceberg field-id; values are Iceberg + * single-value JSON. Policy/ramp lives in {@link ReadBridgeConfigResolver}. */ public interface ColumnDefaultsSource { + + /** Sentinel when no deployment bean is registered; resolver short-circuits before HTS. */ + ColumnDefaultsSource NONE = tableDto -> Collections.emptyMap(); + /** - * @param tableDto the already-loaded table state (no extra fetch needed) - * @return field-id -> initial-default as Iceberg single-value JSON; empty/{@code null} = none + * Field-id → Iceberg single-value JSON. Empty/null stamps nothing. Omit a field that cannot bind + * (today's NULL); do not throw — this is the table-load path. */ Map defaults(TableDto tableDto); } diff --git a/services/tables/src/main/java/com/linkedin/openhouse/tables/readbridge/ReadBridgeConfigResolver.java b/services/tables/src/main/java/com/linkedin/openhouse/tables/readbridge/ReadBridgeConfigResolver.java index 405bb2d3b..6de94c6e4 100644 --- a/services/tables/src/main/java/com/linkedin/openhouse/tables/readbridge/ReadBridgeConfigResolver.java +++ b/services/tables/src/main/java/com/linkedin/openhouse/tables/readbridge/ReadBridgeConfigResolver.java @@ -2,46 +2,86 @@ import com.fasterxml.jackson.databind.JsonNode; import com.linkedin.openhouse.tables.model.TableDto; +import com.linkedin.openhouse.tables.toggle.TableFeatureToggle; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import lombok.extern.slf4j.Slf4j; /** - * Open-source encoder for the {@code read-bridge} feature: it asks the pluggable {@link - * ColumnDefaultsSource} for a table's column initial-defaults and stamps each as a namespaced entry - * in the per-table {@code config} — {@code openhouse.read-bridge.column-default. = - * }. The client decoder ({@code ReadBridge} in {@code openhouse-java-runtime}) - * reads these entries and overlays the defaults at metadata-load time. - * - *

No envelope/POJO: the flat config map (Iceberg REST {@code LoadTableResponse.config} - * convention) carries the structure directly. Behaviorless by default — the open-source {@link - * ColumnDefaultsSource} bean supplies nothing (see {@code ApiConfig}), so no entries are stamped. A - * deployment delivers the bridge by overriding only {@link ColumnDefaultsSource}. - * - *

Mirror: {@link #COLUMN_DEFAULT_PREFIX} is the shared contract with the client decoder; - * keep it in sync. Further V3 features ride the same {@code openhouse.read-bridge.*} namespace as - * additional keys. + * Stamps per-table {@code config} for read-bridge capabilities. Owns policy (feature id, ramp, + * keys); deployments supply data via {@link ColumnDefaultsSource}. */ +@Slf4j public class ReadBridgeConfigResolver { - /** Config key prefix for a per-column read-time default; suffixed with the Iceberg field-id. */ - public static final String COLUMN_DEFAULT_PREFIX = "openhouse.read-bridge.column-default."; + /** Capability id; also names {@code .enabled} and the config key prefix below. */ + public static final String COLUMN_DEFAULT_FEATURE_ID = "read-bridge.column-default"; + + /** Client contract: {@code openhouse.read-bridge.column-default.}. */ + public static final String COLUMN_DEFAULT_PREFIX = "openhouse." + COLUMN_DEFAULT_FEATURE_ID + "."; private final ColumnDefaultsSource columnDefaultsSource; - public ReadBridgeConfigResolver(ColumnDefaultsSource columnDefaultsSource) { + private final TableFeatureToggle featureToggle; + + public ReadBridgeConfigResolver( + ColumnDefaultsSource columnDefaultsSource, TableFeatureToggle featureToggle) { this.columnDefaultsSource = columnDefaultsSource; + this.featureToggle = featureToggle; + } + + /** Merges independently gated capabilities; empty when nothing is bridged. */ + public Map resolve(TableDto tableDto) { + Map config = new HashMap<>(); + config.putAll(columnDefaultConfig(tableDto)); + return config; } - public Map resolve(String databaseId, String tableId, TableDto tableDto) { - Map columnDefaults = columnDefaultsSource.defaults(tableDto); + private Map columnDefaultConfig(TableDto tableDto) { + // No deployment source → skip HTS entirely. + if (columnDefaultsSource == ColumnDefaultsSource.NONE) { + return Collections.emptyMap(); + } + if (!isColumnDefaultRamped(tableDto)) { + return Collections.emptyMap(); + } + Map columnDefaults; + try { + columnDefaults = columnDefaultsSource.defaults(tableDto); + } catch (RuntimeException e) { + log.warn( + "read-bridge: column-defaults source failed for {}.{}; treating as not bridged", + tableDto.getDatabaseId(), + tableDto.getTableId(), + e); + return Collections.emptyMap(); + } if (columnDefaults == null || columnDefaults.isEmpty()) { - return Collections.emptyMap(); // nothing to bridge -> stamp nothing + return Collections.emptyMap(); } Map config = new HashMap<>(); - // JsonNode.toString() is the single-value JSON (e.g. "US" -> "\"US\"", 0 -> "0"). columnDefaults.forEach( (fieldId, value) -> config.put(COLUMN_DEFAULT_PREFIX + fieldId, value.toString())); return config; } + + /** + * Uses {@link TableFeatureToggle#isFeatureActivatedWithOverride} so {@code + * read-bridge.column-default.enabled} can opt in/out without HTS. Fail-open on lookup errors: not + * bridging equals today's NULL reads. + */ + private boolean isColumnDefaultRamped(TableDto tableDto) { + try { + return featureToggle.isFeatureActivatedWithOverride(tableDto, COLUMN_DEFAULT_FEATURE_ID); + } catch (RuntimeException e) { + log.warn( + "read-bridge: toggle lookup failed for {}.{}; treating {} as not ramped", + tableDto.getDatabaseId(), + tableDto.getTableId(), + COLUMN_DEFAULT_FEATURE_ID, + e); + return false; + } + } } diff --git a/services/tables/src/test/java/com/linkedin/openhouse/tables/e2e/h2/ReadBridgeColumnDefaultE2ETest.java b/services/tables/src/test/java/com/linkedin/openhouse/tables/e2e/h2/ReadBridgeColumnDefaultE2ETest.java new file mode 100644 index 000000000..cc551d3d4 --- /dev/null +++ b/services/tables/src/test/java/com/linkedin/openhouse/tables/e2e/h2/ReadBridgeColumnDefaultE2ETest.java @@ -0,0 +1,191 @@ +package com.linkedin.openhouse.tables.e2e.h2; + +import static com.linkedin.openhouse.tables.model.TableModelConstants.CLUSTER_NAME; +import static com.linkedin.openhouse.tables.model.TableModelConstants.GET_TABLE_RESPONSE_BODY; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.node.TextNode; +import com.jayway.jsonpath.JsonPath; +import com.linkedin.openhouse.cluster.storage.StorageManager; +import com.linkedin.openhouse.common.test.cluster.PropertyOverrideContextInitializer; +import com.linkedin.openhouse.housetables.client.model.ToggleStatus; +import com.linkedin.openhouse.tables.api.spec.v0.response.GetTableResponseBody; +import com.linkedin.openhouse.tables.mock.properties.AuthorizationPropertiesInitializer; +import com.linkedin.openhouse.tables.readbridge.ColumnDefaultsSource; +import com.linkedin.openhouse.tables.readbridge.ReadBridgeConfigResolver; +import com.linkedin.openhouse.tables.toggle.TableFeatureToggle; +import com.linkedin.openhouse.tables.toggle.model.TableToggleStatus; +import com.linkedin.openhouse.tables.toggle.repository.ToggleStatusesRepository; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +/** + * HTTP create/get stamps {@code config} from a stub {@link ColumnDefaultsSource} according to the + * OpenHouse ramp. Deployment encoders are out of scope; resolver unit tests cover the same matrix. + */ +@SpringBootTest +@AutoConfigureMockMvc +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_CLASS) +@Import(ReadBridgeColumnDefaultE2ETest.StubDefaults.class) +@ContextConfiguration( + initializers = { + PropertyOverrideContextInitializer.class, + AuthorizationPropertiesInitializer.class + }) +public class ReadBridgeColumnDefaultE2ETest { + + private static final String CONFIG_KEY = ReadBridgeConfigResolver.COLUMN_DEFAULT_PREFIX + "5"; + private static final String ENABLED_PROP = + ReadBridgeConfigResolver.COLUMN_DEFAULT_FEATURE_ID + + TableFeatureToggle.ENABLED_PROPERTY_SUFFIX; + + @TestConfiguration + static class StubDefaults { + @Bean + ColumnDefaultsSource stubColumnDefaults() { + return tableDto -> Collections.singletonMap(5, TextNode.valueOf("US")); + } + } + + @Autowired private MockMvc mvc; + @Autowired private StorageManager storageManager; + @Autowired private ToggleStatusesRepository toggleStatusesRepository; + + private GetTableResponseBody created; + private TableToggleStatus toggleStatus; + + @AfterEach + public void tearDown() throws Exception { + if (created != null) { + RequestAndValidateHelper.deleteTableAndValidateResponse(mvc, created); + created = null; + } + if (toggleStatus != null) { + toggleStatusesRepository.delete(toggleStatus); + toggleStatus = null; + } + } + + @Test + public void createAndGet_stampsColumnDefaultConfigWhenEnabled() throws Exception { + created = create(uniqueTable("prop_on"), Collections.singletonMap(ENABLED_PROP, "true")); + + MvcResult createdResult = + RequestAndValidateHelper.createTableAndValidateResponse(created, mvc, storageManager); + assertEquals( + "\"US\"", + JsonPath.read( + createdResult.getResponse().getContentAsString(), "$.config['" + CONFIG_KEY + "']")); + + getTable() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.config['" + CONFIG_KEY + "']", is("\"US\""))) + .andExpect(jsonPath("$.tableProperties['" + ENABLED_PROP + "']", is("true"))); + } + + @Test + public void get_omitsColumnDefaultConfigWhenFeatureDisabled() throws Exception { + created = create(uniqueTable("prop_off"), Collections.singletonMap(ENABLED_PROP, "false")); + RequestAndValidateHelper.createTableAndValidateResponse(created, mvc, storageManager); + getTable() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.config['" + CONFIG_KEY + "']").doesNotExist()); + } + + @Test + public void get_omitsConfigWhenNoPropertyAndNoHtsToggle() throws Exception { + created = create(uniqueTable("no_ramp"), Collections.emptyMap()); + RequestAndValidateHelper.createTableAndValidateResponse(created, mvc, storageManager); + getTable() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.config['" + CONFIG_KEY + "']").doesNotExist()); + } + + @Test + public void get_stampsWhenHtsToggleActiveAndNoProperty() throws Exception { + String tableId = uniqueTable("hts_on"); + created = create(tableId, Collections.emptyMap()); + activateHtsToggle(created); + RequestAndValidateHelper.createTableAndValidateResponse(created, mvc, storageManager); + getTable() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.config['" + CONFIG_KEY + "']", is("\"US\""))); + } + + @Test + public void get_propertyFalseOptsOutEvenWhenHtsToggleActive() throws Exception { + created = + create(uniqueTable("hts_on_prop_off"), Collections.singletonMap(ENABLED_PROP, "false")); + activateHtsToggle(created); + RequestAndValidateHelper.createTableAndValidateResponse(created, mvc, storageManager); + getTable() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.config['" + CONFIG_KEY + "']").doesNotExist()); + } + + @Test + public void get_unparseablePropertyFailsClosedEvenIfHtsActive() throws Exception { + created = create(uniqueTable("bad_prop"), Collections.singletonMap(ENABLED_PROP, "sometimes")); + activateHtsToggle(created); + RequestAndValidateHelper.createTableAndValidateResponse(created, mvc, storageManager); + getTable() + .andExpect(status().isOk()) + .andExpect(jsonPath("$.config['" + CONFIG_KEY + "']").doesNotExist()); + } + + private void activateHtsToggle(GetTableResponseBody table) { + toggleStatus = + TableToggleStatus.builder() + .featureId(ReadBridgeConfigResolver.COLUMN_DEFAULT_FEATURE_ID) + .databaseId(table.getDatabaseId()) + .tableId(table.getTableId()) + .toggleStatusEnum(ToggleStatus.StatusEnum.ACTIVE) + .build(); + toggleStatusesRepository.save(toggleStatus); + } + + private static GetTableResponseBody create(String tableId, Map extraProps) { + Map props = new HashMap<>(GET_TABLE_RESPONSE_BODY.getTableProperties()); + props.putAll(extraProps); + return GET_TABLE_RESPONSE_BODY + .toBuilder() + .tableId(tableId) + .tableUri(CLUSTER_NAME + ".d1." + tableId) + .tableProperties(props) + .build(); + } + + private static String uniqueTable(String suffix) { + return "rbcd_" + suffix + "_" + UUID.randomUUID().toString().substring(0, 8); + } + + private ResultActions getTable() throws Exception { + return mvc.perform( + MockMvcRequestBuilders.get( + String.format( + ValidationUtilities.CURRENT_MAJOR_VERSION_PREFIX + "/databases/%s/tables/%s", + created.getDatabaseId(), + created.getTableId())) + .accept(MediaType.APPLICATION_JSON)); + } +} diff --git a/services/tables/src/test/java/com/linkedin/openhouse/tables/readbridge/ReadBridgeConfigResolverTest.java b/services/tables/src/test/java/com/linkedin/openhouse/tables/readbridge/ReadBridgeConfigResolverTest.java index a5e06a02f..f7a891544 100644 --- a/services/tables/src/test/java/com/linkedin/openhouse/tables/readbridge/ReadBridgeConfigResolverTest.java +++ b/services/tables/src/test/java/com/linkedin/openhouse/tables/readbridge/ReadBridgeConfigResolverTest.java @@ -3,7 +3,11 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.JsonNode; @@ -16,6 +20,7 @@ import com.linkedin.openhouse.tables.dto.mapper.TablesMapper; import com.linkedin.openhouse.tables.model.TableDto; import com.linkedin.openhouse.tables.services.TablesService; +import com.linkedin.openhouse.tables.toggle.TableFeatureToggle; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; @@ -26,21 +31,197 @@ public class ReadBridgeConfigResolverTest { /** Open-source default source: supplies nothing, so the feature is inert. */ - private static final ColumnDefaultsSource NONE = tableDto -> Collections.emptyMap(); + private static final ColumnDefaultsSource NONE = ColumnDefaultsSource.NONE; private static final String PREFIX = ReadBridgeConfigResolver.COLUMN_DEFAULT_PREFIX; + /** A toggle that ramps everything, so a test isolates the encoder rather than the ramp. */ + private static final TableFeatureToggle ALL_ON = + new TableFeatureToggle() { + @Override + public boolean isFeatureActivated(String databaseId, String tableId, String featureId) { + return true; + } + }; + + private static ReadBridgeConfigResolver resolverFor(ColumnDefaultsSource source) { + return new ReadBridgeConfigResolver(source, ALL_ON); + } + + private static ColumnDefaultsSource oneDefault() { + return tableDto -> Collections.singletonMap(5, TextNode.valueOf("US")); + } + + /** A table carrying an explicit self-service opt-in/opt-out property. */ + private static TableDto tableWithOverride(String value) { + return TableDto.builder() + .databaseId("db") + .tableId("tbl") + .tableProperties( + Collections.singletonMap( + ReadBridgeConfigResolver.COLUMN_DEFAULT_FEATURE_ID + + TableFeatureToggle.ENABLED_PROPERTY_SUFFIX, + value)) + .build(); + } + + /** Gate 1: no deployment-supplied source => inert, and crucially no toggle lookup at all. */ @Test - public void testEmptyWhenNoColumnDefaults() { + public void testInertAndSkipsToggleWhenNoSourceSupplied() { + TableFeatureToggle toggle = mock(TableFeatureToggle.class); + ReadBridgeConfigResolver resolver = + new ReadBridgeConfigResolver(ColumnDefaultsSource.NONE, toggle); + + Assertions.assertTrue(resolver.resolve(mock(TableDto.class)).isEmpty()); + // The toggle is a remote HouseTables call on the table-load path; it must not be made. + verifyNoInteractions(toggle); + } + + /** + * The ramp lookup is a blocking HouseTables call, and this is the table-load path — a path + * toggles are not otherwise on. A HouseTables outage must degrade bridging, not fail reads. Sound + * only because not bridging is exactly today's behavior; a capability where ignoring is unsafe + * (deletion vectors) would have to fail the read instead. + */ + @Test + public void testToggleLookupFailureDegradesInsteadOfFailingTheRead() { + TableFeatureToggle exploding = + new TableFeatureToggle() { + @Override + public boolean isFeatureActivated(String databaseId, String tableId, String featureId) { + throw new IllegalStateException("housetables is down"); + } + }; + + Map config = + new ReadBridgeConfigResolver(oneDefault(), exploding) + .resolve(TableDto.builder().databaseId("db").tableId("tbl").build()); + + Assertions.assertTrue(config.isEmpty()); + } + + /** + * A buggy deployment source must not 500 GET. Not bridging is today's NULL, same as a toggle + * outage. + */ + @Test + public void testSourceFailureDegradesInsteadOfFailingTheRead() { + ColumnDefaultsSource exploding = + tableDto -> { + throw new IllegalStateException("encoder exploded"); + }; + + Map config = + resolverFor(exploding).resolve(TableDto.builder().databaseId("db").tableId("tbl").build()); + + Assertions.assertTrue(config.isEmpty()); + } + + /** Gate 3: a table the ramp has not activated is not bridged, and its source is never asked. */ + @Test + public void testUnrampedTableIsNotBridgedAndSourceNotConsulted() { + ColumnDefaultsSource source = mock(ColumnDefaultsSource.class); + TableFeatureToggle allOff = + new TableFeatureToggle() { + @Override + public boolean isFeatureActivated(String databaseId, String tableId, String featureId) { + return false; + } + }; + Assertions.assertTrue( - new ReadBridgeConfigResolver(NONE).resolve("db", "tbl", mock(TableDto.class)).isEmpty()); + new ReadBridgeConfigResolver(source, allOff) + .resolve(TableDto.builder().databaseId("db").tableId("tbl").build()) + .isEmpty()); + // Deriving defaults can be expensive (a deployment may parse a schema); gate first. + verifyNoInteractions(source); + } + + /** The self-service property opts a table in even when the server-managed ramp says no. */ + @Test + public void testTablePropertyOptsInOverServerToggle() { + // CALLS_REAL_METHODS so the override-honoring default reads the table property; stub the + // server-side form so an accidental HTS call would return false. + TableFeatureToggle toggle = mock(TableFeatureToggle.class, CALLS_REAL_METHODS); + when(toggle.isFeatureActivated(anyString(), anyString(), anyString())).thenReturn(false); + + Map config = + new ReadBridgeConfigResolver(oneDefault(), toggle).resolve(tableWithOverride("true")); + + Assertions.assertEquals("\"US\"", config.get(PREFIX + "5")); + // Explicit opt-in is decided from the table property alone; no HouseTables round-trip. + verify(toggle, never()).isFeatureActivated(anyString(), anyString(), anyString()); + } + + /** ...and opts it out even when the server-managed ramp says yes. */ + @Test + public void testTablePropertyOptsOutOverServerToggle() { + Assertions.assertTrue(resolverFor(oneDefault()).resolve(tableWithOverride("false")).isEmpty()); + } + + /** + * Source present and table ramped, but the source has nothing to stamp — still empty config. Not + * the same as {@link ColumnDefaultsSource#NONE}: the toggle ran and the source was asked. + */ + @Test + public void testEmptyWhenSourceReturnsNoDefaults() { + ColumnDefaultsSource emptySource = mock(ColumnDefaultsSource.class); + when(emptySource.defaults(any())).thenReturn(Collections.emptyMap()); + + Assertions.assertTrue( + resolverFor(emptySource) + .resolve(TableDto.builder().databaseId("db").tableId("tbl").build()) + .isEmpty()); + verify(emptySource).defaults(any()); + } + + /** + * The capability's feature id, its self-service property and its wire keys are one token. Pinned + * as literals because all three are external contracts: the id is stored in HouseTables toggle + * rules, the property is set on customer tables, and the prefix is mirrored by the client + * decoder. Deriving them from each other keeps them consistent; asserting the literals keeps a + * refactor from silently renaming all three at once. + */ + @Test + public void testFeatureIdPropertyAndKeysAreOneToken() { + Assertions.assertEquals( + "read-bridge.column-default", ReadBridgeConfigResolver.COLUMN_DEFAULT_FEATURE_ID); + Assertions.assertEquals( + "read-bridge.column-default.enabled", + ReadBridgeConfigResolver.COLUMN_DEFAULT_FEATURE_ID + + TableFeatureToggle.ENABLED_PROPERTY_SUFFIX); + Assertions.assertEquals( + "openhouse.read-bridge.column-default.", ReadBridgeConfigResolver.COLUMN_DEFAULT_PREFIX); + } + + /** + * Rollout is per capability, never for read-bridge as a whole: capabilities share only the + * transport. A table opted out of column defaults must not thereby be opted out of a capability + * added later, and vice versa. Pinned because the id is baked into a customer-set property, so + * splitting it after the fact means a migration. + * + *

The bare "read-bridge" id staying unclaimed is also the room a future superset ramp (e.g. + * "v3-read-bridge", activating every capability at once) needs in order to exist without + * colliding with a capability's own id. + */ + @Test + public void testRolloutIdIsScopedToTheCapabilityNotTheMechanism() { + Assertions.assertNotEquals("read-bridge", ReadBridgeConfigResolver.COLUMN_DEFAULT_FEATURE_ID); + Assertions.assertNotEquals( + "v3-read-bridge", ReadBridgeConfigResolver.COLUMN_DEFAULT_FEATURE_ID); + Assertions.assertTrue( + ReadBridgeConfigResolver.COLUMN_DEFAULT_FEATURE_ID.startsWith("read-bridge.")); + } + + @Test + public void testEmptyWhenNoColumnDefaults() { + Assertions.assertTrue(resolverFor(NONE).resolve(mock(TableDto.class)).isEmpty()); } @Test public void testStampsColumnDefaultEntry() { ColumnDefaultsSource source = tableDto -> Collections.singletonMap(5, TextNode.valueOf("US")); - Map config = - new ReadBridgeConfigResolver(source).resolve("db", "tbl", mock(TableDto.class)); + Map config = resolverFor(source).resolve(mock(TableDto.class)); // value is the single-value JSON for the default ("US" -> "\"US\""). Assertions.assertEquals("\"US\"", config.get(PREFIX + "5")); } @@ -54,8 +235,7 @@ public void testStampsAllColumnDefaultsAsSeparateEntries() { defaults.put(7, IntNode.valueOf(0)); return defaults; }; - Map config = - new ReadBridgeConfigResolver(source).resolve("db", "tbl", mock(TableDto.class)); + Map config = resolverFor(source).resolve(mock(TableDto.class)); Assertions.assertEquals(2, config.size()); Assertions.assertEquals("\"US\"", config.get(PREFIX + "5")); Assertions.assertEquals("0", config.get(PREFIX + "7")); @@ -74,7 +254,7 @@ public void testGetTableStampsResolvedConfig() { .thenReturn(GetTableResponseBody.builder().tableId("tbl").databaseId("db").build()); Map resolved = Collections.singletonMap(PREFIX + "5", "\"US\""); - when(resolver.resolve(eq("db"), eq("tbl"), eq(tableDto))).thenReturn(resolved); + when(resolver.resolve(eq(tableDto))).thenReturn(resolved); OpenHouseTablesApiHandler handler = handlerWith(tableService, tablesMapper, resolver); @@ -94,8 +274,7 @@ public void testGetTableLeavesConfigEmptyWithNoColumnDefaults() { when(tablesMapper.toGetTableResponseBody(any())) .thenReturn(GetTableResponseBody.builder().tableId("tbl").databaseId("db").build()); - OpenHouseTablesApiHandler handler = - handlerWith(tableService, tablesMapper, new ReadBridgeConfigResolver(NONE)); + OpenHouseTablesApiHandler handler = handlerWith(tableService, tablesMapper, resolverFor(NONE)); ApiResponse response = handler.getTable("db", "tbl", "principal");