Skip to content

Commit 4718534

Browse files
genezhangclaude
andauthored
feat(deltagraph): Bolt e2e boot test + QUICKSTART docs (Phase 4.3) (#337)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 75e66dc commit 4718534

4 files changed

Lines changed: 538 additions & 0 deletions

File tree

Cargo.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,12 @@ path = "tests/rust/e2e/mod.rs"
118118
name = "deltagraph_bin"
119119
path = "tests/rust/bin/deltagraph_smoke.rs"
120120
required-features = ["databricks"]
121+
122+
# Bolt-level boot regression for `deltagraph` (Phase 4.3). Spawns the
123+
# subprocess against wiremock and completes the Bolt v5 handshake to
124+
# prove the server boots end-to-end in Databricks mode without
125+
# tripping any ClickHouse-specific init.
126+
[[test]]
127+
name = "deltagraph_bolt_e2e"
128+
path = "tests/rust/bin/deltagraph_bolt_e2e.rs"
129+
required-features = ["databricks"]

docs/deltagraph/QUICKSTART.md

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
# DeltaGraph Quickstart
2+
3+
DeltaGraph turns a Databricks SQL Warehouse into a Cypher-queryable graph
4+
database. Same Cypher you'd send to Neo4j; the server translates it to
5+
Spark SQL and executes against your warehouse over the Statement
6+
Execution API. Neo4j Browser, NeoDash, graph-notebook, and any other
7+
Bolt v5 client connect unchanged.
8+
9+
This document walks through the manual setup: build, configure, point
10+
Neo4j Browser at the server, run sample Cypher.
11+
12+
> ⚠️ **Status:** DeltaGraph ships in `v0.6.7-dev`. Phases 1.x–4.3 are
13+
> complete: dialect routing, executor, embedded API, FFI, `cg` CLI,
14+
> server binary, Bolt e2e. Phase 3 (catalog discovery) and Phase 5
15+
> (full release) are still ahead. Treat this as an early-adopter path
16+
> until v0.7.0 lands.
17+
18+
## Prerequisites
19+
20+
- A Databricks workspace with at least one **SQL Warehouse** running.
21+
- A **Personal Access Token** (PAT) with `SELECT` on the catalog/schema
22+
you want to query. OAuth M2M is on the roadmap; PAT is the only auth
23+
method today.
24+
- A **schema YAML** describing how your tables map to a graph
25+
(`benchmarks/social_network/schemas/social_benchmark.yaml` is the
26+
smallest example in the repo).
27+
- Rust 1.85+ to build from source. We do not yet ship pre-built
28+
`deltagraph` release artifacts.
29+
30+
## 1. Build
31+
32+
```bash
33+
cargo build --release --features databricks --bin deltagraph
34+
```
35+
36+
The `databricks` feature pulls in the executor and its `reqwest`
37+
client. The default build (no feature) produces only `clickgraph`,
38+
which talks to ClickHouse — that binary is unaffected by this work.
39+
40+
## 2. Configure the environment
41+
42+
The server reads these env vars at startup:
43+
44+
| Variable | Required | Purpose |
45+
| ---------------------------- | -------- | -------------------------------------------------------------------- |
46+
| `DATABRICKS_HOST` | yes | Workspace hostname, no scheme. `dbc-abc123-def4.cloud.databricks.com` |
47+
| `DATABRICKS_WAREHOUSE_ID` | yes | Target SQL Warehouse ID (find under SQL Warehouses in the UI) |
48+
| `DATABRICKS_TOKEN` | yes | Personal access token. **Env-only; never accepted as a flag.** |
49+
| `DATABRICKS_CATALOG` | no | Default catalog for unqualified table names |
50+
| `DATABRICKS_SCHEMA` | no | Default schema for unqualified table names |
51+
| `GRAPH_CONFIG_PATH` | yes | Path to your graph-schema YAML |
52+
53+
The token deliberately has no CLI flag — exposing it on the command
54+
line would leak via `ps`, shell history, and CI log uploads.
55+
56+
## 3. Start the server
57+
58+
```bash
59+
./target/release/deltagraph
60+
```
61+
62+
You'll see:
63+
64+
```
65+
DeltaGraph v0.6.7-dev
66+
67+
🧱 DeltaGraph mode: routing queries through a Databricks SQL Warehouse
68+
✓ Schema initialization complete (YAML mode, 1 schema(s) registered)
69+
✓ Successfully bound HTTP listener to 0.0.0.0:7475
70+
Successfully bound Bolt listener to 0.0.0.0:7687
71+
ClickGraph server is running
72+
HTTP API: http://0.0.0.0:7475
73+
Bolt Protocol: bolt://0.0.0.0:7687
74+
```
75+
76+
By default the server starts in Neo4j compat mode (so Neo4j Browser
77+
recognises it as a Neo4j 5.x server) and accepts unauthenticated
78+
connections. The compat mode is the headline feature for the Browser
79+
demo; pass `--disable-neo4j-compat` if you want the raw ClickGraph
80+
identity.
81+
82+
## 4. Point Neo4j Browser at it
83+
84+
Open Neo4j Browser (web or desktop). Connect with:
85+
86+
```
87+
Connection URI: bolt://localhost:7687
88+
Authentication: no auth (or: Basic, username `neo4j`, any password)
89+
```
90+
91+
You should land on the standard Browser welcome page. Click the
92+
schema icon in the sidebar to list node labels — they'll come from
93+
your YAML.
94+
95+
### Sample queries (assuming the social-network schema)
96+
97+
```cypher
98+
// Count all users
99+
MATCH (u:User) RETURN count(u) AS users;
100+
101+
// Top followers
102+
MATCH (u:User)<-[:FOLLOWS]-(f:User)
103+
RETURN u.name AS user, count(f) AS followers
104+
ORDER BY followers DESC
105+
LIMIT 10;
106+
107+
// Friends-of-friends (variable-length path)
108+
MATCH (me:User {user_id: 42})-[:FOLLOWS*2..2]->(fof:User)
109+
WHERE fof.user_id <> 42
110+
RETURN DISTINCT fof.name AS friend_of_friend
111+
LIMIT 25;
112+
```
113+
114+
Each query is translated to Spark SQL locally, posted to your
115+
warehouse's Statement Execution API, and the result is mapped back
116+
into the Bolt response Browser expects. Use Browser's "Query Plan" or
117+
the equivalent HTTP probe (below) to see the actual SQL.
118+
119+
## 5. Inspect the generated SQL (without executing)
120+
121+
For debugging the translation:
122+
123+
```bash
124+
curl -s -X POST http://localhost:7475/query \
125+
-H "Content-Type: application/json" \
126+
-d '{"query":"MATCH (u:User) RETURN u.name LIMIT 5","sql_only":true}' \
127+
| jq -r .sql
128+
```
129+
130+
This returns the Spark SQL the executor would have sent, without
131+
touching the warehouse. Equivalent to:
132+
133+
```bash
134+
cg --schema schema.yaml --dialect databricks sql "MATCH (u:User) RETURN u.name LIMIT 5"
135+
```
136+
137+
## What works today
138+
139+
- Read queries: `MATCH`, `WHERE`, `RETURN`, `WITH`, `ORDER BY`, `LIMIT`,
140+
`SKIP`, `DISTINCT`, `OPTIONAL MATCH`, `UNWIND`, `UNION ALL`.
141+
- Variable-length paths (`*1..n`), shortest path, multi-hop traversals.
142+
- Aggregations: `count`, `sum`, `avg`, `min`, `max`, `collect`.
143+
- String / numeric / date / list / map functions — all routed through
144+
the dialect-aware `FunctionMapper` (e.g. `collect()``collect_list`,
145+
`toInt64()``bigint`).
146+
- Pattern comprehension, list comprehension, `CASE` expressions.
147+
- Neo4j Browser, NeoDash, graph-notebook, neo4rs, any Bolt v5 client.
148+
149+
## What's not in this iteration
150+
151+
- **Writes** (`CREATE`, `SET`, `DELETE`, `MERGE`). DeltaGraph is read-only
152+
against Databricks; embedded chdb is the only write target today.
153+
Write support against Delta tables is not on the current roadmap and
154+
has no committed timeline — track [the DeltaGraph plan](../design/DELTAGRAPH_PLAN.md)
155+
for any change in scope.
156+
- **OAuth M2M.** PAT is the only supported auth.
157+
- **External-link result chunks.** Large result sets (>25 MB) currently
158+
fail with an error from the Statement Execution API. The executor
159+
uses `INLINE`/`JSON_ARRAY` disposition; switching to `EXTERNAL_LINKS`
160+
for large results is a Phase 5 deliverable.
161+
- **`CALL` subqueries** (e.g. LDBC bi-16) — same gap as ClickGraph;
162+
inherited from the shared planner.
163+
- **Schema discovery from Unity Catalog** (`SHOW TABLES IN catalog.schema`,
164+
`DESCRIBE TABLE EXTENDED`). Phase 3 ships this; until then your YAML
165+
is hand-authored or generated via `cg schema discover` against a
166+
ClickHouse staging copy.
167+
168+
## Pointing `cg` at the same warehouse
169+
170+
The `cg` CLI also supports `--dialect databricks` for ad-hoc queries
171+
without the full server:
172+
173+
```bash
174+
export DATABRICKS_HOST=...
175+
export DATABRICKS_WAREHOUSE_ID=...
176+
export DATABRICKS_TOKEN=...
177+
178+
# Translate (no execution):
179+
cg --schema schema.yaml --dialect databricks sql "MATCH (u:User) RETURN u.name"
180+
181+
# Execute against the warehouse (requires `cg` built with --features databricks):
182+
cargo install --path clickgraph-tool --features databricks --force
183+
cg --schema schema.yaml --dialect databricks query "MATCH (u:User) RETURN u.name LIMIT 5"
184+
```
185+
186+
`cg` shares the same `DATABRICKS_*` env names, plus `CG_DATABRICKS_*`
187+
overrides if you want to scope settings to `cg` alone without leaking
188+
into a running `deltagraph` server.
189+
190+
## Troubleshooting
191+
192+
**`DATABRICKS_HOST not set`** — env var is missing or scrubbed. Check
193+
that you didn't `env -i` the shell, and that `direnv` (if used) loaded
194+
the right `.envrc`.
195+
196+
**`401 Unauthorized` from the executor** — PAT is invalid, expired, or
197+
lacks `SELECT` on the catalog/schema. Verify with `curl` against
198+
`https://$DATABRICKS_HOST/api/2.0/sql/warehouses/$DATABRICKS_WAREHOUSE_ID`.
199+
200+
**Browser shows "Could not connect"** — the server's Bolt port is bound
201+
to `0.0.0.0` by default. Check `lsof -i :7687` on the server host. If
202+
running in Docker, expose the port (`-p 7687:7687`).
203+
204+
**Query returns "Cannot resolve column …"** — the Cypher property names
205+
in your query don't match the `property_mappings` in your schema YAML.
206+
This is the most common source of friction; double-check the YAML.
207+
208+
**Slow first query** — Databricks SQL Warehouses scale to zero by
209+
default. The first query after idle takes 30–90s for the warehouse to
210+
warm up. Subsequent queries are sub-second.
211+
212+
## Where to file feedback
213+
214+
Issues go in the main ClickGraph repo with the `dialect:databricks`
215+
label. The DeltaGraph design doc is at
216+
[`docs/design/DELTAGRAPH_PLAN.md`](../design/DELTAGRAPH_PLAN.md).

src/server/mod.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,5 +647,101 @@ fn build_databricks_config() -> Result<crate::executor::databricks_sql::Databric
647647
crate::executor::databricks_sql::DatabricksConfig::new(hostname, warehouse_id, token);
648648
cfg.catalog = std::env::var("DATABRICKS_CATALOG").ok();
649649
cfg.schema = std::env::var("DATABRICKS_SCHEMA").ok();
650+
// Test-only override for the executor's request base URL. Honored
651+
// by the executor (`DatabricksConfig.base_url`) and used by the
652+
// deltagraph subprocess test in tests/rust/bin/ to redirect HTTP
653+
// at a wiremock URL. Production callers leave this unset — the
654+
// executor falls back to `https://{hostname}`.
655+
//
656+
// Security guardrail: a misconfigured override would otherwise send
657+
// the PAT in plaintext to whatever endpoint is named. We accept
658+
// either an `https://` URL (any host, real workspaces) or `http://`
659+
// restricted to loopback (wiremock-style tests). Anything else is
660+
// rejected up front, and any successful override produces a single
661+
// `log::warn!` so a stray production setting can't be silent.
662+
if let Ok(raw) = std::env::var("DATABRICKS_BASE_URL") {
663+
validate_databricks_base_url(&raw)?;
664+
log::warn!(
665+
"⚠ DATABRICKS_BASE_URL override in use ({raw}); the PAT will be sent there. \
666+
This env var is intended for tests against a local mock — unset it for production."
667+
);
668+
cfg.base_url = Some(raw);
669+
}
650670
Ok(cfg)
651671
}
672+
673+
/// Reject overrides that would silently leak the PAT to a non-localhost
674+
/// plaintext endpoint. Loopback HTTP is allowed (wiremock); arbitrary
675+
/// HTTP is not. Anything that fails to parse or uses a non-http(s)
676+
/// scheme is rejected with a clear message rather than being passed
677+
/// through.
678+
#[cfg(feature = "databricks")]
679+
fn validate_databricks_base_url(raw: &str) -> Result<(), String> {
680+
// Minimal parsing — we don't want to pull in the `url` crate just
681+
// for this check. Match `https://...` unconditionally; `http://`
682+
// only when the host segment is `localhost` or `127.0.0.1` (with
683+
// optional port). Anything else is rejected.
684+
if let Some(rest) = raw.strip_prefix("https://") {
685+
if rest.is_empty() {
686+
return Err("DATABRICKS_BASE_URL is empty after https://".to_string());
687+
}
688+
return Ok(());
689+
}
690+
if let Some(rest) = raw.strip_prefix("http://") {
691+
let host = rest.split(['/', ':']).next().unwrap_or("");
692+
if host == "localhost" || host == "127.0.0.1" {
693+
return Ok(());
694+
}
695+
return Err(format!(
696+
"DATABRICKS_BASE_URL rejected: `http://` is only allowed for loopback (localhost, \
697+
127.0.0.1) to avoid leaking the PAT in plaintext. For real workspaces use https:// \
698+
or unset the variable. Got host={host:?}"
699+
));
700+
}
701+
Err(format!(
702+
"DATABRICKS_BASE_URL rejected: must start with `https://` (or `http://localhost…` for \
703+
tests). Got: {raw:?}"
704+
))
705+
}
706+
707+
#[cfg(all(test, feature = "databricks"))]
708+
mod databricks_base_url_validation_tests {
709+
use super::validate_databricks_base_url;
710+
711+
#[test]
712+
fn accepts_https_for_real_workspaces() {
713+
assert!(validate_databricks_base_url("https://dbc-abc-def.cloud.databricks.com").is_ok());
714+
assert!(
715+
validate_databricks_base_url("https://dbc-abc-def.cloud.databricks.com/api/").is_ok()
716+
);
717+
}
718+
719+
#[test]
720+
fn accepts_http_for_loopback_tests() {
721+
// wiremock + mock server hosts — anything bound to loopback.
722+
assert!(validate_databricks_base_url("http://127.0.0.1:55555").is_ok());
723+
assert!(validate_databricks_base_url("http://localhost:55555/api/2.0/sql").is_ok());
724+
}
725+
726+
#[test]
727+
fn rejects_plaintext_http_to_external_hosts() {
728+
// The whole point of the validator: a typo'd
729+
// `DATABRICKS_BASE_URL=http://workspace.example.com` would
730+
// POST the PAT in plaintext. Block it up front.
731+
let err = validate_databricks_base_url("http://workspace.example.com").unwrap_err();
732+
assert!(err.contains("loopback"), "got: {err}");
733+
let err = validate_databricks_base_url("http://192.168.1.10:8080").unwrap_err();
734+
assert!(err.contains("loopback"), "got: {err}");
735+
}
736+
737+
#[test]
738+
fn rejects_unknown_schemes() {
739+
// ftp/gopher/no-scheme — anything that wouldn't reach the
740+
// executor's reqwest client cleanly should fail loudly here
741+
// rather than producing a confusing runtime error later.
742+
assert!(validate_databricks_base_url("workspace.example.com").is_err());
743+
assert!(validate_databricks_base_url("ftp://workspace.example.com").is_err());
744+
assert!(validate_databricks_base_url("").is_err());
745+
assert!(validate_databricks_base_url("https://").is_err());
746+
}
747+
}

0 commit comments

Comments
 (0)