Skip to content

Commit fa28104

Browse files
committed
Add generic table feature activation gate
1 parent da04091 commit fa28104

4 files changed

Lines changed: 199 additions & 13 deletions

File tree

services/housetables/src/main/java/com/linkedin/openhouse/housetables/services/WildcardTableToggleRuleMatcher.java

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,24 @@
22

33
import com.linkedin.openhouse.housetables.model.TableToggleRule;
44
import org.springframework.stereotype.Component;
5+
import org.springframework.util.AntPathMatcher;
6+
import org.springframework.util.PathMatcher;
57

6-
/** An implementation of {@link TableToggleRuleMatcher} that supports '*' to match any entities */
8+
/**
9+
* A {@link TableToggleRuleMatcher} matching database and table names as case-sensitive globs, so
10+
* {@code *} matches any run of characters wherever it appears and {@code ?} matches one.
11+
*
12+
* <p>TODO: patterns are unvalidated because rules are inserted straight into MySQL, so a malformed
13+
* pattern is only discovered by matching nothing. Validation belongs with a rule-write path, which
14+
* does not exist yet.
15+
*/
716
@Component
817
public class WildcardTableToggleRuleMatcher implements TableToggleRuleMatcher {
18+
private static final PathMatcher MATCHER = new AntPathMatcher();
19+
920
@Override
1021
public boolean matches(TableToggleRule rule, String tableId, String databaseId) {
11-
boolean tableMatches =
12-
rule.getTablePattern().equals("*") || rule.getTablePattern().equals(tableId);
13-
boolean databaseMatches =
14-
rule.getDatabasePattern().equals("*") || rule.getDatabasePattern().equals(databaseId);
15-
16-
return tableMatches && databaseMatches;
22+
return MATCHER.match(rule.getTablePattern(), tableId)
23+
&& MATCHER.match(rule.getDatabasePattern(), databaseId);
1724
}
1825
}

services/housetables/src/test/java/com/linkedin/openhouse/housetables/mock/WildcardTableToggleRuleMatcherTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,36 @@ void testBothWildcardMatch() {
6262
assertTrue(matcher.matches(mockRule, "anyTable", "anyDb"));
6363
}
6464

