use std::sync::Arc;
use async_trait::async_trait;
use graph_storage_sdk::models::{
Direction, EdgeRef, EngineCapabilities, GraphRevision, TruncationReason, TypeIdSet,
};
use graph_storage_sdk::plugin_api::{
EngineCursor, ExpandRequest, ExpandResponse, GraphEngineError, GraphEngineV1, HopBackend,
PathResponse, PatternRequest, PatternResponse, ShortestPathRequest, StoreCtx,
};
use sea_orm::sea_query::{Alias, Expr, ExprTrait as _};
use sea_orm::{ColumnTrait, Condition, EntityTrait, FromQueryResult};
use toolkit_db::secure::{DBRunner, ScopeError, SecureEntityExt};
use toolkit_security::AccessScope;
use tracing::warn;
use crate::config::HopStrategy;
use crate::infra::logged;
use crate::infra::projections::{
EdgeHop, EndpointPair, NodeIdent, TypeId, TypeName, edge_hop_columns, endpoint_pair_columns,
node_ident_columns, type_id_columns, type_name_columns,
};
use crate::infra::storage::entity::{edge, gts_type, node};
use crate::infra::storage::graph::KnowledgeGraph;
use crate::infra::store::PgGraphStore;
pub async fn probe_pgq(db: &toolkit_db::secure::Db) -> bool {
let Ok(conn) = db.conn() else {
warn!("cannot probe SQL/PGQ: no connection; assuming it is unavailable");
return false;
};
let scope = AccessScope::for_tenant(uuid::Uuid::nil());
let probe: Result<Vec<Reached>, _> = node::Entity::find()
.secure()
.scope_with(&scope)
.with_graph::<KnowledgeGraph>()
.match_path(|p| {
p.vertex::<node::Entity>("a")
.edge_to::<edge::Entity>("e")
.to::<node::Entity>("b")
})
.column("b", "id", "neighbour")
.limit(1)
.all_as(&conn)
.await;
match probe {
Ok(_) => true,
Err(error) => {
warn!(
%error,
"this server does not serve SQL/PGQ over the declared property graph; \
every hop will use the two-query backend"
);
false
}
}
}
const NO_CONNECTION: &str = "the database gave no connection; the reason is in the gear's log";
const PATTERN_DID_NOT_EXECUTE: &str =
"the pattern statement did not execute; the reason is in the gear's log";
enum PatternOutcome {
Answered(ExpandResponse),
Unavailable(String),
}
pub struct PgGraphEngine {
store: Arc<PgGraphStore>,
}
impl PgGraphEngine {
#[must_use]
pub fn new(store: Arc<PgGraphStore>) -> Self {
Self { store }
}
fn effective_backend(&self) -> Result<HopBackend, GraphEngineError> {
let available = self.store.pgq_available();
match self.store.config().traversal_hop {
HopStrategy::Auto | HopStrategy::Pgq if available => Ok(HopBackend::Pattern),
HopStrategy::Auto | HopStrategy::TwoQuery => Ok(HopBackend::TwoQuery),
HopStrategy::Pgq => Err(GraphEngineError::Unavailable {
reason: "traversal_hop is `pgq` and this server does not provide SQL/PGQ; \
set it to `auto` or `two_query`, or run on PostgreSQL 19 with the \
property-graph migration applied"
.to_owned(),
}),
}
}
}
fn engine_error(error: graph_storage_sdk::plugin_api::GraphStoreError) -> GraphEngineError {
use graph_storage_sdk::plugin_api::GraphStoreError as E;
match error {
E::ScopeUnservable { reason } => GraphEngineError::ScopeNotEnforceable { reason },
E::Unavailable { reason } => GraphEngineError::Unavailable { reason },
E::Deadline => GraphEngineError::Deadline,
E::Cancelled => GraphEngineError::Cancelled,
other => GraphEngineError::Internal(other.to_string()),
}
}
fn scope_error(error: ScopeError) -> GraphEngineError {
match error {
ScopeError::UnresolvedScopeProperty { element, property } => {
GraphEngineError::ScopeNotEnforceable {
reason: format!(
"scope does not resolve on element `{element}` property `{property}`"
),
}
}
ScopeError::GraphSyntax(inner) => {
GraphEngineError::Internal(format!("graph pattern is malformed: {inner}"))
}
other => GraphEngineError::Internal(other.to_string()),
}
}
#[async_trait]
impl GraphEngineV1 for PgGraphEngine {
fn capabilities(&self) -> EngineCapabilities {
EngineCapabilities {
shortest_path: false,
match_pattern: false,
}
}
async fn cursor(&self, ctx: &StoreCtx<'_>) -> Result<EngineCursor, GraphEngineError> {
let revision: GraphRevision = crate::infra::store::reads::revision(&self.store, ctx)
.await
.map_err(engine_error)?;
Ok(EngineCursor { revision })
}
async fn expand(
&self,
ctx: &StoreCtx<'_>,
req: ExpandRequest,
) -> Result<ExpandResponse, GraphEngineError> {
if req.labels.is_some() {
return Err(GraphEngineError::Unsupported {
what: "per-hop label filters",
});
}
let would_serve = self.effective_backend()?;
if req.frontier.is_empty() {
return Ok(ExpandResponse {
reached: Vec::new(),
degrees: Vec::new(),
edges: Vec::new(),
truncated: None,
served_by: would_serve,
});
}
if req.frontier.len() as u64 > u64::from(req.budget.max_frontier) {
return Ok(ExpandResponse {
reached: Vec::new(),
degrees: Vec::new(),
edges: Vec::new(),
truncated: Some(TruncationReason::FrontierCap),
served_by: would_serve,
});
}
match self.effective_backend()? {
HopBackend::Pattern => match expand_pgq(&self.store, ctx, &req).await {
Ok(PatternOutcome::Answered(response)) => Ok(response),
Ok(PatternOutcome::Unavailable(reason)) => {
self.store.pgq_lost();
match self.store.config().traversal_hop {
HopStrategy::Pgq => Err(GraphEngineError::Unavailable {
reason: format!(
"traversal_hop is `pgq` and the declared property graph stopped \
answering a pattern: {reason}; readiness reports it, and no \
other backend is substituted for one configured by name"
),
}),
HopStrategy::Auto | HopStrategy::TwoQuery => {
warn!(
reason = %reason,
"graph pattern did not execute; serving the two-query hop, and \
readiness reports the loss from now on"
);
expand_two_query(&self.store, ctx, &req).await
}
}
}
Err(GraphEngineError::ScopeNotEnforceable { reason }) => {
warn!(reason = %reason, "graph pattern refused this scope; serving the two-query hop");
expand_two_query(&self.store, ctx, &req).await
}
Err(other) => Err(other),
},
HopBackend::TwoQuery => expand_two_query(&self.store, ctx, &req).await,
}
}
async fn shortest_path(
&self,
_ctx: &StoreCtx<'_>,
_req: ShortestPathRequest,
) -> Result<PathResponse, GraphEngineError> {
Err(GraphEngineError::Unsupported {
what: "shortest_path",
})
}
async fn match_pattern(
&self,
_ctx: &StoreCtx<'_>,
_req: PatternRequest,
) -> Result<PatternResponse, GraphEngineError> {
Err(GraphEngineError::Unsupported {
what: "match_pattern",
})
}
}
#[derive(Debug, FromQueryResult)]
struct Reached {
neighbour: i64,
}
async fn edge_type_ids(
ctx: &StoreCtx<'_>,
runner: &impl DBRunner,
types: Option<&TypeIdSet>,
) -> Result<Option<Vec<i32>>, GraphEngineError> {
let Some(set) = types else {
return Ok(None);
};
let names: Vec<String> = set.0.iter().cloned().collect();
let rows = gts_type::Entity::find()
.secure()
.scope_with(ctx.scope)
.filter(Condition::all().add(gts_type::Column::GtsTypeId.is_in(names)))
.project_all(runner, |query| {
type_id_columns(query).into_model::<TypeId>()
})
.await
.map_err(scope_error)?;
Ok(Some(rows.into_iter().map(|r| r.id).collect()))
}
async fn expand_pgq(
store: &PgGraphStore,
ctx: &StoreCtx<'_>,
req: &ExpandRequest,
) -> Result<PatternOutcome, GraphEngineError> {
let conn = store.db().conn().map_err(|error| {
warn!(error = %logged(&error), "the database gave the hop no connection");
GraphEngineError::Unavailable {
reason: NO_CONNECTION.to_owned(),
}
})?;
let anchor_correlation = |variable: &'static str| {
Condition::all()
.add(
Expr::col((Alias::new(variable), Alias::new("tenant_id")))
.eq(Expr::col((Alias::new("node"), Alias::new("tenant_id")))),
)
.add(
Expr::col((Alias::new(variable), Alias::new("id")))
.eq(Expr::col((Alias::new("node"), Alias::new("id")))),
)
};
let mut reached: Vec<i64> = Vec::new();
for direction in directions_of(req.direction) {
let rows: Result<Vec<Reached>, ScopeError> = {
let select = node::Entity::find()
.secure()
.scope_with(ctx.scope)
.with_graph::<KnowledgeGraph>();
let select = match direction {
Direction::Outgoing => select.match_path(|p| {
p.vertex::<node::Entity>("a")
.where_(anchor_correlation("a"))
.correlate_with_anchor()
.edge_to::<edge::Entity>("e")
.to::<node::Entity>("b")
}),
_ => select.match_path(|p| {
p.vertex::<node::Entity>("a")
.where_(anchor_correlation("a"))
.correlate_with_anchor()
.edge_from::<edge::Entity>("e")
.to::<node::Entity>("b")
}),
};
select
.column("b", "id", "neighbour")
.filter(Condition::all().add(node::Column::Id.is_in(req.frontier.clone())))
.filter(Condition::all().add(node::Column::DeletedAt.is_null()))
.limit(u64::from(req.budget.max_frontier) + 1)
.all_as(&conn)
.await
};
let rows = match rows {
Ok(rows) => rows,
Err(
error @ (ScopeError::UnresolvedScopeProperty { .. } | ScopeError::GraphSyntax(_)),
) => {
return Err(scope_error(error));
}
Err(error) => {
warn!(error = %logged(&error), "the pattern statement did not execute");
return Ok(PatternOutcome::Unavailable(
PATTERN_DID_NOT_EXECUTE.to_owned(),
));
}
};
reached.extend(rows.into_iter().map(|r| r.neighbour));
}
reached.sort_unstable();
reached.dedup();
let incidence = live_edges(ctx, &conn, req, Some(&reached)).await?;
Ok(PatternOutcome::Answered(ExpandResponse {
truncated: hop_truncation(req, &incidence),
reached: incidence.reached,
degrees: incidence.degrees,
edges: incidence.edges,
served_by: HopBackend::Pattern,
}))
}
fn directions_of(direction: Direction) -> Vec<Direction> {
match direction {
Direction::Outgoing => vec![Direction::Outgoing],
Direction::Incoming => vec![Direction::Incoming],
Direction::Either => vec![Direction::Outgoing, Direction::Incoming],
}
}
struct Incidence {
edges: Vec<EdgeRef>,
reached: Vec<i64>,
degrees: Vec<u32>,
over_edge_budget: bool,
}
async fn live_edges(
ctx: &StoreCtx<'_>,
runner: &impl DBRunner,
req: &ExpandRequest,
candidates: Option<&[i64]>,
) -> Result<Incidence, GraphEngineError> {
let type_ids = edge_type_ids(ctx, runner, req.edge_types.as_ref()).await?;
let mut incidence = Condition::any();
match req.direction {
Direction::Outgoing => {
incidence = incidence.add(edge::Column::SrcNodeId.is_in(req.frontier.clone()));
}
Direction::Incoming => {
incidence = incidence.add(edge::Column::DstNodeId.is_in(req.frontier.clone()));
}
Direction::Either => {
incidence = incidence
.add(edge::Column::SrcNodeId.is_in(req.frontier.clone()))
.add(edge::Column::DstNodeId.is_in(req.frontier.clone()));
}
}
let mut select = edge::Entity::find()
.secure()
.scope_with(ctx.scope)
.filter(incidence)
.filter(Condition::all().add(edge::Column::DeletedAt.is_null()));
if let Some(ids) = &type_ids {
select =
select.filter(Condition::all().add(edge::Column::GtsEdgeTypeId.is_in(ids.clone())));
}
if let Some(candidates) = candidates {
if candidates.is_empty() {
return Ok(Incidence {
edges: Vec::new(),
reached: Vec::new(),
degrees: Vec::new(),
over_edge_budget: false,
});
}
let far = Condition::any()
.add(edge::Column::DstNodeId.is_in(candidates.to_vec()))
.add(edge::Column::SrcNodeId.is_in(candidates.to_vec()));
select = select.filter(far);
}
let mut rows: Vec<EdgeHop> = select
.limit(req.budget.max_edges_scanned.saturating_add(1))
.project_all(runner, |query| {
edge_hop_columns(query).into_model::<EdgeHop>()
})
.await
.map_err(scope_error)?;
let over_edge_budget = rows.len() as u64 > req.budget.max_edges_scanned;
rows.truncate(usize::try_from(req.budget.max_edges_scanned).unwrap_or(usize::MAX));
let rows_scanned = rows.len();
let mut endpoint_ids: Vec<i64> = rows
.iter()
.flat_map(|e| [e.src_node_id, e.dst_node_id])
.collect();
endpoint_ids.sort_unstable();
endpoint_ids.dedup();
let visible: Vec<NodeIdent> = node::Entity::find()
.secure()
.scope_with(ctx.scope)
.filter(Condition::all().add(node::Column::Id.is_in(endpoint_ids)))
.filter(Condition::all().add(node::Column::DeletedAt.is_null()))
.project_all(runner, |query| {
node_ident_columns(query).into_model::<NodeIdent>()
})
.await
.map_err(scope_error)?;
let keys: std::collections::BTreeMap<i64, String> =
visible.into_iter().map(|n| (n.id, n.node_key)).collect();
let mut type_names: Vec<i32> = rows.iter().map(|e| e.gts_edge_type_id).collect();
type_names.sort_unstable();
type_names.dedup();
let names = gts_type::Entity::find()
.secure()
.scope_with(ctx.scope)
.filter(Condition::all().add(gts_type::Column::Id.is_in(type_names)))
.project_all(runner, |query| {
type_name_columns(query).into_model::<TypeName>()
})
.await
.map_err(scope_error)?
.into_iter()
.map(|t| (t.id, t.gts_type_id))
.collect::<std::collections::BTreeMap<_, _>>();
let frontier: std::collections::BTreeSet<i64> = req.frontier.iter().copied().collect();
let mut reached: Vec<i64> = Vec::new();
let mut edges = Vec::new();
for e in &rows {
let (Some(src), Some(dst)) = (keys.get(&e.src_node_id), keys.get(&e.dst_node_id)) else {
continue;
};
if frontier.contains(&e.src_node_id) {
reached.push(e.dst_node_id);
}
if frontier.contains(&e.dst_node_id) {
reached.push(e.src_node_id);
}
edges.push(EdgeRef {
edge_key: e.edge_key.clone(),
edge_type_id: names.get(&e.gts_edge_type_id).cloned().unwrap_or_default(),
src: src.clone(),
dst: dst.clone(),
});
}
reached.sort_unstable();
reached.dedup();
let remaining = req
.budget
.max_edges_scanned
.saturating_sub(rows_scanned as u64);
let (degrees, degree_scan_over_budget) = if req.with_degrees && !reached.is_empty() {
degrees_of(ctx, runner, &reached, remaining).await?
} else {
(Vec::new(), false)
};
Ok(Incidence {
edges,
reached,
degrees,
over_edge_budget: over_edge_budget || degree_scan_over_budget,
})
}
async fn degrees_of(
ctx: &StoreCtx<'_>,
runner: &impl DBRunner,
reached: &[i64],
budget: u64,
) -> Result<(Vec<u32>, bool), GraphEngineError> {
if budget == 0 {
return Ok((vec![0; reached.len()], true));
}
let incidence = Condition::any()
.add(edge::Column::SrcNodeId.is_in(reached.to_vec()))
.add(edge::Column::DstNodeId.is_in(reached.to_vec()));
let mut rows = edge::Entity::find()
.secure()
.scope_with(ctx.scope)
.filter(incidence)
.filter(Condition::all().add(edge::Column::DeletedAt.is_null()))
.limit(budget.saturating_add(1))
.project_all(runner, |query| {
endpoint_pair_columns(query).into_model::<EndpointPair>()
})
.await
.map_err(scope_error)?;
let over_budget = rows.len() as u64 > budget;
rows.truncate(usize::try_from(budget).unwrap_or(usize::MAX));
let mut incident: std::collections::BTreeMap<i64, u32> = std::collections::BTreeMap::new();
for row in &rows {
for id in [row.src_node_id, row.dst_node_id] {
*incident.entry(id).or_default() += 1;
}
}
let degrees = reached
.iter()
.map(|id| incident.get(id).copied().unwrap_or(0))
.collect();
Ok((degrees, over_budget))
}
async fn expand_two_query(
store: &PgGraphStore,
ctx: &StoreCtx<'_>,
req: &ExpandRequest,
) -> Result<ExpandResponse, GraphEngineError> {
let conn = store.db().conn().map_err(|error| {
warn!(error = %logged(&error), "the database gave the hop no connection");
GraphEngineError::Unavailable {
reason: NO_CONNECTION.to_owned(),
}
})?;
let incidence = live_edges(ctx, &conn, req, None).await?;
Ok(ExpandResponse {
truncated: hop_truncation(req, &incidence),
reached: incidence.reached,
degrees: incidence.degrees,
edges: incidence.edges,
served_by: HopBackend::TwoQuery,
})
}
fn hop_truncation(req: &ExpandRequest, incidence: &Incidence) -> Option<TruncationReason> {
if incidence.over_edge_budget {
return Some(TruncationReason::EdgeScanCap);
}
(incidence.reached.len() as u64 > u64::from(req.budget.max_frontier))
.then_some(TruncationReason::FrontierCap)
}