use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use boltr::server::BoltServer;
use clap::{Parser, ValueEnum};
use tracing_subscriber::EnvFilter;
use kglite::api::durable::DurabilityLevel;
use kglite::api::io::OpenDisposition;
use kglite::api::session::CsvImportPolicy;
use kglite::api::storage::StorageMode;
use crate::backend::{
checkpoint_if_changed, CheckpointOutcome, CheckpointState, KgliteBackend, ServerIdentity,
};
use crate::startup::{start_graph, DurabilityRequest};
mod auth;
mod backend;
mod error_map;
mod startup;
mod value_adapter;
#[derive(Copy, Clone, Debug, ValueEnum)]
enum AuthScheme {
None,
Basic,
}
#[derive(Parser, Debug)]
#[command(
name = "kglite-bolt-server",
about = "Bolt v5.x protocol server for kglite knowledge graphs.",
long_about = "Loads a .kgl file and serves it over the Neo4j Bolt wire protocol. \
The official Python driver path is regression-tested; other Bolt v5 \
clients are subject to KGLite's documented protocol and Cypher limits."
)]
struct Cli {
#[arg(long, value_name = "PATH")]
graph: PathBuf,
#[arg(long)]
storage: Option<String>,
#[arg(long, default_value = "127.0.0.1")]
bind: IpAddr,
#[arg(long, default_value_t = 7687)]
port: u16,
#[arg(long, default_value_t = false)]
readonly: bool,
#[arg(long, default_value_t = false, conflicts_with = "readonly")]
save_on_exit: bool,
#[arg(
long,
value_name = "SECS",
value_parser = parse_checkpoint_interval,
conflicts_with = "readonly"
)]
checkpoint_interval: Option<Duration>,
#[arg(long, value_name = "LEVEL", value_parser = parse_durability_level)]
durability: Option<DurabilityLevel>,
#[arg(long, default_value_t = false)]
neo4j_compat: bool,
#[arg(long, value_name = "DIR")]
allow_csv_import: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = AuthScheme::None)]
auth: AuthScheme,
#[arg(long, requires = "auth_pass")]
auth_user: Option<String>,
#[arg(long, requires = "auth_user")]
auth_pass: Option<String>,
#[arg(long, value_name = "SECS")]
idle_timeout: Option<u64>,
#[arg(long, default_value_t = 256)]
max_sessions: usize,
#[arg(long, value_name = "BYTES", default_value_t = 16 * 1024 * 1024)]
max_message_size: usize,
#[arg(long, value_name = "HOST:PORT")]
advertise_addr: Option<String>,
#[arg(long, value_name = "PATH", requires = "tls_key")]
tls_cert: Option<PathBuf>,
#[arg(long, value_name = "PATH", requires = "tls_cert")]
tls_key: Option<PathBuf>,
}
const NEO4J_COMPAT_ENV: &str = "KGLITE_BOLT_NEO4J_COMPAT";
const SAVE_ON_EXIT_ENV: &str = "KGLITE_BOLT_SAVE_ON_EXIT";
const CHECKPOINT_INTERVAL_ENV: &str = "KGLITE_BOLT_CHECKPOINT_INTERVAL";
const DURABILITY_ENV: &str = "KGLITE_BOLT_DURABILITY";
const DEFAULT_DURABILITY: DurabilityLevel = DurabilityLevel::Normal;
fn env_flag(name: &str) -> Option<bool> {
let raw = std::env::var(name).ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"" | "0" | "false" | "no" | "off" => Some(false),
_ => {
tracing::warn!(
var = name,
value = %raw,
"unrecognised boolean value; expected one of 1/true/yes/on or \
0/false/no/off — ignoring this variable"
);
None
}
}
}
fn parse_checkpoint_interval(raw: &str) -> Result<Duration, String> {
let trimmed = raw.trim();
let secs: u64 = trimmed.parse().map_err(|_| {
format!(
"invalid checkpoint interval {trimmed:?}: expected a whole number of \
seconds (for example 300)"
)
})?;
if secs == 0 {
return Err(
"invalid checkpoint interval 0: the interval must be at least 1 second — \
omit --checkpoint-interval (or KGLITE_BOLT_CHECKPOINT_INTERVAL) to \
disable periodic checkpoints"
.to_string(),
);
}
Ok(Duration::from_secs(secs))
}
fn env_checkpoint_interval() -> Result<Option<Duration>> {
let Ok(raw) = std::env::var(CHECKPOINT_INTERVAL_ENV) else {
return Ok(None);
};
if raw.trim().is_empty() {
return Ok(None);
}
parse_checkpoint_interval(&raw)
.map(Some)
.map_err(|e| anyhow::anyhow!("{CHECKPOINT_INTERVAL_ENV}: {e}"))
}
fn parse_durability_level(raw: &str) -> Result<DurabilityLevel, String> {
let trimmed = raw.trim();
DurabilityLevel::from_name(&trimmed.to_ascii_lowercase()).ok_or_else(|| {
format!(
"invalid durability level {trimmed:?}: expected one of {:?}",
DurabilityLevel::NAMES
)
})
}
fn env_durability_level() -> Result<Option<DurabilityLevel>> {
let Ok(raw) = std::env::var(DURABILITY_ENV) else {
return Ok(None);
};
if raw.trim().is_empty() {
return Ok(None);
}
parse_durability_level(&raw)
.map(Some)
.map_err(|e| anyhow::anyhow!("{DURABILITY_ENV}: {e}"))
}
fn ensure_durability_supported(level: DurabilityLevel, readonly: bool) -> Result<()> {
if level.logs() && readonly {
anyhow::bail!(
"--durability {} (or {DURABILITY_ENV}) cannot be combined with --readonly: a \
read-only server never commits, so there is nothing to log — pass \
--durability off, or drop --readonly",
level.name()
);
}
Ok(())
}
fn ensure_checkpointing_supported(
flag: &str,
env_var: &str,
readonly: bool,
mode: Option<StorageMode>,
) -> Result<()> {
if readonly {
anyhow::bail!(
"{flag} (or {env_var}) cannot be combined with --readonly: a read-only \
server never changes the graph, so there is nothing to write back"
);
}
if mode == Some(StorageMode::Disk) {
anyhow::bail!(
"{flag} is not supported for disk-mode graphs: every disk save publishes \
a new on-disk generation and nothing prunes the old ones, so repeated \
checkpoints grow the directory without bound. Serve a `.kgl` graph \
(memory or mapped) if you want checkpoints"
);
}
Ok(())
}
#[derive(Clone, Copy, Debug)]
struct Durability {
save_on_exit: bool,
checkpoint_interval: Option<Duration>,
level: DurabilityLevel,
level_requested: bool,
}
impl Durability {
fn resolve(cli: &Cli) -> Result<Self> {
let (level, level_requested) = match cli.durability {
Some(level) => (level, true),
None => match env_durability_level()? {
Some(level) => (level, true),
None => (DEFAULT_DURABILITY, false),
},
};
let mut resolved = Self {
save_on_exit: cli.save_on_exit || env_flag(SAVE_ON_EXIT_ENV).unwrap_or(false),
checkpoint_interval: match cli.checkpoint_interval {
Some(interval) => Some(interval),
None => env_checkpoint_interval()?,
},
level,
level_requested,
};
if resolved.level.logs() && cli.readonly && !resolved.level_requested {
tracing::info!(
default_level = resolved.level.name(),
"--readonly: serving at durability off (a read-only server never commits, \
so there is nothing to log)"
);
resolved.level = DurabilityLevel::Off;
}
resolved.ensure_supported(cli.readonly, None)?;
Ok(resolved)
}
fn ensure_supported(&self, readonly: bool, mode: Option<StorageMode>) -> Result<()> {
ensure_durability_supported(self.level, readonly)?;
if self.save_on_exit {
ensure_checkpointing_supported("--save-on-exit", SAVE_ON_EXIT_ENV, readonly, mode)?;
}
if self.checkpoint_interval.is_some() {
ensure_checkpointing_supported(
"--checkpoint-interval",
CHECKPOINT_INTERVAL_ENV,
readonly,
mode,
)?;
}
Ok(())
}
}
fn run_exit_save(session: &kglite::api::session::Session, path: &Path) -> Result<()> {
tracing::info!(path = %path.display(), "save-on-exit: writing the served graph back");
session
.save(&path.to_string_lossy(), true)
.map_err(|e| anyhow::anyhow!("save-on-exit failed writing {}: {e}", path.display()))?;
tracing::info!(
path = %path.display(),
graph_version = session.version(),
"save-on-exit complete"
);
Ok(())
}
fn spawn_checkpoint_task(
session: Arc<kglite::api::session::Session>,
path: PathBuf,
state: CheckpointState,
interval: Duration,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
loop {
ticker.tick().await;
match checkpoint_if_changed(&session, &path, &state) {
Ok(CheckpointOutcome::Written(version)) => tracing::info!(
path = %path.display(),
graph_version = version,
"checkpoint-interval: graph written"
),
Ok(CheckpointOutcome::Skipped(version)) => tracing::debug!(
graph_version = version,
"checkpoint-interval: skipped (graph unchanged since the last checkpoint)"
),
Err(e) => tracing::error!(
path = %path.display(),
error = %e,
"checkpoint-interval: save FAILED — the server keeps serving, but \
writes since the last successful checkpoint are not on disk"
),
}
}
})
}
fn read_tls_config(cert_path: &Path, key_path: &Path) -> Result<boltr::server::TlsConfig> {
let _ = rustls::crypto::ring::default_provider().install_default();
let cert_pem = std::fs::read(cert_path)
.with_context(|| format!("reading TLS cert {}", cert_path.display()))?;
let key_pem = std::fs::read(key_path)
.with_context(|| format!("reading TLS key {}", key_path.display()))?;
boltr::server::TlsConfig::from_pem(&cert_pem, &key_pem)
.map_err(|e| anyhow::anyhow!("invalid TLS cert/key: {}", e))
}
#[cfg(unix)]
async fn shutdown_signal() {
use tokio::signal::unix::{signal, SignalKind};
match signal(SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => tracing::info!("SIGINT received; shutting down"),
_ = sigterm.recv() => tracing::info!("SIGTERM received; shutting down"),
}
}
Err(e) => {
tracing::warn!(
error = %e,
"could not install a SIGTERM handler; only SIGINT will shut down gracefully"
);
let _ = tokio::signal::ctrl_c().await;
tracing::info!("SIGINT received; shutting down");
}
}
}
#[cfg(not(unix))]
async fn shutdown_signal() {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("SIGINT received; shutting down");
}
fn init_tracing() {
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("kglite_bolt_server=info,boltr=warn,warn"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.init();
}
fn main() -> Result<()> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(kglite::api::session::QUERY_THREAD_STACK_SIZE)
.build()
.context("failed to build tokio runtime")?
.block_on(serve())
}
async fn serve() -> Result<()> {
init_tracing();
let cli = Cli::parse();
let requested_mode = cli
.storage
.as_deref()
.map(StorageMode::parse)
.transpose()
.map_err(|e| anyhow::anyhow!(e))?;
let mut durability = Durability::resolve(&cli)?;
let started = start_graph(
&cli.graph,
requested_mode,
cli.readonly,
DurabilityRequest {
level: durability.level,
explicit: durability.level_requested,
},
&mut |_| {},
)?;
durability.level = started.level;
let _writer_lease = started.writer_lease;
tracing::info!(
disposition = match started.disposition {
OpenDisposition::Opened => "opened",
OpenDisposition::Created => "created",
},
storage = started.live_mode.as_str(),
converted_from = started.converted_from.map(StorageMode::as_str),
durability = durability.level.name(),
"graph ready; constructing Bolt server"
);
durability.ensure_supported(cli.readonly, Some(started.live_mode))?;
let advertised_addr = cli
.advertise_addr
.clone()
.unwrap_or_else(|| format!("{}:{}", cli.bind, cli.port));
let csv_import = match cli.allow_csv_import.clone() {
Some(dir) => CsvImportPolicy::Directory(dir),
None => CsvImportPolicy::Denied,
};
let neo4j_compat = cli.neo4j_compat || env_flag(NEO4J_COMPAT_ENV).unwrap_or(false);
let identity = if neo4j_compat {
ServerIdentity::Neo4jCompatible
} else {
ServerIdentity::Kglite
};
tracing::info!(
server_agent = %identity.product_string(env!("CARGO_PKG_VERSION")),
neo4j_compat,
"bolt handshake identity"
);
let backend = KgliteBackend::new(
started.session,
cli.graph.clone(),
cli.readonly,
advertised_addr,
csv_import,
identity,
cli.auth_user.clone(),
);
let exit_session = backend.session_handle();
let served_path = backend.graph_path().to_path_buf();
let checkpoint_state = backend.checkpoint_state();
let addr = SocketAddr::new(cli.bind, cli.port);
let builder = configure_builder(&cli, backend)?;
tracing::info!(
%addr,
readonly = cli.readonly,
durability = durability.level.name(),
save_on_exit = durability.save_on_exit,
checkpoint_interval_secs = durability.checkpoint_interval.map(|d| d.as_secs()),
"Bolt server starting"
);
let checkpoint_task = durability.checkpoint_interval.map(|interval| {
tracing::info!(
interval_secs = interval.as_secs(),
path = %served_path.display(),
"periodic checkpointing enabled"
);
spawn_checkpoint_task(
Arc::clone(&exit_session),
served_path.clone(),
checkpoint_state,
interval,
)
});
let serve_result = builder
.serve(addr)
.await
.map_err(|e| anyhow::anyhow!("BoltServer::serve failed: {}", e));
tracing::info!("Bolt server stopped");
finish_shutdown(
&durability,
&exit_session,
&served_path,
checkpoint_task,
serve_result,
)
.await
}
fn configure_builder(cli: &Cli, backend: KgliteBackend) -> Result<BoltServer<KgliteBackend>> {
let mut builder = BoltServer::builder(backend)
.max_sessions(cli.max_sessions)
.max_message_size(cli.max_message_size)
.shutdown(shutdown_signal());
if let Some(secs) = cli.idle_timeout {
builder = builder.idle_timeout(Duration::from_secs(secs));
}
if let (Some(cert_path), Some(key_path)) = (cli.tls_cert.as_ref(), cli.tls_key.as_ref()) {
builder = builder.tls(read_tls_config(cert_path, key_path)?);
tracing::info!(
cert = %cert_path.display(),
key = %key_path.display(),
"TLS enabled — clients must connect via bolt+s:// or neo4j+s://"
);
}
if matches!(cli.auth, AuthScheme::Basic) {
let user = cli.auth_user.clone().ok_or_else(|| {
anyhow::anyhow!("--auth basic requires both --auth-user and --auth-pass")
})?;
let pass = cli.auth_pass.clone().ok_or_else(|| {
anyhow::anyhow!("--auth basic requires both --auth-user and --auth-pass")
})?;
builder = builder.auth(crate::auth::BasicAuthValidator::new(user, pass));
tracing::info!(user = %cli.auth_user.as_deref().unwrap_or(""), "wired --auth basic validator");
}
Ok(builder)
}
async fn finish_shutdown(
durability: &Durability,
exit_session: &kglite::api::session::Session,
served_path: &Path,
checkpoint_task: Option<tokio::task::JoinHandle<()>>,
serve_result: Result<()>,
) -> Result<()> {
if let Some(task) = checkpoint_task {
task.abort();
let _ = task.await;
tracing::info!("checkpoint-interval: stopped");
}
let sync_result = if durability.level.logs() {
exit_session
.sync()
.map_err(|e| anyhow::anyhow!("flushing the write-ahead log at shutdown: {e}"))
.inspect(|()| {
tracing::info!(
durability = durability.level.name(),
"write-ahead log flushed"
)
})
.inspect_err(|e| {
tracing::error!(
error = %format!("{e:#}"),
"write-ahead log flush FAILED — commits acknowledged since the last \
checkpoint may not be on disk"
)
})
} else {
Ok(())
};
let save_result = if durability.save_on_exit {
run_exit_save(exit_session, served_path)
.inspect_err(|e| tracing::error!(error = %format!("{e:#}"), "save-on-exit FAILED"))
} else {
Ok(())
};
serve_result?;
sync_result?;
save_result?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_dir(tag: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos();
let dir =
std::env::temp_dir().join(format!("kglite-bolt-{tag}-{}-{nonce}", std::process::id()));
std::fs::create_dir_all(&dir).expect("scratch dir");
dir
}
#[test]
fn checkpointing_is_refused_for_a_readonly_server() {
for (flag, env) in [
("--save-on-exit", SAVE_ON_EXIT_ENV),
("--checkpoint-interval", CHECKPOINT_INTERVAL_ENV),
] {
let error = ensure_checkpointing_supported(flag, env, true, None)
.expect_err("a read-only server has nothing to save");
let message = format!("{error:#}");
assert!(
message.contains("--readonly") && message.contains(flag) && message.contains(env),
"the refusal must name the flag, its env mirror and --readonly: {message}"
);
}
}
#[test]
fn checkpointing_is_refused_for_a_disk_graph() {
for (flag, env) in [
("--save-on-exit", SAVE_ON_EXIT_ENV),
("--checkpoint-interval", CHECKPOINT_INTERVAL_ENV),
] {
let error = ensure_checkpointing_supported(flag, env, false, Some(StorageMode::Disk))
.expect_err("disk-mode checkpoints grow the directory without bound");
let message = format!("{error:#}");
assert!(
message.contains("disk-mode") && message.contains("generation"),
"the refusal must carry the reason, not just the verdict: {message}"
);
}
}
#[test]
fn checkpointing_is_allowed_for_the_portable_modes() {
for mode in [StorageMode::Memory, StorageMode::Mapped] {
ensure_checkpointing_supported("--save-on-exit", SAVE_ON_EXIT_ENV, false, Some(mode))
.unwrap_or_else(|e| panic!("{} must support an exit save: {e:#}", mode.as_str()));
ensure_checkpointing_supported(
"--checkpoint-interval",
CHECKPOINT_INTERVAL_ENV,
false,
Some(mode),
)
.unwrap_or_else(|e| {
panic!("{} must support periodic checkpoints: {e:#}", mode.as_str())
});
}
}
#[test]
fn checkpoint_interval_accepts_whole_seconds() {
assert_eq!(
parse_checkpoint_interval("300").expect("a plain integer is the documented spelling"),
Duration::from_secs(300)
);
assert_eq!(
parse_checkpoint_interval(" 1 \n").expect("surrounding whitespace is not a typo"),
Duration::from_secs(1)
);
}
#[test]
fn checkpoint_interval_rejects_zero_and_junk() {
let zero = parse_checkpoint_interval("0").expect_err("0 would checkpoint continuously");
assert!(
zero.contains("at least 1 second") && zero.contains("--checkpoint-interval"),
"the refusal must say what to write instead: {zero}"
);
for junk in ["", "abc", "5s", "1.5", "-1", "300 seconds"] {
let outcome = parse_checkpoint_interval(junk);
assert!(
outcome.is_err(),
"{junk:?} must be rejected, not guessed at — got {outcome:?}"
);
}
}
#[test]
fn durability_accepts_every_engine_level() {
for name in DurabilityLevel::NAMES {
let parsed = parse_durability_level(name)
.unwrap_or_else(|e| panic!("{name} is an engine level: {e}"));
assert_eq!(parsed.name(), name);
}
assert_eq!(
parse_durability_level(" FULL \n").expect("case and whitespace are not typos"),
DurabilityLevel::Full
);
}
#[test]
fn durability_rejects_an_unknown_level_naming_the_accepted_ones() {
for junk in ["", "on", "true", "fsync", "none", "1"] {
let error = parse_durability_level(junk)
.err()
.unwrap_or_else(|| panic!("{junk:?} must not be guessed at"));
assert!(
error.contains("full") && error.contains("normal") && error.contains("off"),
"the refusal must list the levels that exist: {error}"
);
}
}
#[test]
fn a_logging_level_is_refused_for_a_readonly_server() {
for level in [DurabilityLevel::Full, DurabilityLevel::Normal] {
let error = ensure_durability_supported(level, true)
.expect_err("a read-only server has nothing to log");
let message = format!("{error:#}");
assert!(
message.contains("--readonly")
&& message.contains(level.name())
&& message.contains(DURABILITY_ENV),
"the refusal must name the level, its env mirror and --readonly: {message}"
);
}
ensure_durability_supported(DurabilityLevel::Off, true)
.expect("--readonly --durability off is the read-only server's normal shape");
for level in [DurabilityLevel::Full, DurabilityLevel::Normal] {
ensure_durability_supported(level, false)
.unwrap_or_else(|e| panic!("a writable server may log at {}: {e}", level.name()));
}
}
#[test]
fn the_default_level_is_normal() {
assert_eq!(DEFAULT_DURABILITY, DurabilityLevel::Normal);
assert!(DEFAULT_DURABILITY.logs());
}
#[test]
fn a_readonly_server_serves_the_default_level_at_off() {
let cli = Cli::parse_from(["kglite-bolt-server", "--graph", "g.kgl", "--readonly"]);
let resolved = Durability::resolve(&cli).expect(
"--readonly must not become a startup error the day the default starts logging",
);
assert_eq!(resolved.level, DurabilityLevel::Off);
assert!(!resolved.level_requested);
}
#[test]
fn a_requested_level_is_still_refused_with_readonly() {
let cli = Cli::parse_from([
"kglite-bolt-server",
"--graph",
"g.kgl",
"--readonly",
"--durability",
"normal",
]);
let error = Durability::resolve(&cli)
.err()
.map(|e| format!("{e:#}"))
.expect("an explicit level with --readonly is a startup error");
assert!(
error.contains("--durability normal") && error.contains("--readonly"),
"the refusal must name both flags: {error}"
);
}
#[test]
fn checkpoint_interval_junk_reports_what_was_typed() {
let error = parse_checkpoint_interval("5s").expect_err("units are not supported");
assert!(
error.contains("\"5s\"") && error.contains("whole number of seconds"),
"the error must quote the value and name the accepted form: {error}"
);
}
#[test]
fn first_checkpoint_writes_then_unchanged_ones_skip() {
let dir = scratch_dir("first-tick");
let path = dir.join("served.kgl");
let graph = kglite::api::storage::new_dir_graph_in_mode(StorageMode::Memory, None)
.expect("memory graph");
let session = kglite::api::session::Session::new(graph);
let state: CheckpointState = Arc::new(std::sync::Mutex::new(None));
let first = checkpoint_if_changed(&session, &path, &state).expect("first checkpoint");
assert_eq!(
first,
CheckpointOutcome::Written(session.version()),
"the first checkpoint of a process must write whatever the file holds"
);
assert!(path.exists(), "a Written outcome must produce the file");
let second = checkpoint_if_changed(&session, &path, &state).expect("second checkpoint");
assert_eq!(
second,
CheckpointOutcome::Skipped(session.version()),
"an unchanged graph must not be rewritten"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_failed_checkpoint_does_not_record_a_version() {
let dir = scratch_dir("failed-tick");
let unwritable = dir.join("no-such-directory").join("served.kgl");
let graph = kglite::api::storage::new_dir_graph_in_mode(StorageMode::Memory, None)
.expect("memory graph");
let session = kglite::api::session::Session::new(graph);
let state: CheckpointState = Arc::new(std::sync::Mutex::new(None));
checkpoint_if_changed(&session, &unwritable, &state)
.expect_err("a save into a missing directory must be reported");
assert_eq!(
*state.lock().expect("uncontended"),
None,
"a failed checkpoint must not record its version"
);
let retry = checkpoint_if_changed(&session, &dir.join("served.kgl"), &state)
.expect("the retry writes");
assert!(matches!(retry, CheckpointOutcome::Written(_)));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn exit_save_writes_the_graph_and_reports_failure() {
let dir = scratch_dir("exit-save");
let path = dir.join("served.kgl");
let graph = kglite::api::storage::new_dir_graph_in_mode(StorageMode::Memory, None)
.expect("memory graph");
let session = kglite::api::session::Session::new(graph);
run_exit_save(&session, &path).expect("exit save on a writable path");
assert!(path.exists(), "the exit save must produce the served file");
let unwritable = dir.join("no-such-directory").join("served.kgl");
let error = run_exit_save(&session, &unwritable)
.expect_err("a save into a missing directory must be reported");
assert!(
format!("{error:#}").contains("save-on-exit failed"),
"the error must identify the exit save: {error:#}"
);
std::fs::remove_dir_all(&dir).ok();
}
}