65+
@Test
66+
void testDatabaseAndTablePrefixMatch() {
67+
when(mockRule.getTablePattern()).thenReturn("events_*");
68+
when(mockRule.getDatabasePattern()).thenReturn("tracking_*");
69+
70+
assertTrue(matcher.matches(mockRule, "events_daily", "tracking_prod"));
71+
assertFalse(matcher.matches(mockRule, "metrics_daily", "tracking_prod"));
72+
assertFalse(matcher.matches(mockRule, "events_daily", "analytics_prod"));
73+
}
74+
75+
@Test
76+
void testWildcardMatchesInsidePattern() {
77+
when(mockRule.getTablePattern()).thenReturn("events_*_daily");
78+
when(mockRule.getDatabasePattern()).thenReturn("*_prod");
79+
80+
assertTrue(matcher.matches(mockRule, "events_click_daily", "tracking_prod"));
81+
assertFalse(matcher.matches(mockRule, "events_click_hourly", "tracking_prod"));
82+
assertFalse(matcher.matches(mockRule, "events_click_daily", "tracking_test"));
83+
}
84+
85+
@Test
86+
void testMatchIsCaseSensitive() {
87+
when(mockRule.getTablePattern()).thenReturn("events_*");
88+
when(mockRule.getDatabasePattern()).thenReturn("tracking");
89+
90+
assertTrue(matcher.matches(mockRule, "events_daily", "tracking"));
91+
assertFalse(matcher.matches(mockRule, "Events_daily", "tracking"));
92+
assertFalse(matcher.matches(mockRule, "events_daily", "Tracking"));
93+
}
94+
6595
@Test
6696
void testNoMatch() {
6797
when(mockRule.getTablePattern()).thenReturn("table1");
Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,65 @@
11
package com.linkedin.openhouse.tables.toggle;
22

3-
/** Interface to check if a feature is toggled-on for a table */
3+
import com.linkedin.openhouse.tables.model.TableDto;
4+
import java.util.Map;
5+
import org.slf4j.Logger;
6+
import org.slf4j.LoggerFactory;
7+
8+
/**
9+
* Interface to check if a feature is toggled-on for a table.
10+
*
11+
* <p>TODO: the two forms below differ in who may influence the decision, which today is conveyed by
12+
* their names and javadoc rather than enforced. The intended model declares that per feature
13+
* instead of per call site — {@code TableFeature.capability(id)} vs {@code
14+
* TableFeature.rollout(id)} — so a permission-bearing feature cannot acquire self-service opt-in by
15+
* calling the wrong method. The same change would carry a decision's cause for metrics, give rules
16+
* an explicit effect, priority and expiry rather than presence-implies-active, and evaluate rules
17+
* from a locally replicated snapshot so a table read no longer blocks on HouseTables.
18+
*/
419
public interface TableFeatureToggle {
20+
Logger LOG = LoggerFactory.getLogger(TableFeatureToggle.class);
21+
22+
/** Suffix appended to a feature id to form its self-service table property. */
23+
String ENABLED_PROPERTY_SUFFIX = ".enabled";
24+
525
/**
6-
* Determine if given feature is activated for the table.
26+
* Determines the server-side activation decision for a table.
727
*
8-
* @param databaseId databaseId
9-
* @param tableId tableId
10-
* @param featureId featureId
11-
* @return True if the feature is activated for the table.
28+
* <p>Authorization gates — features deciding whether a user may write an otherwise preserved
29+
* property, like {@code enable_mor} — must use this form, since the table property honored by
30+
* {@link #isFeatureActivatedWithOverride(TableDto, String)} is writable by the user being gated.
1231
*/
1332
boolean isFeatureActivated(String databaseId, String tableId, String featureId);
33+
34+
/**
35+
* Determines activation, letting the table override the server-side decision.
36+
*
37+
* <p>An explicit {@code <featureId>.enabled} property opts the table in or out; when absent, the
38+
* server-side toggle decides. An unparseable value fails closed, so a typo cannot make the table
39+
* unusable.
40+
*/
41+
default boolean isFeatureActivatedWithOverride(TableDto tableDto, String featureId) {
42+
Map<String, String> properties = tableDto.getTableProperties();
43+
String tableProperty = featureId + ENABLED_PROPERTY_SUFFIX;
44+
String override = properties == null ? null : properties.get(tableProperty);
45+
if (override == null) {
46+
return isFeatureActivated(tableDto.getDatabaseId(), tableDto.getTableId(), featureId);
47+
}
48+
49+
String normalized = override.trim();
50+
if ("true".equalsIgnoreCase(normalized)) {
51+
return true;
52+
}
53+
if ("false".equalsIgnoreCase(normalized)) {
54+
return false;
55+
}
56+
LOG.warn(
57+
"Ignoring unparseable table property {}={} for {}.{}; treating feature {} as inactive",
58+
tableProperty,
59+
override,
60+
tableDto.getDatabaseId(),
61+
tableDto.getTableId(),
62+
featureId);
63+
return false;
64+
}
1465
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package com.linkedin.openhouse.tables.toggle;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertFalse;
5+
import static org.junit.jupiter.api.Assertions.assertTrue;
6+
7+
import com.linkedin.openhouse.tables.model.TableDto;
8+
import java.util.Collections;
9+
import java.util.Map;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.Test;
12+
13+
class TableFeatureToggleTest {
14+
private static final String DATABASE = "database";
15+
private static final String TABLE = "table";
16+
private static final String FEATURE = "feature";
17+
private static final String PROPERTY = "feature.enabled";
18+
19+
private TestTableFeatureToggle featureToggle;
20+
21+
@BeforeEach
22+
void setUp() {
23+
featureToggle = new TestTableFeatureToggle();
24+
}
25+
26+
@Test
27+
void testUsesServerToggleWhenPropertyIsAbsent() {
28+
TableDto tableDto = tableWithProperties(Collections.emptyMap());
29+
featureToggle.serverDecision = true;
30+
31+
assertTrue(featureToggle.isFeatureActivatedWithOverride(tableDto, FEATURE));
32+
assertEquals(1, featureToggle.serverInvocationCount);
33+
}
34+
35+
@Test
36+
void testUsesServerToggleWhenPropertiesAreNull() {
37+
TableDto tableDto = tableWithProperties(null);
38+
featureToggle.serverDecision = false;
39+
40+
assertFalse(featureToggle.isFeatureActivatedWithOverride(tableDto, FEATURE));
41+
assertEquals(1, featureToggle.serverInvocationCount);
42+
}
43+
44+
@Test
45+
void testTruePropertyOptsInWithoutServerToggle() {
46+
TableDto tableDto = tableWithProperties(Collections.singletonMap(PROPERTY, "true"));
47+
48+
assertTrue(featureToggle.isFeatureActivatedWithOverride(tableDto, FEATURE));
49+
assertEquals(0, featureToggle.serverInvocationCount);
50+
}
51+
52+
@Test
53+
void testFalsePropertyOptsOutWithoutServerToggle() {
54+
TableDto tableDto = tableWithProperties(Collections.singletonMap(PROPERTY, "false"));
55+
56+
assertFalse(featureToggle.isFeatureActivatedWithOverride(tableDto, FEATURE));
57+
assertEquals(0, featureToggle.serverInvocationCount);
58+
}
59+
60+
@Test
61+
void testPropertyParsingIgnoresCaseAndWhitespace() {
62+
TableDto tableDto = tableWithProperties(Collections.singletonMap(PROPERTY, " TRUE "));
63+
64+
assertTrue(featureToggle.isFeatureActivatedWithOverride(tableDto, FEATURE));
65+
assertEquals(0, featureToggle.serverInvocationCount);
66+
}
67+
68+
@Test
69+
void testUnparseablePropertyFailsClosed() {
70+
TableDto tableDto = tableWithProperties(Collections.singletonMap(PROPERTY, "sometimes"));
71+
featureToggle.serverDecision = true;
72+
73+
assertFalse(featureToggle.isFeatureActivatedWithOverride(tableDto, FEATURE));
74+
assertEquals(0, featureToggle.serverInvocationCount);
75+
}
76+
77+
private static TableDto tableWithProperties(Map<String, String> properties) {
78+
return TableDto.builder()
79+
.databaseId(DATABASE)
80+
.tableId(TABLE)
81+
.tableProperties(properties)
82+
.build();
83+
}
84+
85+
private static class TestTableFeatureToggle implements TableFeatureToggle {
86+
private boolean serverDecision;
87+
private int serverInvocationCount;
88+
89+
@Override
90+
public boolean isFeatureActivated(String databaseId, String tableId, String featureId) {
91+
assertEquals(DATABASE, databaseId);
92+
assertEquals(TABLE, tableId);
93+
assertEquals(FEATURE, featureId);
94+
serverInvocationCount++;
95+
return serverDecision;
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)