#![allow(
clippy::result_large_err,
reason = "the typed graph cause stays unboxed and allocation-free, as in GraphQueryError"
)]
use crate::epoch::EmbeddingTower;
use crate::lifecycle::durability::{CommitTier, DurabilityMode};
use crate::lifecycle::native_graph::NativeGraphError;
use crate::lifecycle::{AccessMode, OpenOptions, QueryControl, Store, StoreErrorKind};
use crate::property_graph::query::completed::{
GraphQueryCause, GraphQueryError, GraphQueryErrorKind, native_graph_error_kind,
};
use crate::property_graph::query::plan::PlanNodeId;
use crate::property_graph::query::runtime::WorkCounters;
use crate::property_graph::resources::GraphResources;
use crate::property_graph::staging::{
ItemReceipt, StageError, StructuredOperation, StructuredWrite,
};
use crate::property_graph::{BatchDisposition, EntityId, GraphGeneration, NodeId};
use std::path::{Path, PathBuf};
#[cfg(any(test, feature = "test-seams"))]
pub(crate) mod commit_recovery_test_support;
mod maintenance;
pub use maintenance::{GraphMaintenancePolicy, GraphMaintenanceReport};
mod query;
pub use query::{GraphPlanBacking, GraphQueryPlan};
impl Store {
#[doc(hidden)]
pub fn create_graph_with_relationship_types(
path: impl AsRef<Path>,
options: OpenOptions,
document: Option<EmbeddingTower>,
rules: &[crate::property_graph::catalog::RelationshipRule<'_>],
) -> Result<Self, GraphStoreError> {
refuse_unsupported_platform()?;
let path = path.as_ref();
refuse_legacy_directory(path)?;
let rules =
crate::property_graph::catalog::RelationshipRules::new(rules).map_err(|error| {
GraphStoreError::graph(NativeGraphError::Stage(StageError::Catalog(error)))
})?;
let store = Store::create_native_graph_with_relationship_types(
path,
graph_options(options, AccessMode::ReadWrite),
document,
rules,
)?;
Ok(store)
}
#[doc(hidden)]
pub fn create_graph(
path: impl AsRef<Path>,
options: OpenOptions,
document: Option<EmbeddingTower>,
) -> Result<Self, GraphStoreError> {
refuse_unsupported_platform()?;
let path = path.as_ref();
refuse_legacy_directory(path)?;
let store = Store::create_native_graph(
path,
graph_options(options, AccessMode::ReadWrite),
document,
)?;
Ok(store)
}
#[doc(hidden)]
pub fn open_graph(
path: impl AsRef<Path>,
options: OpenOptions,
document: Option<EmbeddingTower>,
) -> Result<Self, GraphStoreError> {
Self::open_graph_as(path.as_ref(), options, document, AccessMode::ReadWrite)
}
#[doc(hidden)]
pub fn open_graph_read_only(
path: impl AsRef<Path>,
options: OpenOptions,
document: Option<EmbeddingTower>,
) -> Result<Self, GraphStoreError> {
Self::open_graph_as(path.as_ref(), options, document, AccessMode::ReadOnly)
}
fn open_graph_as(
path: &Path,
options: OpenOptions,
document: Option<EmbeddingTower>,
access: AccessMode,
) -> Result<Self, GraphStoreError> {
refuse_unsupported_platform()?;
refuse_legacy_directory(path)?;
let store = Store::open_native_graph(path, graph_options(options, access), document)?;
Ok(store)
}
#[doc(hidden)]
pub fn close_graph(&self) -> Result<(), GraphStoreError> {
let mut checkpoint = Ok(());
let closed = self.close_with_final_writer(|| {
checkpoint = self.checkpoint_native_graph_for_close();
});
checkpoint.map_err(GraphStoreError::graph)?;
closed.map_err(|error| GraphStoreError::graph(NativeGraphError::Store(error)))
}
pub fn graph_apply<'a, 'b: 'a, 'c: 'a>(
&self,
batch: impl Into<GraphBatch<'a, 'b, 'c>>,
control: &QueryControl,
) -> Result<GraphWriteResult, GraphStoreError> {
let batch = batch.into();
let BoundGraphBatch { writes, documents } = batch.bind(self)?;
let prepared = match documents.as_deref() {
Some(documents) => self.apply_native_mixed(documents, &writes, control)?,
None => self.apply_native_graph(&writes, control)?,
};
let outcome = match (prepared.changed_generation(), prepared.disposition()) {
(Some(generation), _) => GraphWriteOutcome::Committed { generation },
(None, BatchDisposition::Replayed) => GraphWriteOutcome::Replayed,
(None, BatchDisposition::NoOp) => GraphWriteOutcome::NoOp,
(None, BatchDisposition::Changed) => {
return Err(GraphStoreError::contract(
"changed batch returned without a published generation",
));
}
};
let admitted_generation = prepared.admitted_generation();
let ack = crate::ingest::IngestAck::mixed(
prepared.seq,
prepared
.changed_generation()
.unwrap_or(admitted_generation)
.get(),
);
let receipts = prepared.into_registration().into_receipts();
Ok(GraphWriteResult {
ack,
receipts,
outcome,
admitted_generation,
})
}
#[cfg(any(test, feature = "test-seams"))]
#[doc(hidden)]
pub fn apply_mixed_for_test(
&self,
documents: &crate::ingest::IngestBatch,
requests: &[StructuredWrite<'_, '_>],
control: &QueryControl,
) -> Result<GraphWriteResult, GraphStoreError> {
self.graph_apply(
GraphBatch {
documents: Some(documents),
writes: requests,
node_documents: &[],
},
control,
)
}
#[doc(hidden)]
pub fn graph_apply_with_materializer<
'a,
'b: 'a,
'c: 'a,
M: crate::property_graph::staging::ResultMaterializer,
>(
&self,
batch: impl Into<GraphBatch<'a, 'b, 'c>>,
control: &QueryControl,
materializer: &mut M,
) -> Result<(GraphWriteOutcome, GraphGeneration, M::Registration), GraphStoreError> {
let BoundGraphBatch { writes, documents } = batch.into().bind(self)?;
let prepared = self.apply_native_mixed_with_materializer(
documents.as_deref(),
&writes,
control,
materializer,
)?;
let outcome = match (prepared.changed_generation(), prepared.disposition()) {
(Some(generation), _) => GraphWriteOutcome::Committed { generation },
(None, BatchDisposition::Replayed) => GraphWriteOutcome::Replayed,
(None, BatchDisposition::NoOp) => GraphWriteOutcome::NoOp,
(None, BatchDisposition::Changed) => {
return Err(GraphStoreError::contract(
"changed batch returned without a published generation",
));
}
};
let admitted = prepared.admitted_generation();
Ok((outcome, admitted, prepared.into_registration()))
}
pub fn graph_resources(&self) -> Result<GraphResources, GraphStoreError> {
GraphResources::from_store(self)
.map_err(|error| GraphStoreError::graph(NativeGraphError::Store(error)))
}
#[cfg(any(test, feature = "test-seams"))]
#[doc(hidden)]
pub fn create_graph_with_allocator_seed_for_test(
path: impl AsRef<Path>,
options: OpenOptions,
first_node: crate::property_graph::NodeId,
first_relationship: crate::property_graph::RelId,
) -> Result<Self, GraphStoreError> {
let path = path.as_ref();
refuse_legacy_directory(path)?;
let store = Store::create_native_graph_with_allocator_seed_for_test(
path,
graph_options(options, AccessMode::ReadWrite),
None,
first_node,
first_relationship,
)?;
Ok(store)
}
#[cfg(feature = "test-seams")]
#[doc(hidden)]
pub fn jump_allocators_for_test(
&self,
next_node: crate::property_graph::NodeId,
next_relationship: crate::property_graph::RelId,
control: &QueryControl,
) -> Result<crate::property_graph::GraphGeneration, GraphStoreError> {
Ok(self.jump_native_graph_allocators_for_test(next_node, next_relationship, control)?)
}
#[cfg(test)]
pub(crate) const fn store_for_test(&self) -> &Store {
self
}
}
const fn graph_options(options: OpenOptions, access: AccessMode) -> OpenOptions {
options
.with_access_mode(access)
.with_durability(DurabilityMode::Durable, CommitTier::Durable)
}
#[cfg(any(target_os = "macos", test))]
pub(crate) const GRAPH_MIN_MACOS: (u32, u32) = (14, 0);
#[cfg(any(target_os = "macos", test))]
pub(crate) const fn macos_admits_graph(observed: (u32, u32)) -> bool {
observed.0 > GRAPH_MIN_MACOS.0
|| (observed.0 == GRAPH_MIN_MACOS.0 && observed.1 >= GRAPH_MIN_MACOS.1)
}
fn refuse_unsupported_platform() -> Result<(), GraphStoreError> {
#[cfg(target_os = "macos")]
{
match crate::sys::darwin::os_product_version() {
Ok(observed) if macos_admits_graph(observed) => Ok(()),
Ok(observed) => Err(GraphStoreError {
cause: Cause::UnsupportedPlatform {
required: GRAPH_MIN_MACOS,
observed: Some(observed),
probe_error: String::new(),
},
}),
Err(error) => Err(GraphStoreError {
cause: Cause::UnsupportedPlatform {
required: GRAPH_MIN_MACOS,
observed: None,
probe_error: error.to_string(),
},
}),
}
}
#[cfg(not(target_os = "macos"))]
{
Ok(())
}
}
fn refuse_legacy_directory(path: &Path) -> Result<(), GraphStoreError> {
let legacy = crate::lifecycle::is_legacy_store_directory(&crate::vfs::StdVfs, path)
.map_err(|error| GraphStoreError::graph(NativeGraphError::Store(error)))?;
if legacy {
let manifest_path = path.join(crate::manifest::io::MANIFEST_FILE);
if let Ok(bytes) = crate::vfs::Vfs::read(&crate::vfs::StdVfs, &manifest_path) {
let manifest =
crate::manifest::decode_manifest(&manifest_path.to_string_lossy(), &bytes)
.map_err(|error| {
GraphStoreError::graph(NativeGraphError::Store(
crate::lifecycle::StoreError::Manifest(error),
))
})?;
if manifest.graph.is_some() {
return Ok(());
}
}
return Err(GraphStoreError {
cause: Cause::LegacyStore {
path: path.to_path_buf(),
},
});
}
Ok(())
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GraphWriteOutcome {
Committed {
generation: GraphGeneration,
},
Replayed,
NoOp,
}
pub struct GraphBatch<'a, 'b, 'c> {
pub documents: Option<&'a crate::ingest::IngestBatch>,
pub writes: &'a [StructuredWrite<'b, 'c>],
pub node_documents: &'a [GraphNodeDocument],
}
#[derive(Clone, Debug)]
pub struct GraphNodeDocument {
pub item_index: usize,
pub document: crate::ingest::IngestDocument,
}
struct BoundGraphBatch<'a, 'b, 'c> {
writes: std::borrow::Cow<'a, [StructuredWrite<'b, 'c>]>,
documents: Option<std::borrow::Cow<'a, crate::ingest::IngestBatch>>,
}
impl<'a, 'b, 'c> GraphBatch<'a, 'b, 'c> {
pub const fn with_node_documents(
writes: &'a [StructuredWrite<'b, 'c>],
node_documents: &'a [GraphNodeDocument],
) -> Self {
Self {
documents: None,
writes,
node_documents,
}
}
fn bind(&self, store: &Store) -> Result<BoundGraphBatch<'a, 'b, 'c>, GraphStoreError> {
let mut writes = std::borrow::Cow::Borrowed(self.writes);
if self.node_documents.is_empty() {
return Ok(BoundGraphBatch {
writes,
documents: self.documents.map(std::borrow::Cow::Borrowed),
});
}
if self.documents.is_some() {
return Err(GraphStoreError::graph(NativeGraphError::Stage(
StageError::InvalidInput,
)));
}
let mut documents = Vec::new();
for (position, linked) in self.node_documents.iter().enumerate() {
let version = linked.document.version();
let id = NodeId::new(version.doc_id().get()).map_err(|_| {
GraphStoreError::graph(NativeGraphError::Stage(StageError::InvalidInput))
})?;
if self.node_documents.iter().take(position).any(|prior| {
prior.item_index == linked.item_index
|| prior.document.version().doc_id() == version.doc_id()
}) {
return Err(GraphStoreError::graph(NativeGraphError::Stage(
StageError::InvalidInput,
)));
}
let write = writes.to_mut().get_mut(linked.item_index).ok_or_else(|| {
GraphStoreError::graph(NativeGraphError::Stage(StageError::InvalidInput))
})?;
if !matches!(
write.image,
Some(crate::property_graph::staging::WriteImage::Node(_))
) || write.revision.get() != version.revision().get()
{
return Err(GraphStoreError::graph(NativeGraphError::Stage(
StageError::InvalidInput,
)));
}
write.operation = match write.operation {
StructuredOperation::Create => StructuredOperation::CreateWithId(id),
StructuredOperation::Put(EntityId::Node(expected)) if expected == id => {
write.operation
}
_ => {
return Err(GraphStoreError::graph(NativeGraphError::Stage(
StageError::InvalidInput,
)));
}
};
documents.push(linked.document.clone());
}
let mut input_bytes = 0_u64;
for document in &documents {
let payload =
crate::ingest::wal_payload::encode_upsert_v2(document).map_err(|error| {
GraphStoreError::graph(NativeGraphError::Ingest(
crate::ingest::IngestError::Payload(error),
))
})?;
input_bytes = input_bytes
.checked_add(payload.len() as u64)
.ok_or_else(|| GraphStoreError::contract("mixed graph input length overflow"))?;
}
for write in writes.iter() {
use crate::property_graph::staging::WriteImage;
use crate::property_graph::{CanonicalContents, ExpectedGraphState};
let expected = match write.operation {
StructuredOperation::Create | StructuredOperation::CreateWithId(_) => {
ExpectedGraphState::Absent
}
StructuredOperation::Put(id) | StructuredOperation::Delete(id, _) => {
ExpectedGraphState::Entity(id)
}
StructuredOperation::Recreate(revision) => ExpectedGraphState::Deletion(revision),
};
let framing = crate::property_graph::provenance::measure_operation_framing(
Some(write.key),
expected,
&mut || Ok(()),
)
.map_err(|error| GraphStoreError::graph(NativeGraphError::Stage(error.into())))?;
let image_bytes = match write.image {
None => 0,
Some(WriteImage::Node(image)) => image.encoded_len(),
Some(WriteImage::Relationship {
relationship_type,
properties,
..
}) => {
let mut properties = properties.to_vec();
let placeholder = NodeId::new(1)
.map_err(|_| GraphStoreError::contract("nonzero sizing identity"))?;
CanonicalContents::relationship(
placeholder,
placeholder,
relationship_type,
&mut properties,
)
.map_err(|error| GraphStoreError::graph(NativeGraphError::Stage(error.into())))?
.encoded_len()
}
};
input_bytes = input_bytes
.checked_add(framing)
.and_then(|bytes| bytes.checked_add(image_bytes))
.ok_or_else(|| GraphStoreError::contract("mixed graph input length overflow"))?;
}
if input_bytes > crate::property_graph::MAX_GRAPH_INPUT_BYTES as u64 {
return Err(GraphStoreError::graph(NativeGraphError::Stage(
StageError::InvalidInput,
)));
}
let mut batch = crate::ingest::IngestBatch::new(documents);
if let Some(epoch) = store.epoch_identity() {
batch = batch.with_epoch(epoch);
}
Ok(BoundGraphBatch {
writes,
documents: Some(std::borrow::Cow::Owned(batch)),
})
}
}
impl<'a, 'b, 'c> From<&'a [StructuredWrite<'b, 'c>]> for GraphBatch<'a, 'b, 'c> {
fn from(writes: &'a [StructuredWrite<'b, 'c>]) -> Self {
Self {
documents: None,
writes,
node_documents: &[],
}
}
}
impl<'a, 'b, 'c, const N: usize> From<&'a [StructuredWrite<'b, 'c>; N]> for GraphBatch<'a, 'b, 'c> {
fn from(writes: &'a [StructuredWrite<'b, 'c>; N]) -> Self {
Self::from(writes.as_slice())
}
}
impl<'a, 'b, 'c> From<&'a Vec<StructuredWrite<'b, 'c>>> for GraphBatch<'a, 'b, 'c> {
fn from(writes: &'a Vec<StructuredWrite<'b, 'c>>) -> Self {
Self::from(writes.as_slice())
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct GraphWriteResult {
ack: crate::ingest::IngestAck,
receipts: Box<[ItemReceipt]>,
outcome: GraphWriteOutcome,
admitted_generation: GraphGeneration,
}
impl GraphWriteResult {
#[must_use]
pub const fn ack(&self) -> crate::ingest::IngestAck {
self.ack
}
#[must_use]
pub fn receipts(&self) -> &[ItemReceipt] {
&self.receipts
}
#[must_use]
pub const fn outcome(&self) -> GraphWriteOutcome {
self.outcome
}
#[must_use]
pub const fn admitted_generation(&self) -> GraphGeneration {
self.admitted_generation
}
#[must_use]
pub fn into_receipts(self) -> Box<[ItemReceipt]> {
self.receipts
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum GraphStoreErrorKind {
LegacyStore,
Busy,
ReadOnly,
InvalidRequest,
Constraint,
Limit,
Cancelled,
Timeout,
Closed,
Corruption,
Storage,
Unavailable,
WriteIndeterminate,
Unsupported,
Internal,
}
#[derive(Debug)]
#[allow(
clippy::large_enum_variant,
reason = "the typed graph and query causes stay unboxed and allocation-free, as in GraphQueryError"
)]
enum Cause {
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
UnsupportedPlatform {
required: (u32, u32),
observed: Option<(u32, u32)>,
probe_error: String,
},
LegacyStore {
path: PathBuf,
},
Graph(NativeGraphError),
Query(GraphQueryError),
Contract(&'static str),
Limit(&'static str),
}
const fn from_graph_query_kind(kind: GraphQueryErrorKind) -> GraphStoreErrorKind {
match kind {
GraphQueryErrorKind::InvalidPlan
| GraphQueryErrorKind::Parameter
| GraphQueryErrorKind::Expression => GraphStoreErrorKind::InvalidRequest,
GraphQueryErrorKind::Constraint => GraphStoreErrorKind::Constraint,
GraphQueryErrorKind::Limit => GraphStoreErrorKind::Limit,
GraphQueryErrorKind::Cancelled => GraphStoreErrorKind::Cancelled,
GraphQueryErrorKind::Timeout => GraphStoreErrorKind::Timeout,
GraphQueryErrorKind::Closed => GraphStoreErrorKind::Closed,
GraphQueryErrorKind::Corruption => GraphStoreErrorKind::Corruption,
GraphQueryErrorKind::Internal => GraphStoreErrorKind::Internal,
GraphQueryErrorKind::Storage => GraphStoreErrorKind::Storage,
GraphQueryErrorKind::Unavailable => GraphStoreErrorKind::Unavailable,
GraphQueryErrorKind::WriteIndeterminate => GraphStoreErrorKind::WriteIndeterminate,
}
}
#[derive(Debug)]
pub struct GraphStoreError {
cause: Cause,
}
impl GraphStoreError {
const fn graph(error: NativeGraphError) -> Self {
Self {
cause: Cause::Graph(error),
}
}
const fn contract(reason: &'static str) -> Self {
Self {
cause: Cause::Contract(reason),
}
}
const fn limit(reason: &'static str) -> Self {
Self {
cause: Cause::Limit(reason),
}
}
#[must_use]
pub fn kind(&self) -> GraphStoreErrorKind {
match &self.cause {
Cause::UnsupportedPlatform { .. } => GraphStoreErrorKind::Unsupported,
Cause::LegacyStore { .. } => GraphStoreErrorKind::LegacyStore,
Cause::Contract(_) => GraphStoreErrorKind::Corruption,
Cause::Limit(_) => GraphStoreErrorKind::Limit,
Cause::Graph(NativeGraphError::Store(error))
if error.kind() == StoreErrorKind::StoreBusy =>
{
GraphStoreErrorKind::Busy
}
Cause::Graph(NativeGraphError::Store(error))
if error.kind() == StoreErrorKind::ReadOnly =>
{
GraphStoreErrorKind::ReadOnly
}
Cause::Graph(error) => from_graph_query_kind(native_graph_error_kind(error)),
Cause::Query(error)
if matches!(
error.cause(),
GraphQueryCause::Graph(NativeGraphError::Store(inner))
if inner.kind() == StoreErrorKind::ReadOnly
) =>
{
GraphStoreErrorKind::ReadOnly
}
Cause::Query(error) => from_graph_query_kind(error.kind()),
}
}
#[must_use]
pub const fn operator(&self) -> Option<PlanNodeId> {
match &self.cause {
Cause::Query(error) => error.operator(),
Cause::UnsupportedPlatform { .. }
| Cause::LegacyStore { .. }
| Cause::Graph(_)
| Cause::Contract(_)
| Cause::Limit(_) => None,
}
}
#[must_use]
pub const fn counters(&self) -> Option<WorkCounters> {
match &self.cause {
Cause::Query(error) => error.counters(),
Cause::UnsupportedPlatform { .. }
| Cause::LegacyStore { .. }
| Cause::Graph(_)
| Cause::Contract(_)
| Cause::Limit(_) => None,
}
}
#[must_use]
pub fn nothing_committed(&self) -> bool {
self.kind() != GraphStoreErrorKind::WriteIndeterminate
}
#[must_use]
pub const fn stage_error(&self) -> Option<&StageError> {
match &self.cause {
Cause::Graph(NativeGraphError::Stage(error)) => Some(error),
_ => None,
}
}
#[must_use]
pub fn legacy_store_path(&self) -> Option<&Path> {
match &self.cause {
Cause::LegacyStore { path } => Some(path),
_ => None,
}
}
}
impl From<NativeGraphError> for GraphStoreError {
fn from(error: NativeGraphError) -> Self {
Self::graph(error)
}
}
impl From<GraphQueryError> for GraphStoreError {
fn from(error: GraphQueryError) -> Self {
Self {
cause: Cause::Query(error),
}
}
}
impl std::fmt::Display for GraphStoreError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(formatter, "graph store rejected ({:?}): ", self.kind())?;
match &self.cause {
Cause::UnsupportedPlatform {
required,
observed,
probe_error,
} => {
write!(
formatter,
"graph store requires macOS {}.{} or newer; ",
required.0, required.1
)?;
if let Some(observed) = observed {
write!(formatter, "this host reports {}.{}", observed.0, observed.1)
} else {
write!(
formatter,
"could not determine the macOS version: {}",
probe_error
)
}
}
Cause::LegacyStore { path } => write!(
formatter,
"legacy store directory cannot be opened as a graph store: {}",
path.display()
),
Cause::Graph(error) => error.fmt(formatter),
Cause::Query(error) => error.fmt(formatter),
Cause::Contract(reason) | Cause::Limit(reason) => formatter.write_str(reason),
}
}
}
impl std::error::Error for GraphStoreError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match &self.cause {
Cause::Graph(error) => Some(error),
Cause::Query(error) => Some(error),
Cause::UnsupportedPlatform { .. }
| Cause::LegacyStore { .. }
| Cause::Contract(_)
| Cause::Limit(_) => None,
}
}
}
mod get;
pub use get::{GraphGetOptions, GraphNodesResult, GraphRelationshipsResult};
#[cfg(test)]
mod tests;