use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
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, QueryLimits,
ServerIdentity, WriteConcurrency, WriterConfig,
};
use crate::startup::{start_graph, DurabilityRequest};
mod accept;
mod auth;
mod backend;
mod backup;
mod coalesce;
mod discard;
mod error_map;
mod guard;
mod inflight;
mod ontology;
mod pump;
mod startup;
mod value_adapter;
#[derive(Copy, Clone, Debug, ValueEnum)]
enum AuthScheme {
None,
Basic,
}
#[derive(Parser, Debug)]
#[command(
name = "kglite-bolt-server",
version,
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 = "MIB", value_parser = parse_checkpoint_wal_mib)]
checkpoint_wal_mib: Option<u64>,
#[arg(long, value_name = "today|all|YYYY-MM-DD",
value_parser = |text: &str| kglite::api::temporal::ValidTimeDefault::parse(text))]
valid_time_default: Option<kglite::api::temporal::ValidTimeDefault>,
#[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_name = "DIR")]
backup_dir: Option<PathBuf>,
#[arg(long, default_value_t = false)]
backup_allow_any_path: bool,
#[arg(long, value_name = "SECS", value_parser = backup::parse_backup_interval)]
backup_interval: Option<Duration>,
#[arg(long, value_name = "N", value_parser = backup::parse_backup_keep)]
backup_keep: Option<usize>,
#[arg(long, value_name = "FILE")]
ontology: Option<PathBuf>,
#[arg(long, default_value_t = false, requires = "ontology")]
ontology_replace: bool,
#[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 = "MS")]
query_timeout: Option<u64>,
#[arg(long, value_name = "N")]
max_work_units: Option<usize>,
#[arg(long, value_name = "N")]
max_rows: Option<usize>,
#[arg(long, value_enum, default_value_t = WriteConcurrencyArg::Queue)]
write_concurrency: WriteConcurrencyArg,
#[arg(long, value_name = "SECS", default_value_t = 20.0, value_parser = parse_timeout_secs)]
writer_wait_timeout: f64,
#[arg(long, value_name = "SECS", default_value_t = 10.0, value_parser = parse_timeout_secs)]
writer_idle_timeout: f64,
#[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>,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
enum WriteConcurrencyArg {
Queue,
Optimistic,
}
fn parse_timeout_secs(raw: &str) -> Result<f64, String> {
let secs: f64 = raw
.parse()
.map_err(|_| format!("{raw:?} is not a number of seconds"))?;
if secs.is_finite() && secs >= 0.0 {
Ok(secs)
} else {
Err(format!("{raw:?} must be a non-negative number of seconds"))
}
}
fn timeout_from_secs(secs: f64) -> Option<Duration> {
(secs > 0.0).then(|| Duration::from_secs_f64(secs))
}
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 CHECKPOINT_WAL_MIB_ENV: &str = "KGLITE_BOLT_CHECKPOINT_WAL_MIB";
const DEFAULT_CHECKPOINT_WAL_BYTES: u64 = kglite::api::session::DEFAULT_AUTO_CHECKPOINT_WAL_BYTES;
const WAL_SIZE_POLL: Duration = Duration::from_secs(10);
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_checkpoint_wal_mib(raw: &str) -> Result<u64, String> {
let trimmed = raw.trim();
trimmed.parse().map_err(|_| {
format!(
"invalid checkpoint WAL size {trimmed:?}: expected a whole number of MiB \
(for example 16), or 0 to disable"
)
})
}
fn env_checkpoint_wal_mib() -> Result<Option<u64>> {
let Ok(raw) = std::env::var(CHECKPOINT_WAL_MIB_ENV) else {
return Ok(None);
};
if raw.trim().is_empty() {
return Ok(None);
}
parse_checkpoint_wal_mib(&raw)
.map(Some)
.map_err(|e| anyhow::anyhow!("{CHECKPOINT_WAL_MIB_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>,
checkpoint_wal_bytes: Option<u64>,
wal_checkpoint_requested: bool,
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 wal_mib = match cli.checkpoint_wal_mib {
Some(mib) => Some(mib),
None => env_checkpoint_wal_mib()?,
};
let mut resolved = Self {
checkpoint_wal_bytes: None,
wal_checkpoint_requested: wal_mib.is_some_and(|mib| mib > 0),
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.checkpoint_wal_bytes = match wal_mib {
Some(0) => None,
Some(mib) => Some(mib.saturating_mul(1 << 20)),
None => resolved
.level
.logs()
.then_some(DEFAULT_CHECKPOINT_WAL_BYTES),
};
if resolved.wal_checkpoint_requested && !resolved.level.logs() {
tracing::info!(
"--checkpoint-wal-mib has no effect at durability off: there is no \
write-ahead log to bound"
);
resolved.checkpoint_wal_bytes = None;
}
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,
)?;
}
if self.wal_checkpoint_requested && self.checkpoint_wal_bytes.is_some() {
ensure_checkpointing_supported(
"--checkpoint-wal-mib",
CHECKPOINT_WAL_MIB_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 spawn_wal_size_checkpoint_task(
session: Arc<kglite::api::session::Session>,
path: PathBuf,
state: CheckpointState,
threshold: u64,
poll: Duration,
) -> tokio::task::JoinHandle<()> {
session.set_auto_checkpoint_wal_bytes(Some(threshold));
tokio::spawn(async move {
let mut ticker = tokio::time::interval(poll);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
loop {
ticker.tick().await;
if !session.needs_checkpoint() {
continue;
}
let (worker, recorded) = (Arc::clone(&session), Arc::clone(&state));
let outcome = tokio::task::spawn_blocking(move || {
let report = worker.maybe_checkpoint_online()?;
if let Some(report) = &report {
*recorded.lock().unwrap_or_else(|p| p.into_inner()) =
Some(report.graph_version);
}
Ok::<_, String>(report)
})
.await
.unwrap_or_else(|e| Err(format!("checkpoint task panicked: {e}")));
match outcome {
Ok(Some(report)) => tracing::info!(
path = %path.display(),
graph_version = report.graph_version,
wal_bytes_before = report.wal_bytes_before,
wal_bytes_after = report.wal_bytes_after,
snapshot_hold_us = report.snapshot_hold.as_micros() as u64,
trim_hold_us = report.trim_hold.as_micros() as u64,
"checkpoint-wal: online checkpoint written, log trimmed"
),
Ok(None) => tracing::debug!("checkpoint-wal: another checkpoint was running"),
Err(e) => tracing::error!(
path = %path.display(),
error = %e,
"checkpoint-wal: online checkpoint FAILED — the log keeps growing and \
the next attempt waits for one more threshold of growth"
),
}
}
})
}
fn read_tls_config(cert_path: &Path, key_path: &Path) -> Result<tokio_rustls::TlsAcceptor> {
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()))?;
accept::tls_acceptor_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 (backup_policy, backup_dir) = resolve_backup_policy(&cli)?;
let (started, durability) = open_served_graph(&cli)?;
log_graph_ready(&started, &durability);
let _writer_lease = started.writer_lease;
durability.ensure_supported(cli.readonly, Some(started.live_mode))?;
let backend = build_backend(&cli, started.session, backup_policy);
let exit_session = backend.session_handle();
let served_path = backend.graph_path().to_path_buf();
let tasks = spawn_background_tasks(&cli, &durability, &backend, &exit_session, backup_dir);
let addr = SocketAddr::new(cli.bind, cli.port);
let listener = configure_listener(&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()),
checkpoint_wal_bytes = durability.checkpoint_wal_bytes,
"Bolt server starting"
);
let serve_result = listener
.serve(addr)
.await
.map_err(|e| anyhow::anyhow!("Bolt accept loop failed: {}", e));
tracing::info!("Bolt server stopped");
finish_shutdown(
&durability,
&exit_session,
&served_path,
tasks,
serve_result,
)
.await
}
fn resolve_backup_policy(cli: &Cli) -> Result<(backup::BackupPolicy, Option<PathBuf>)> {
let backup_policy = backup::BackupPolicy::from_flags(
cli.backup_dir.as_deref(),
cli.backup_allow_any_path,
matches!(cli.auth, AuthScheme::None),
)
.map_err(|e| anyhow::anyhow!(e))?;
backup::validate_schedule(cli.backup_interval, cli.backup_keep, backup_policy.dir())
.map_err(|e| anyhow::anyhow!(e))?;
let backup_dir = backup_policy.dir().map(Path::to_path_buf);
Ok((backup_policy, backup_dir))
}
fn open_served_graph(cli: &Cli) -> Result<(startup::StartedGraph, Durability)> {
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,
},
cli.valid_time_default,
&mut |_| {},
)?;
durability.level = started.level;
ontology::apply_cli(&started.session, cli)?;
if !durability.level.logs() {
durability.checkpoint_wal_bytes = None;
}
Ok((started, durability))
}
fn log_graph_ready(started: &startup::StartedGraph, durability: &Durability) {
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"
);
}
fn server_identity(cli: &Cli) -> ServerIdentity {
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"
);
identity
}
fn build_backend(
cli: &Cli,
session: kglite::api::session::Session,
backup_policy: backup::BackupPolicy,
) -> KgliteBackend {
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,
};
KgliteBackend::new(
session,
cli.graph.clone(),
cli.readonly,
advertised_addr,
csv_import,
server_identity(cli),
cli.auth_user.clone(),
)
.with_backup_policy(backup_policy)
.with_query_limits(QueryLimits {
timeout_ms: cli.query_timeout.filter(|ms| *ms > 0),
max_work_units: cli.max_work_units,
max_rows: cli.max_rows,
})
.with_writer_config(WriterConfig {
mode: match cli.write_concurrency {
WriteConcurrencyArg::Queue => WriteConcurrency::Queue,
WriteConcurrencyArg::Optimistic => WriteConcurrency::Optimistic,
},
wait_timeout: timeout_from_secs(cli.writer_wait_timeout),
idle_timeout: timeout_from_secs(cli.writer_idle_timeout),
})
}
fn spawn_background_tasks(
cli: &Cli,
durability: &Durability,
backend: &KgliteBackend,
exit_session: &Arc<kglite::api::session::Session>,
backup_dir: Option<PathBuf>,
) -> [Option<tokio::task::JoinHandle<()>>; 3] {
let served_path = backend.graph_path().to_path_buf();
let checkpoint_state = backend.checkpoint_state();
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(),
Arc::clone(&checkpoint_state),
interval,
)
});
let wal_task = durability.checkpoint_wal_bytes.map(|threshold| {
tracing::info!(
threshold_bytes = threshold,
path = %served_path.display(),
"log-size checkpointing enabled"
);
spawn_wal_size_checkpoint_task(
Arc::clone(exit_session),
served_path.clone(),
checkpoint_state,
threshold,
WAL_SIZE_POLL,
)
});
let backup_task = spawn_backup_task(cli, backend, &served_path, backup_dir);
[checkpoint_task, wal_task, backup_task]
}
fn spawn_backup_task(
cli: &Cli,
backend: &KgliteBackend,
served_path: &Path,
backup_dir: Option<PathBuf>,
) -> Option<tokio::task::JoinHandle<()>> {
let (interval, dir) = cli.backup_interval.zip(backup_dir)?;
let stem = served_path
.file_stem()
.map_or_else(|| "graph".to_string(), |s| s.to_string_lossy().into_owned());
tracing::info!(
interval_secs = interval.as_secs(),
keep = cli.backup_keep,
dir = %dir.display(),
"scheduled backups enabled"
);
Some(backup::spawn_scheduler(
backend.backup_service(),
dir,
stem,
interval,
cli.backup_keep,
))
}
fn configure_listener(
cli: &Cli,
backend: KgliteBackend,
) -> Result<accept::BoltListener<KgliteBackend>> {
let mut tls = None;
if let (Some(cert_path), Some(key_path)) = (cli.tls_cert.as_ref(), cli.tls_key.as_ref()) {
tls = Some(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://"
);
}
let mut auth: Option<Arc<dyn boltr::server::AuthValidator>> = None;
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")
})?;
auth = Some(Arc::new(crate::auth::BasicAuthValidator::new(user, pass)));
tracing::info!(user = %cli.auth_user.as_deref().unwrap_or(""), "wired --auth basic validator");
}
Ok(accept::BoltListener {
backend,
auth,
tls,
idle_timeout: cli.idle_timeout.map(Duration::from_secs),
max_sessions: cli.max_sessions,
max_message_size: cli.max_message_size,
shutdown: Box::pin(shutdown_signal()),
})
}
async fn finish_shutdown(
durability: &Durability,
exit_session: &kglite::api::session::Session,
served_path: &Path,
checkpoint_tasks: [Option<tokio::task::JoinHandle<()>>; 3],
serve_result: Result<()>,
) -> Result<()> {
let [interval_task, wal_task, backup_task] = checkpoint_tasks;
if let Some(task) = interval_task {
task.abort();
let _ = task.await;
tracing::info!("checkpoint-interval: stopped");
}
if let Some(task) = wal_task {
task.abort();
let _ = task.await;
tracing::info!("checkpoint-wal: stopped");
}
if let Some(task) = backup_task {
task.abort();
let _ = task.await;
tracing::info!("backup-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::*;
#[test]
fn version_flag_displays_version() {
let error = Cli::try_parse_from(["kglite-bolt-server", "--version"])
.expect_err("--version exits through clap");
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
assert!(error.to_string().contains(env!("CARGO_PKG_VERSION")));
}
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)
);
}
#[tokio::test]
async fn the_wal_size_task_checkpoints_an_oversized_log_and_only_that() {
use kglite::api::session::{execute_mut, CommitOutcome, ExecuteOptions};
use std::collections::HashMap;
use std::time::Instant;
let scratch = scratch_dir("walsize");
let path = scratch.join("graph.kgl");
let started = start_graph(
&path,
Some(StorageMode::Memory),
false,
DurabilityRequest {
level: DurabilityLevel::Normal,
explicit: true,
},
None,
&mut |_| {},
)
.expect("a durable memory graph");
let session = Arc::new(started.session);
let commit = |query: &str| {
let params = HashMap::new();
let mut tx = session.begin();
execute_mut(
tx.working_mut().expect("working copy"),
query,
&ExecuteOptions::eager(¶ms),
)
.expect("mutation");
assert!(matches!(
session.commit(tx, true),
CommitOutcome::Committed { .. }
));
};
let wal = kglite::api::durable::wal_path(&path);
let wal_len = || std::fs::metadata(&wal).map_or(0, |m| m.len());
let state: CheckpointState = Arc::default();
let poll = Duration::from_millis(20);
commit("CREATE (:N {id: 1})");
let small = wal_len();
assert!(small > 5, "the commit must have been logged");
let task = spawn_wal_size_checkpoint_task(
Arc::clone(&session),
path.clone(),
Arc::clone(&state),
small + 10_000,
poll,
);
tokio::time::sleep(poll * 8).await;
assert!(
!path.exists(),
"a log under the threshold must not checkpoint"
);
assert_eq!(wal_len(), small);
for id in 2..400 {
commit(&format!("CREATE (:N {{id: {id}}})"));
}
assert!(wal_len() > small + 10_000, "the log must now be oversized");
let deadline = Instant::now() + Duration::from_secs(10);
while wal_len() > 5 && Instant::now() < deadline {
tokio::time::sleep(poll).await;
}
task.abort();
let _ = task.await;
assert!(path.exists(), "the checkpoint must have written the graph");
assert_eq!(
wal_len(),
5,
"the checkpoint must have cut the log to its header"
);
let _ = std::fs::remove_dir_all(&scratch);
}
#[tokio::test]
async fn the_wal_size_task_checkpoints_online_while_writers_keep_committing() {
use kglite::api::session::{execute_mut, CommitOutcome, ExecuteOptions};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Instant;
let scratch = scratch_dir("walonline");
let path = scratch.join("graph.kgl");
let started = start_graph(
&path,
Some(StorageMode::Memory),
false,
DurabilityRequest {
level: DurabilityLevel::Normal,
explicit: true,
},
None,
&mut |_| {},
)
.expect("a durable memory graph");
let session = Arc::new(started.session);
let lease = started.writer_lease;
let wal_header =
std::fs::metadata(kglite::api::durable::wal_path(&path)).map_or(0, |m| m.len());
assert!(wal_header > 0, "a durable open writes the log header");
let state: CheckpointState = Arc::default();
let stop = Arc::new(AtomicBool::new(false));
let progress = Arc::new(AtomicU64::new(0));
let writer = {
let (session, stop, progress) = (
Arc::clone(&session),
Arc::clone(&stop),
Arc::clone(&progress),
);
std::thread::spawn(move || {
let mut acked = 0u64;
while !stop.load(Ordering::Relaxed) {
let params = HashMap::new();
let mut tx = session.begin();
execute_mut(
tx.working_mut().expect("working copy"),
&format!("CREATE (:N {{id: {acked}, pad: '{}'}})", "x".repeat(200)),
&ExecuteOptions::eager(¶ms),
)
.expect("mutation");
match session.commit(tx, true) {
CommitOutcome::Committed { .. } => {
acked += 1;
progress.store(acked, Ordering::Relaxed);
}
other => panic!("commit must not fail: {other:?}"),
}
}
acked
})
};
let poll = Duration::from_millis(10);
let task = spawn_wal_size_checkpoint_task(
Arc::clone(&session),
path.clone(),
Arc::clone(&state),
8 * 1024,
poll,
);
let wal = kglite::api::durable::wal_path(&path);
let wal_len = || std::fs::metadata(&wal).map_or(0, |m| m.len());
let deadline = Instant::now() + Duration::from_secs(60);
while state.lock().unwrap().is_none() && Instant::now() < deadline {
tokio::time::sleep(poll).await;
}
while progress.load(Ordering::Relaxed) < 600 && Instant::now() < deadline {
tokio::time::sleep(poll).await;
}
stop.store(true, Ordering::Relaxed);
let acked = writer.join().expect("writer thread");
assert!(
state.lock().unwrap().is_some(),
"an online checkpoint must have run"
);
let deadline = Instant::now() + Duration::from_secs(60);
let bound = || (8 * 1024).max(std::fs::metadata(&path).map_or(0, |m| m.len())) + wal_header;
while wal_len() > bound() && Instant::now() < deadline {
tokio::time::sleep(poll).await;
}
task.abort();
let _ = task.await;
assert!(
acked >= 600,
"the writer must have outrun the threshold ({acked} commits)"
);
let settled = wal_len();
assert!(
settled <= bound(),
"the log must have been trimmed along the way: {settled} bytes for {acked} commits"
);
drop(session);
drop(lease);
let reopened = start_graph(
&path,
Some(StorageMode::Memory),
false,
DurabilityRequest {
level: DurabilityLevel::Normal,
explicit: true,
},
None,
&mut |_| {},
)
.expect("reopen");
let rows = kglite::api::session::execute_read(
&reopened.session.snapshot(),
"MATCH (n:N) RETURN count(n) AS c",
&ExecuteOptions::eager(&HashMap::new()),
)
.expect("count")
.result
.rows;
assert_eq!(format!("{:?}", rows[0][0]), format!("Int64({acked})"));
let _ = std::fs::remove_dir_all(&scratch);
}
#[test]
fn checkpoint_wal_mib_accepts_whole_numbers_and_zero() {
assert_eq!(parse_checkpoint_wal_mib(" 64 "), Ok(64));
assert_eq!(parse_checkpoint_wal_mib("0"), Ok(0));
assert!(parse_checkpoint_wal_mib("32MiB").is_err());
assert!(parse_checkpoint_wal_mib("-1").is_err());
}
#[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 writer_admission_flags_default_to_queue_and_accept_fractions() {
let cli = Cli::parse_from(["kglite-bolt-server", "--graph", "g.kgl"]);
assert!(matches!(cli.write_concurrency, WriteConcurrencyArg::Queue));
assert_eq!(cli.writer_wait_timeout, 20.0);
assert_eq!(cli.writer_idle_timeout, 10.0);
let cli = Cli::parse_from([
"kglite-bolt-server",
"--graph",
"g.kgl",
"--write-concurrency",
"optimistic",
"--writer-wait-timeout",
"0.5",
"--writer-idle-timeout",
"0",
]);
assert!(matches!(
cli.write_concurrency,
WriteConcurrencyArg::Optimistic
));
assert_eq!(
timeout_from_secs(cli.writer_wait_timeout),
Some(Duration::from_millis(500))
);
assert_eq!(timeout_from_secs(cli.writer_idle_timeout), None);
assert!(parse_timeout_secs("-1").is_err());
assert!(parse_timeout_secs("nan").is_err());
}
#[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();
}
}