pub mod client;
pub mod config;
pub mod error;
pub mod guard;
pub mod udtf;
pub mod value;
mod view;
use std::collections::hash_map::Entry;
use std::sync::Arc;
#[cfg(test)]
use std::sync::RwLock;
use std::time::Duration;
use datafusion::catalog::{
CatalogProvider, MemoryCatalogProvider, MemorySchemaProvider, SchemaProvider,
};
use datafusion::prelude::SessionContext;
use client::{AgeClient, GraphClient, QueryBounds};
use config::GraphConfig;
use error::GraphError;
use udtf::{GraphSourceHandle, GraphSourceHealth, GraphSources};
use view::GraphViewProvider;
use crate::sources::hierarchy::HierarchyLevel;
pub use udtf::register_graph_udtfs;
pub async fn register_graph_source(
sources: &GraphSources,
name: &str,
connection_string: &str,
config: &GraphConfig,
) -> Result<(), GraphError> {
config.validate(name, connection_string)?;
{
let map = sources.read().unwrap_or_else(|p| p.into_inner());
if map.contains_key(name) {
return Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: "a graph source with this name is already registered \
(the existing connection is unchanged)"
.to_string(),
});
}
}
let client = AgeClient::connect(
name,
connection_string,
&config.graph_name,
config.username_env.as_deref(),
config.password_env.as_deref(),
config.max_connections,
Duration::from_secs(config.query_timeout_seconds),
)
.await?;
let handle = Arc::new(GraphSourceHandle::new(
Arc::new(client),
QueryBounds {
timeout: Duration::from_secs(config.query_timeout_seconds),
max_rows: config.max_rows,
},
GraphSourceHealth::Healthy,
Arc::new(vec![]),
config.max_connections as usize,
));
let mut map = sources.write().unwrap_or_else(|p| p.into_inner());
match map.entry(name.to_string()) {
Entry::Occupied(_) => Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: "a graph source with this name is already registered \
(the existing connection is unchanged)"
.to_string(),
}),
Entry::Vacant(slot) => {
slot.insert(handle);
Ok(())
}
}
}
pub async fn register_graph_tables(
session_ctx: &mut SessionContext,
sources: &GraphSources,
name: &str,
connection_string: &str,
config: Option<&GraphConfig>,
read_write: bool,
hierarchy_level: HierarchyLevel,
) -> Result<(), GraphError> {
register_graph_tables_impl(
session_ctx,
sources,
name,
connection_string,
config,
read_write,
hierarchy_level,
true,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn register_graph_tables_lazy(
session_ctx: &mut SessionContext,
sources: &GraphSources,
name: &str,
connection_string: &str,
config: Option<&GraphConfig>,
read_write: bool,
hierarchy_level: HierarchyLevel,
) -> Result<(), GraphError> {
register_graph_tables_impl(
session_ctx,
sources,
name,
connection_string,
config,
read_write,
hierarchy_level,
false,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn register_graph_tables_impl(
session_ctx: &mut SessionContext,
sources: &GraphSources,
name: &str,
connection_string: &str,
config: Option<&GraphConfig>,
read_write: bool,
hierarchy_level: HierarchyLevel,
eager: bool,
) -> Result<(), GraphError> {
if read_write {
return Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: "read-only milestone; writes are rejected by the backend's \
READ ONLY transaction"
.to_string(),
});
}
if hierarchy_level != HierarchyLevel::Catalog {
return Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: "type: graph requires hierarchy: catalog (views register as \
<name>.main.<view> catalog tables)"
.to_string(),
});
}
let Some(config) = config else {
return Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: "type: graph requires a graph: block".to_string(),
});
};
config.validate(name, connection_string)?;
{
let map = sources.read().unwrap_or_else(|p| p.into_inner());
if map.contains_key(name) {
return Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: "a graph source with this name is already registered \
(the existing connection is unchanged)"
.to_string(),
});
}
}
if !config.views.is_empty() && session_ctx.catalog(name).is_some() {
return Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: format!(
"a catalog named '{name}' already exists in this session — the graph \
source's views would replace it (register_catalog replaces \
unconditionally); rename the source"
),
});
}
let timeout = Duration::from_secs(config.query_timeout_seconds);
let bounds = QueryBounds {
timeout,
max_rows: config.max_rows,
};
let contracts: Arc<Vec<view::ViewContract>> = Arc::new(
config
.views
.iter()
.map(|view| {
Ok(view::ViewContract {
name: view.name.clone(),
cypher: view.cypher.clone(),
columns: view.declared_columns()?,
})
})
.collect::<Result<Vec<_>, GraphError>>()?,
);
let (client, health): (Arc<dyn GraphClient>, GraphSourceHealth) = if !eager {
let client = AgeClient::connect_degraded(
name,
connection_string,
&config.graph_name,
config.username_env.as_deref(),
config.password_env.as_deref(),
config.max_connections,
timeout,
)?;
(
Arc::new(client) as Arc<dyn GraphClient>,
GraphSourceHealth::Degraded(
"registered without a startup probe (lazy registration; the first \
query or scan validates against the live backend)"
.to_string(),
),
)
} else {
match AgeClient::connect(
name,
connection_string,
&config.graph_name,
config.username_env.as_deref(),
config.password_env.as_deref(),
config.max_connections,
timeout,
)
.await
{
Ok(client) => {
let client: Arc<dyn GraphClient> = Arc::new(client);
match view::validate_views_concurrently(
&client,
bounds,
contracts.as_ref().clone(),
config.max_connections as usize,
)
.await
{
Ok(()) => (client, GraphSourceHealth::Healthy),
Err(e) if is_availability_artifact(&e) => {
tracing::warn!(
source = name,
error = %e,
"graph view validation hit an availability artifact at \
registration; registering degraded (first scan retries)"
);
(client, GraphSourceHealth::Degraded(e.to_string()))
}
Err(e) => return Err(e),
}
}
Err(e) => {
if !matches!(e, GraphError::Unavailable { .. }) {
return Err(e);
}
tracing::warn!(
source = name,
error = %e,
"graph backend unreachable at registration; registering degraded \
(first scan retries the validation)"
);
let client = AgeClient::connect_degraded(
name,
connection_string,
&config.graph_name,
config.username_env.as_deref(),
config.password_env.as_deref(),
config.max_connections,
timeout,
)?;
(
Arc::new(client) as Arc<dyn GraphClient>,
GraphSourceHealth::Degraded(e.to_string()),
)
}
}
};
let handle = Arc::new(GraphSourceHandle::new(
client,
bounds,
health,
Arc::clone(&contracts),
config.max_connections as usize,
));
{
let mut map = sources.write().unwrap_or_else(|p| p.into_inner());
match map.entry(name.to_string()) {
Entry::Occupied(_) => {
return Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: "a graph source with this name is already registered \
(the existing connection is unchanged)"
.to_string(),
});
}
Entry::Vacant(slot) => {
slot.insert(Arc::clone(&handle));
}
}
}
if !contracts.is_empty() {
let catalog = Arc::new(MemoryCatalogProvider::new());
let schema_provider = Arc::new(MemorySchemaProvider::new());
let build = (|| {
for contract in contracts.iter() {
let provider = GraphViewProvider::new(
Arc::clone(&handle),
contract.name.clone(),
contract.cypher.clone(),
contract.columns.clone(),
);
schema_provider
.register_table(contract.name.clone(), Arc::new(provider))
.map_err(|e| GraphError::InvalidConfig {
name: name.to_string(),
reason: format!("failed to register view '{}': {e}", contract.name),
})?;
}
catalog
.register_schema("main", schema_provider)
.map_err(|e| GraphError::InvalidConfig {
name: name.to_string(),
reason: format!("failed to register the 'main' schema: {e}"),
})?;
Ok::<_, GraphError>(catalog)
})();
match build {
Ok(catalog) => {
if session_ctx.catalog(name).is_some() {
sources
.write()
.unwrap_or_else(|p| p.into_inner())
.remove(name);
return Err(GraphError::InvalidConfig {
name: name.to_string(),
reason: format!(
"a catalog named '{name}' was registered in this session \
while the graph source was validating — the graph \
source's views would replace it (register_catalog \
replaces unconditionally); rename the source"
),
});
}
session_ctx.register_catalog(name, catalog);
Ok(())
}
Err(e) => {
sources
.write()
.unwrap_or_else(|p| p.into_inner())
.remove(name);
Err(e)
}
}
} else {
Ok(())
}
}
pub(crate) fn is_availability_artifact(e: &GraphError) -> bool {
let underlying = match e {
GraphError::ViewValidationFailed { source, .. } => source.as_ref(),
other => other,
};
matches!(
underlying,
GraphError::Unavailable { .. }
| GraphError::ConnectionAcquireTimeout { .. }
| GraphError::BackendSilent { .. }
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use async_trait::async_trait;
use futures::StreamExt;
use futures::stream::{self, BoxStream};
use serde_json::Value;
use client::GraphRow;
#[derive(Debug)]
struct StubClient;
#[async_trait]
impl GraphClient for StubClient {
async fn execute(
&self,
_cypher: &str,
_params: &Value,
_arity: usize,
_bounds: QueryBounds,
_limit: Option<usize>,
) -> Result<BoxStream<'static, Result<GraphRow, GraphError>>, GraphError> {
Ok(stream::iter(vec![]).boxed())
}
async fn labels(
&self,
_bounds: QueryBounds,
_limit: Option<usize>,
) -> Result<Vec<(String, String)>, GraphError> {
Ok(vec![])
}
}
fn sources() -> GraphSources {
Arc::new(RwLock::new(HashMap::new()))
}
fn config_with_views() -> GraphConfig {
serde_yaml::from_str(
r#"
backend: age
graph_name: knowledge
query_timeout_seconds: 1
views:
- name: user_posts
cypher: MATCH (u:User) RETURN u.name AS user_name
schema:
- name: user_name
type: string
"#,
)
.expect("parses")
}
const DEAD_URL: &str = "postgres://127.0.0.1:1/none";
#[tokio::test]
async fn invariants_are_rejected_before_any_network() {
let config = config_with_views();
let mut ctx = SessionContext::new();
let err = register_graph_tables(
&mut ctx,
&sources(),
"kg",
DEAD_URL,
Some(&config),
true,
HierarchyLevel::Catalog,
)
.await
.unwrap_err();
assert!(err.to_string().contains("read-only"), "{err}");
let mut ctx = SessionContext::new();
let err = register_graph_tables(
&mut ctx,
&sources(),
"kg",
DEAD_URL,
Some(&config),
false,
HierarchyLevel::Table,
)
.await
.unwrap_err();
assert!(err.to_string().contains("hierarchy: catalog"), "{err}");
let mut ctx = SessionContext::new();
let err = register_graph_tables(
&mut ctx,
&sources(),
"kg",
DEAD_URL,
None,
false,
HierarchyLevel::Catalog,
)
.await
.unwrap_err();
assert!(err.to_string().contains("graph: block"), "{err}");
let bad: GraphConfig =
serde_yaml::from_str("backend: neo4j\ngraph_name: g\n").expect("parses");
let mut ctx = SessionContext::new();
let err = register_graph_tables(
&mut ctx,
&sources(),
"kg",
DEAD_URL,
Some(&bad),
false,
HierarchyLevel::Catalog,
)
.await
.unwrap_err();
assert!(err.to_string().contains("not supported"), "{err}");
let existing = sources();
existing.write().unwrap_or_else(|p| p.into_inner()).insert(
"kg".to_string(),
Arc::new(GraphSourceHandle::new(
Arc::new(StubClient),
QueryBounds {
timeout: Duration::from_secs(1),
max_rows: 10,
},
GraphSourceHealth::Healthy,
Arc::new(vec![]),
config.max_connections as usize,
)),
);
let mut ctx = SessionContext::new();
let err = register_graph_tables(
&mut ctx,
&existing,
"kg",
DEAD_URL,
Some(&config),
false,
HierarchyLevel::Catalog,
)
.await
.unwrap_err();
assert!(err.to_string().contains("already registered"), "{err}");
}
#[tokio::test]
async fn an_unreachable_backend_registers_degraded_and_the_first_scan_fails_loudly() {
let sources = sources();
let mut ctx = SessionContext::new();
register_graph_tables(
&mut ctx,
&sources,
"kg",
DEAD_URL,
Some(&config_with_views()),
false,
HierarchyLevel::Catalog,
)
.await
.expect("an unreachable backend still registers (degraded)");
let handle = {
let map = sources.read().unwrap_or_else(|p| p.into_inner());
Arc::clone(map.get("kg").expect("registered"))
};
{
let health = handle.health.read().unwrap_or_else(|p| p.into_inner());
match &*health {
GraphSourceHealth::Degraded(reason) => {
assert!(!reason.is_empty(), "the cause is carried");
}
GraphSourceHealth::Healthy => panic!("must register degraded"),
}
}
let df = ctx
.sql("SELECT user_name FROM kg.main.user_posts")
.await
.expect("the declared schema plans");
assert_eq!(df.schema().field(0).name(), "user_name");
let err = df.collect().await.expect_err("the backend is still gone");
let msg = err.to_string();
assert!(msg.contains("user_posts"), "the view is named: {msg}");
assert!(msg.contains("DEGRADED"), "{msg}");
assert!(
!handle
.health
.read()
.unwrap_or_else(|p| p.into_inner())
.is_healthy()
);
}
#[tokio::test]
async fn a_blackholed_backend_registers_degraded_within_the_bound() {
let config: GraphConfig =
serde_yaml::from_str("backend: age\ngraph_name: knowledge\nquery_timeout_seconds: 1\n")
.expect("parses");
let sources = sources();
let mut ctx = SessionContext::new();
let started = std::time::Instant::now();
register_graph_tables(
&mut ctx,
&sources,
"kg",
"postgres://10.255.255.1:5432/none",
Some(&config),
false,
HierarchyLevel::Catalog,
)
.await
.expect("a blackholed backend still registers (degraded)");
assert!(
started.elapsed() < Duration::from_secs(10),
"startup is not held hostage"
);
let handle = Arc::clone(
sources
.read()
.unwrap_or_else(|p| p.into_inner())
.get("kg")
.expect("registered"),
);
assert!(
!handle
.health
.read()
.unwrap_or_else(|p| p.into_inner())
.is_healthy(),
"the blackholed source is degraded"
);
}
#[test]
fn availability_artifacts_degrade_and_contract_failures_refuse() {
let acquire = GraphError::ConnectionAcquireTimeout { seconds: 30 };
let unavailable = GraphError::Unavailable {
source_name: "kg".to_string(),
reason: "connection refused".to_string(),
};
let silent = GraphError::BackendSilent { seconds: 35 };
let timeout = GraphError::Timeout { seconds: 30 };
let mismatch = GraphError::TypeMismatch {
column: "age".to_string(),
row: 0,
expected: "int",
found: "string",
};
let arity = GraphError::RowArityMismatch {
row: 0,
expected: 2,
found: 1,
};
let wrap = |e: GraphError| GraphError::ViewValidationFailed {
view: "people".to_string(),
source: Box::new(e),
};
for e in [acquire, unavailable, silent] {
assert!(is_availability_artifact(&e), "{e}");
assert!(is_availability_artifact(&wrap(e)), "wrapped");
}
for e in [timeout, mismatch, arity] {
assert!(!is_availability_artifact(&e), "{e}");
assert!(!is_availability_artifact(&wrap(e)), "wrapped");
}
}
#[tokio::test]
async fn an_existing_custom_catalog_survives_a_name_collision() {
let mut ctx = SessionContext::new();
let custom = Arc::new(MemoryCatalogProvider::new());
let custom_schema = Arc::new(MemorySchemaProvider::new());
custom
.register_schema("main", custom_schema)
.expect("schema registers");
ctx.register_catalog("kg", Arc::clone(&custom) as Arc<dyn CatalogProvider>);
let err = register_graph_tables(
&mut ctx,
&sources(),
"kg",
DEAD_URL,
Some(&config_with_views()),
false,
HierarchyLevel::Catalog,
)
.await
.expect_err("a name collision with an existing catalog is refused");
let msg = err.to_string();
assert!(msg.contains("already exists"), "{msg}");
let survived = ctx.catalog("kg").expect("catalog still present");
assert!(
survived.schema("main").is_some(),
"the embedder's catalog kept its schema"
);
}
#[tokio::test]
async fn a_reserved_catalog_name_is_rejected_before_any_network() {
let mut ctx = SessionContext::new();
let err = register_graph_tables(
&mut ctx,
&sources(),
"datafusion",
DEAD_URL,
Some(&config_with_views()),
false,
HierarchyLevel::Catalog,
)
.await
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("already exists"), "{msg}");
assert!(ctx.catalog("datafusion").is_some(), "built-in untouched");
}
#[tokio::test]
async fn lazy_registration_performs_no_dial_and_registers_degraded() {
let sources = sources();
let mut ctx = SessionContext::new();
let started = std::time::Instant::now();
register_graph_tables_lazy(
&mut ctx,
&sources,
"kg",
"postgres://10.255.255.1:5432/none",
Some(&config_with_views()),
false,
HierarchyLevel::Catalog,
)
.await
.expect("lazy registration never dials");
assert!(
started.elapsed() < Duration::from_secs(1),
"no preflight was paid"
);
let df = ctx
.sql("SELECT user_name FROM kg.main.user_posts")
.await
.expect("the declared schema plans");
assert_eq!(df.schema().field(0).name(), "user_name");
let handle = Arc::clone(
sources
.read()
.unwrap_or_else(|p| p.into_inner())
.get("kg")
.expect("registered"),
);
{
let health = handle.health.read().unwrap_or_else(|p| p.into_inner());
match &*health {
GraphSourceHealth::Degraded(reason) => {
assert!(reason.contains("without a startup probe"), "{reason}");
}
GraphSourceHealth::Healthy => panic!("lazy registration must not claim health"),
}
}
let bad: GraphConfig =
serde_yaml::from_str("backend: age\ngraph_name: g\nusername_env: BAD NAME\n")
.expect("parses");
let mut ctx = SessionContext::new();
let err = register_graph_tables_lazy(
&mut ctx,
&sources,
"kg2",
DEAD_URL,
Some(&bad),
false,
HierarchyLevel::Catalog,
)
.await
.unwrap_err();
assert!(
err.to_string().contains("environment variable NAME"),
"{err}"
);
}
#[tokio::test]
async fn a_duplicate_registration_leaves_catalog_and_handle_unsplit() {
let sources = sources();
let mut ctx = SessionContext::new();
register_graph_tables(
&mut ctx,
&sources,
"kg",
DEAD_URL,
Some(&config_with_views()),
false,
HierarchyLevel::Catalog,
)
.await
.expect("degraded registration");
let original_handle = Arc::clone(
sources
.read()
.unwrap_or_else(|p| p.into_inner())
.get("kg")
.expect("registered"),
);
let original_catalog = ctx.catalog("kg").expect("catalog registered");
let err = register_graph_tables(
&mut ctx,
&sources,
"kg",
DEAD_URL,
Some(&config_with_views()),
false,
HierarchyLevel::Catalog,
)
.await
.unwrap_err();
assert!(err.to_string().contains("already registered"), "{err}");
let handle = Arc::clone(
sources
.read()
.unwrap_or_else(|p| p.into_inner())
.get("kg")
.expect("still registered"),
);
assert!(
Arc::ptr_eq(&original_handle, &handle),
"the original handle survives"
);
let catalog = ctx.catalog("kg").expect("catalog still registered");
assert!(
Arc::ptr_eq(&original_catalog, &catalog),
"the original catalog was never replaced"
);
}
}