Skip to content

Commit de95f1d

Browse files
authored
Add SPOG support for ?o= routing in httpPath (#1316)
## Summary SPOG (Single Panel of Glass) replaces workspace-specific hostnames with account-level vanity URLs. When httpPath contains `?o=<workspaceId>`, the driver needs to: 1. Preserve the `?o=` value through URL parsing 2. Extract the clean warehouse ID (without `?o=`) for SEA JSON body 3. Inject `x-databricks-org-id` header on endpoints that don't carry `?o=` in the URL path (telemetry, feature flags, DBFS volume) ### Changes **Fix 1 — Property parser** (`DatabricksConnectionContext.java`): - `split("=")` broke httpPath values containing `=` (e.g. `httpPath=/sql/1.0/warehouses/xxx?o=yyy` split into 3 parts, dropping the workspace ID) - Changed to `indexOf("=")` + `substring` to split on first `=` only **Fix 2 — Warehouse ID regex** (`DatabricksJdbcConstants.java`): - `(.+)` captured `abc123?o=999` as warehouse ID, corrupting SEA JSON body - Changed to `([^?]+).*` to stop capture at query params - Trailing `.*` needed because Java `.matches()` requires full-string match **Fix 3 — SPOG header extraction** (`DatabricksConnectionContext.java`): - Uses `URIBuilder` to parse `?o=<workspaceId>` from httpPath - Injects `x-databricks-org-id` into `customHeaders` map - Respects explicitly set header (won't override) **Fix 4 — Header propagation** (telemetry, feature flags, DBFS volume): - `TelemetryPushClient.java`: Added `connectionContext.getCustomHeaders()` to telemetry POST - `DatabricksDriverFeatureFlagsContext.java`: Added `connectionContext.getCustomHeaders()` to feature flag GET - `DBFSVolumeClient.java`: Added `connectionContext.getCustomHeaders()` to all 5 HTTP request paths (4 sync + 1 async), with null guard for test constructor ### What does NOT get the header OAuth endpoints (`/oidc/v1/token`, OIDC discovery) — they are workspace-agnostic and reject `x-databricks-org-id` with HTTP 400. These use their own HTTP client and never see `customHeaders`. ## Context Per the [SPOG Peco Clients doc](https://docs.google.com/document/d/1ZTacOW72Jetr6Qo1iLVogeXKDYoFAE_R8_32KmLawmQ/): - Thrift: `?o=` in the POST URL handles routing naturally - SEA, telemetry, feature flags, DBFS volume: Need `x-databricks-org-id` header since they use separate endpoint paths Jira: [XTA-15079](https://databricks.atlassian.net/browse/XTA-15079) ## Test plan **Unit tests added:** - [x] `testBuildPropertiesMap_preservesQueryParamInHttpPath` — httpPath with `?o=` preserved - [x] `testBuildPropertiesMap_handlesValueWithMultipleEquals` — values with multiple `=` preserved - [x] `testBuildPropertiesMap_handlesValueWithNoEquals` — key-only params handled - [x] `testSpogContext_extractsOrgIdFromHttpPath` — `x-databricks-org-id` extracted from `?o=` - [x] `testSpogContext_extractsCleanWarehouseId` — warehouse ID is `abc123` not `abc123?o=...` - [x] `testSpogContext_noOrgIdWithoutQueryParam` — no header injected for legacy URLs - [x] `testSpogContext_explicitHeaderTakesPrecedence` — explicit `http.header.x-databricks-org-id` wins over `?o=` - [x] 3 SPOG URL validation tests in `ValidationUtilTest` **E2E tested (not committed):** - [x] DBSQL + PAT: Thrift and SEA on SPOG host - [x] GP Cluster + PAT: Thrift on SPOG host - [x] OAuth M2M: Thrift and SEA on SPOG host - [x] OAuth U2M: Thrift and SEA on SPOG host (with browser login) - [x] Telemetry: Verified 200 on SPOG with org-id header - [x] Feature flags: Verified 200 on SPOG with org-id header - [x] DBFS Volume LIST: Verified on SPOG host - [x] Legacy host: All auth modes unaffected NO_CHANGELOG=true This pull request was AI-assisted by Isaac. [XTA-15079]: https://databricks.atlassian.net/browse/XTA-15079?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com> Signed-off-by: Madhavendra Rathore
1 parent a29d39b commit de95f1d

8 files changed

Lines changed: 169 additions & 19 deletions

File tree

src/main/java/com/databricks/jdbc/api/impl/DatabricksConnectionContext.java

Lines changed: 52 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@
2929
import com.google.common.base.Supplier;
3030
import com.google.common.collect.ImmutableMap;
3131
import java.net.URI;
32+
import java.net.URISyntaxException;
3233
import java.util.*;
3334
import java.util.Collections;
3435
import java.util.regex.Matcher;
3536
import java.util.stream.Collectors;
37+
import org.apache.http.NameValuePair;
3638
import org.apache.http.client.utils.URIBuilder;
3739

3840
public class DatabricksConnectionContext implements IDatabricksConnectionContext {
@@ -108,14 +110,14 @@ public static ImmutableMap<String, String> buildPropertiesMap(
108110
if (!isNullOrEmpty(connectionParamString)) {
109111
String[] urlParts = connectionParamString.split(DatabricksJdbcConstants.URL_DELIMITER);
110112
for (String urlPart : urlParts) {
111-
String[] pair = urlPart.split(DatabricksJdbcConstants.PAIR_DELIMITER);
112-
if (pair.length == 1) {
113-
pair = new String[] {pair[0], ""};
114-
}
115-
if (pair[0].startsWith(DatabricksJdbcUrlParams.HTTP_HEADERS.getParamName())) {
116-
parametersBuilder.put(pair[0], pair[1]);
113+
// Split on first '=' only — values (like httpPath) may contain '=' (e.g. ?o=123)
114+
int delimIdx = urlPart.indexOf(DatabricksJdbcConstants.PAIR_DELIMITER);
115+
String key = delimIdx >= 0 ? urlPart.substring(0, delimIdx) : urlPart;
116+
String value = delimIdx >= 0 ? urlPart.substring(delimIdx + 1) : "";
117+
if (key.startsWith(DatabricksJdbcUrlParams.HTTP_HEADERS.getParamName())) {
118+
parametersBuilder.put(key, value);
117119
} else {
118-
parametersBuilder.put(pair[0].toLowerCase(), pair[1]);
120+
parametersBuilder.put(key.toLowerCase(), value);
119121
}
120122
}
121123
}
@@ -1166,14 +1168,52 @@ private String getParameter(DatabricksJdbcUrlParams key, String defaultValue) {
11661168
return this.parameters.getOrDefault(key.getParamName().toLowerCase(), defaultValue);
11671169
}
11681170

1171+
private static final String ORG_ID_HEADER = "x-databricks-org-id";
1172+
private static final String ORG_ID_QUERY_PARAM = "o";
1173+
11691174
private Map<String, String> parseCustomHeaders(ImmutableMap<String, String> parameters) {
11701175
String filterPrefix = DatabricksJdbcUrlParams.HTTP_HEADERS.getParamName();
11711176

1172-
return parameters.entrySet().stream()
1173-
.filter(entry -> entry.getKey().startsWith(filterPrefix))
1174-
.collect(
1175-
Collectors.toMap(
1176-
entry -> entry.getKey().substring(filterPrefix.length()), Map.Entry::getValue));
1177+
Map<String, String> headers =
1178+
new HashMap<>(
1179+
parameters.entrySet().stream()
1180+
.filter(entry -> entry.getKey().startsWith(filterPrefix))
1181+
.collect(
1182+
Collectors.toMap(
1183+
entry -> entry.getKey().substring(filterPrefix.length()),
1184+
Map.Entry::getValue)));
1185+
1186+
// Extract org ID from ?o= in httpPath for SPOG routing
1187+
if (!headers.containsKey(ORG_ID_HEADER)) {
1188+
String httpPath =
1189+
parameters.getOrDefault(
1190+
DatabricksJdbcUrlParams.HTTP_PATH.getParamName().toLowerCase(), "");
1191+
try {
1192+
for (NameValuePair param :
1193+
new URIBuilder("http://placeholder" + httpPath).getQueryParams()) {
1194+
if (ORG_ID_QUERY_PARAM.equals(param.getName())
1195+
&& param.getValue() != null
1196+
&& !param.getValue().isEmpty()) {
1197+
headers.put(ORG_ID_HEADER, param.getValue());
1198+
LOGGER.debug(
1199+
"SPOG header extraction: injecting {}={} (extracted from ?o= in httpPath)",
1200+
ORG_ID_HEADER,
1201+
param.getValue());
1202+
break;
1203+
}
1204+
}
1205+
} catch (URISyntaxException e) {
1206+
LOGGER.debug(
1207+
"SPOG header extraction: malformed httpPath, skipping org-id extraction: "
1208+
+ e.getMessage());
1209+
}
1210+
} else {
1211+
LOGGER.debug(
1212+
"SPOG header extraction: {} already set by caller, not extracting from httpPath",
1213+
ORG_ID_HEADER);
1214+
}
1215+
1216+
return headers;
11771217
}
11781218

11791219
@Override

src/main/java/com/databricks/jdbc/api/impl/volume/DBFSVolumeClient.java

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -492,7 +492,8 @@ CreateUploadUrlResponse getCreateUploadUrlResponse(String objectPath)
492492
CreateUploadUrlRequest request = new CreateUploadUrlRequest(objectPath);
493493
try {
494494
Request req = new Request(Request.POST, CREATE_UPLOAD_URL_PATH, apiClient.serialize(request));
495-
req.withHeaders(JSON_HTTP_HEADERS);
495+
req.withHeaders(JSON_HTTP_HEADERS)
496+
.withHeaders(connectionContext != null ? connectionContext.getCustomHeaders() : Map.of());
496497
return apiClient.execute(req, CreateUploadUrlResponse.class);
497498
} catch (IOException | DatabricksException e) {
498499
String errorMessage =
@@ -514,7 +515,8 @@ CreateDownloadUrlResponse getCreateDownloadUrlResponse(String objectPath)
514515
try {
515516
Request req =
516517
new Request(Request.POST, CREATE_DOWNLOAD_URL_PATH, apiClient.serialize(request));
517-
req.withHeaders(JSON_HTTP_HEADERS);
518+
req.withHeaders(JSON_HTTP_HEADERS)
519+
.withHeaders(connectionContext != null ? connectionContext.getCustomHeaders() : Map.of());
518520
return apiClient.execute(req, CreateDownloadUrlResponse.class);
519521
} catch (IOException | DatabricksException e) {
520522
String errorMessage =
@@ -534,7 +536,8 @@ CreateDeleteUrlResponse getCreateDeleteUrlResponse(String objectPath)
534536

535537
try {
536538
Request req = new Request(Request.POST, CREATE_DELETE_URL_PATH, apiClient.serialize(request));
537-
req.withHeaders(JSON_HTTP_HEADERS);
539+
req.withHeaders(JSON_HTTP_HEADERS)
540+
.withHeaders(connectionContext != null ? connectionContext.getCustomHeaders() : Map.of());
538541
return apiClient.execute(req, CreateDeleteUrlResponse.class);
539542
} catch (IOException | DatabricksException e) {
540543
String errorMessage =
@@ -551,7 +554,8 @@ ListResponse getListResponse(String listPath) throws DatabricksVolumeOperationEx
551554
ListRequest request = new ListRequest(listPath);
552555
try {
553556
Request req = new Request(Request.GET, LIST_PATH);
554-
req.withHeaders(JSON_HTTP_HEADERS);
557+
req.withHeaders(JSON_HTTP_HEADERS)
558+
.withHeaders(connectionContext != null ? connectionContext.getCustomHeaders() : Map.of());
555559
ApiClient.setQuery(req, request);
556560
return apiClient.execute(req, ListResponse.class);
557561
} catch (IOException | DatabricksException e) {
@@ -888,6 +892,9 @@ private CompletableFuture<CreateUploadUrlResponse> requestPresignedUrlWithRetry(
888892
Map<String, String> authHeaders = workspaceClient.config().authenticate();
889893
authHeaders.forEach(requestBuilder::addHeader);
890894
JSON_HTTP_HEADERS.forEach(requestBuilder::addHeader);
895+
if (connectionContext != null) {
896+
connectionContext.getCustomHeaders().forEach(requestBuilder::addHeader);
897+
}
891898

892899
requestBuilder.setEntity(
893900
AsyncEntityProducers.create(requestBody.getBytes(), ContentType.APPLICATION_JSON));

src/main/java/com/databricks/jdbc/common/DatabricksJdbcConstants.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@ public final class DatabricksJdbcConstants {
1717
"(?:/([^;]*))?"
1818
+ // Optional Schema (captured without /)
1919
"(?:;(.*))?"); // Optional Property=Value pairs (captured without leading ;)
20-
public static final Pattern HTTP_WAREHOUSE_PATH_PATTERN = Pattern.compile(".*/warehouses/(.+)");
21-
public static final Pattern HTTP_ENDPOINT_PATH_PATTERN = Pattern.compile(".*/endpoints/(.+)");
20+
public static final Pattern HTTP_WAREHOUSE_PATH_PATTERN =
21+
Pattern.compile(".*/warehouses/([^?&]+).*");
22+
public static final Pattern HTTP_ENDPOINT_PATH_PATTERN =
23+
Pattern.compile(".*/endpoints/([^?&]+).*");
2224
public static final Pattern HTTP_CLI_PATTERN = Pattern.compile(".*cliservice(.+)");
2325
public static final Pattern HTTP_PATH_CLI_PATTERN = Pattern.compile("cliservice");
2426
public static final Pattern TEST_PATH_PATTERN = Pattern.compile("jdbc:databricks://test");

src/main/java/com/databricks/jdbc/common/safe/DatabricksDriverFeatureFlagsContext.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ private void refreshAllFeatureFlags() {
102102
.getDatabricksConfig()
103103
.authenticate()
104104
.forEach(request::addHeader);
105+
connectionContext.getCustomHeaders().forEach(request::addHeader);
105106
fetchAndSetFlagsFromServer(httpClient, request);
106107
} catch (Exception e) {
107108
LOGGER.trace(

src/main/java/com/databricks/jdbc/telemetry/TelemetryPushClient.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public void pushEvent(TelemetryRequest request) throws Exception {
5959
Map<String, String> authHeaders =
6060
isAuthenticated ? databricksConfig.authenticate() : Collections.emptyMap();
6161
authHeaders.forEach(post::addHeader);
62+
connectionContext.getCustomHeaders().forEach(post::addHeader);
6263
try (CloseableHttpResponse response = httpClient.execute(post)) {
6364
// TODO: check response and add retry for partial failures
6465
if (!HttpUtil.isSuccessfulHttpResponse(response)) {

src/test/java/com/databricks/jdbc/TestConstants.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,4 +321,17 @@ public class TestConstants {
321321
public static final List<TSparkArrowBatch> ARROW_BATCH_LIST =
322322
Collections.singletonList(
323323
new TSparkArrowBatch().setRowCount(0).setBatch(new byte[] {65, 66, 67}));
324+
325+
// SPOG URLs with ?o= query parameter in httpPath
326+
public static final String VALID_SPOG_URL_WAREHOUSE =
327+
"jdbc:databricks://spog.cloud.databricks.com/default;ssl=1;AuthMech=3;"
328+
+ "httpPath=/sql/1.0/warehouses/abc123?o=6051921418418893;UseThriftClient=1";
329+
330+
public static final String VALID_SPOG_URL_ENDPOINT =
331+
"jdbc:databricks://spog.cloud.databricks.com/default;ssl=1;AuthMech=3;"
332+
+ "httpPath=/sql/1.0/endpoints/abc123?o=6051921418418893;UseThriftClient=0";
333+
334+
public static final String VALID_SPOG_URL_WAREHOUSE_NO_EXTRA_PARAMS =
335+
"jdbc:databricks://spog.cloud.databricks.com/default;ssl=1;AuthMech=3;"
336+
+ "httpPath=/sql/1.0/warehouses/abc123?o=6051921418418893";
324337
}

src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionContextTest.java

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1358,6 +1358,85 @@ public void testOAuthWebServerTimeoutCustom() throws DatabricksSQLException {
13581358
assertEquals(300, connectionContext.getOAuthWebServerTimeout());
13591359
}
13601360

1361+
// ==================== SPOG ?o= Tests ====================
1362+
1363+
@Test
1364+
void testBuildPropertiesMap_preservesQueryParamInHttpPath() {
1365+
String params = "ssl=1;AuthMech=3;httpPath=/sql/1.0/warehouses/abc123?o=999;UseThriftClient=1";
1366+
ImmutableMap<String, String> result = buildPropertiesMap(params, new Properties());
1367+
1368+
assertEquals("/sql/1.0/warehouses/abc123?o=999", result.get("httppath"));
1369+
assertEquals("1", result.get("usethriftclient"));
1370+
}
1371+
1372+
@Test
1373+
void testBuildPropertiesMap_handlesValueWithMultipleEquals() {
1374+
String params = "httpPath=/sql/1.0/warehouses/abc?o=999&other=foo";
1375+
ImmutableMap<String, String> result = buildPropertiesMap(params, new Properties());
1376+
1377+
assertEquals("/sql/1.0/warehouses/abc?o=999&other=foo", result.get("httppath"));
1378+
}
1379+
1380+
@Test
1381+
void testBuildPropertiesMap_handlesValueWithNoEquals() {
1382+
String params = "keyonly";
1383+
ImmutableMap<String, String> result = buildPropertiesMap(params, new Properties());
1384+
1385+
assertEquals("", result.get("keyonly"));
1386+
}
1387+
1388+
@Test
1389+
void testSpogContext_extractsOrgIdFromHttpPath() throws DatabricksSQLException {
1390+
Properties props = new Properties();
1391+
props.put("user", "token");
1392+
props.put("password", "test-token");
1393+
IDatabricksConnectionContext ctx =
1394+
DatabricksConnectionContext.parse(TestConstants.VALID_SPOG_URL_WAREHOUSE, props);
1395+
1396+
Map<String, String> headers = ctx.getCustomHeaders();
1397+
assertEquals("6051921418418893", headers.get("x-databricks-org-id"));
1398+
}
1399+
1400+
@Test
1401+
void testSpogContext_extractsCleanWarehouseId() throws DatabricksSQLException {
1402+
Properties props = new Properties();
1403+
props.put("user", "token");
1404+
props.put("password", "test-token");
1405+
IDatabricksConnectionContext ctx =
1406+
DatabricksConnectionContext.parse(TestConstants.VALID_SPOG_URL_WAREHOUSE, props);
1407+
1408+
// Warehouse ID should be "abc123" not "abc123?o=6051921418418893"
1409+
assertTrue(ctx.getComputeResource() instanceof Warehouse);
1410+
assertEquals("abc123", ((Warehouse) ctx.getComputeResource()).getWarehouseId());
1411+
}
1412+
1413+
@Test
1414+
void testSpogContext_noOrgIdWithoutQueryParam() throws DatabricksSQLException {
1415+
Properties props = new Properties();
1416+
props.put("user", "token");
1417+
props.put("password", "test-token");
1418+
IDatabricksConnectionContext ctx =
1419+
DatabricksConnectionContext.parse(TestConstants.VALID_URL_1, props);
1420+
1421+
Map<String, String> headers = ctx.getCustomHeaders();
1422+
assertFalse(headers.containsKey("x-databricks-org-id"));
1423+
}
1424+
1425+
@Test
1426+
void testSpogContext_explicitHeaderTakesPrecedence() throws DatabricksSQLException {
1427+
String url =
1428+
"jdbc:databricks://host/default;ssl=1;AuthMech=3;"
1429+
+ "httpPath=/sql/1.0/warehouses/abc123?o=frompath;"
1430+
+ "http.header.x-databricks-org-id=fromheader";
1431+
Properties props = new Properties();
1432+
props.put("user", "token");
1433+
props.put("password", "test-token");
1434+
IDatabricksConnectionContext ctx = DatabricksConnectionContext.parse(url, props);
1435+
1436+
Map<String, String> headers = ctx.getCustomHeaders();
1437+
assertEquals("fromheader", headers.get("x-databricks-org-id"));
1438+
}
1439+
13611440
@Test
13621441
public void testDefaultGetterCoverage() throws DatabricksSQLException {
13631442
IDatabricksConnectionContext ctx =

src/test/java/com/databricks/jdbc/common/util/ValidationUtilTest.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,13 @@ private static Stream<Arguments> jdbcUrlValidityTestCases() {
128128
"Valid URL with invalid compression type",
129129
true),
130130
Arguments.of(INVALID_URL_1, "Invalid non-Databricks JDBC URL", false),
131-
Arguments.of(INVALID_URL_2, "Invalid malformed JDBC scheme", false));
131+
Arguments.of(INVALID_URL_2, "Invalid malformed JDBC scheme", false),
132+
Arguments.of(
133+
VALID_SPOG_URL_WAREHOUSE, "Valid SPOG URL with ?o= in warehouse httpPath", true),
134+
Arguments.of(VALID_SPOG_URL_ENDPOINT, "Valid SPOG URL with ?o= in endpoint httpPath", true),
135+
Arguments.of(
136+
VALID_SPOG_URL_WAREHOUSE_NO_EXTRA_PARAMS,
137+
"Valid SPOG URL with ?o= at end of URL",
138+
true));
132139
}
133140
}

0 commit comments

Comments
 (0)