use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result};
use kglite::api::durable::DurabilityLevel;
use kglite::api::io::{open_or_create_graph_in_mode, GraphWriterLease, OpenDisposition};
use kglite::api::session::Session;
use kglite::api::storage::{live_storage_mode, StorageMode};
const WRITER_LEASE_TIMEOUT: Duration = Duration::ZERO;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum StartupStep {
LeaseAcquired,
GraphOpened,
SessionOpened,
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct DurabilityRequest {
pub(crate) level: DurabilityLevel,
pub(crate) explicit: bool,
}
pub(crate) struct StartedGraph {
pub(crate) session: Session,
pub(crate) level: DurabilityLevel,
pub(crate) disposition: OpenDisposition,
pub(crate) live_mode: StorageMode,
pub(crate) converted_from: Option<StorageMode>,
pub(crate) writer_lease: Option<GraphWriterLease>,
}
pub(crate) fn start_graph(
path: &Path,
requested_mode: Option<StorageMode>,
readonly: bool,
durability: DurabilityRequest,
record: &mut dyn FnMut(StartupStep),
) -> Result<StartedGraph> {
let writer_lease = if readonly {
None
} else {
let lease = GraphWriterLease::acquire(path, WRITER_LEASE_TIMEOUT).with_context(|| {
format!(
"acquiring the writer lease for {}; pass --readonly to serve this graph \
alongside its writer",
path.display()
)
})?;
record(StartupStep::LeaseAcquired);
Some(lease)
};
let opened = open_or_create_graph_in_mode(path, requested_mode, durability.level)
.with_context(|| format!("opening or creating {}", path.display()))?;
record(StartupStep::GraphOpened);
let live_mode = live_storage_mode(&opened.graph);
let level = if durability.level.logs() && !durability.explicit && live_mode == StorageMode::Disk
{
tracing::info!(
default_level = durability.level.name(),
"disk-mode graph: serving at durability off (a disk graph commits by \
publishing a generation, so it carries no write-ahead log)"
);
DurabilityLevel::Off
} else {
durability.level
};
let session =
Session::open_durable(opened.graph, &path.to_string_lossy(), level).map_err(|e| {
anyhow::anyhow!(
"opening {} at --durability {}: {e}",
path.display(),
level.name()
)
})?;
record(StartupStep::SessionOpened);
Ok(StartedGraph {
session,
level,
disposition: opened.disposition,
live_mode,
converted_from: opened.converted_from,
writer_lease,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
fn explicit(level: DurabilityLevel) -> DurabilityRequest {
DurabilityRequest {
level,
explicit: true,
}
}
fn defaulted(level: DurabilityLevel) -> DurabilityRequest {
DurabilityRequest {
level,
explicit: false,
}
}
struct ScratchDir(PathBuf);
impl ScratchDir {
fn new(tag: &str) -> Self {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!(
"kglite-bolt-startup-{tag}-{}-{nonce}",
std::process::id()
));
std::fs::create_dir_all(&dir).expect("create scratch dir");
Self(dir)
}
fn graph_path(&self) -> PathBuf {
self.0.join("graph.kgl")
}
}
impl Drop for ScratchDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn writable_startup_takes_the_lease_before_opening_the_graph() {
let scratch = ScratchDir::new("order");
let mut steps = Vec::new();
let started = start_graph(
&scratch.graph_path(),
Some(StorageMode::Memory),
false,
explicit(DurabilityLevel::Off),
&mut |step| steps.push(step),
)
.expect("writable startup on a free path");
assert_eq!(
steps,
vec![
StartupStep::LeaseAcquired,
StartupStep::GraphOpened,
StartupStep::SessionOpened
]
);
assert!(
started.writer_lease.is_some(),
"a writable server must retain the lease it acquired"
);
}
#[test]
fn second_writable_startup_fails_naming_the_holder() {
let scratch = ScratchDir::new("contended");
let path = scratch.graph_path();
let held = GraphWriterLease::acquire(&path, Duration::ZERO).expect("first writer");
let mut steps = Vec::new();
let error = match start_graph(
&path,
Some(StorageMode::Memory),
false,
explicit(DurabilityLevel::Off),
&mut |step| steps.push(step),
) {
Ok(_) => panic!("a second writable server must not open a leased graph"),
Err(error) => error,
};
let message = format!("{error:#}");
assert!(
message.contains(&format!("pid {}", std::process::id())),
"refusal must name the holding process: {message}"
);
assert!(
message.contains("only one process may write a graph at a time"),
"refusal must explain the constraint: {message}"
);
assert!(
steps.is_empty(),
"a refused writer must not have touched the graph: {steps:?}"
);
drop(held);
}
fn seed_graph(path: &Path, mode: StorageMode) {
let mut graph = Arc::new(
kglite::api::storage::new_dir_graph_in_mode(mode, None).expect("portable mode"),
);
kglite::api::io::save_graph(&mut graph, &path.to_string_lossy()).expect("seed save");
}
fn start(path: &Path, requested: Option<StorageMode>) -> Result<StartedGraph> {
start_graph(
path,
requested,
false,
explicit(DurabilityLevel::Off),
&mut |_| {},
)
}
#[test]
fn no_storage_flag_serves_the_mode_the_checkpoint_recorded() {
let scratch = ScratchDir::new("recorded");
let path = scratch.graph_path();
seed_graph(&path, StorageMode::Mapped);
let started = start(&path, None).expect("startup on a mapped checkpoint");
assert_eq!(
started.live_mode,
StorageMode::Mapped,
"with no --storage the file decides, and this one recorded mapped"
);
}
#[test]
fn explicit_matching_storage_proceeds_without_converting() {
let scratch = ScratchDir::new("agree");
let path = scratch.graph_path();
seed_graph(&path, StorageMode::Mapped);
let started = start(&path, Some(StorageMode::Mapped)).expect("agreeing request");
assert_eq!(started.live_mode, StorageMode::Mapped);
assert_eq!(
started.converted_from, None,
"nothing was converted, so nothing may be reported as converted"
);
}
#[test]
fn explicit_mismatching_portable_storage_converts_and_reports_it() {
for (recorded, requested) in [
(StorageMode::Memory, StorageMode::Mapped),
(StorageMode::Mapped, StorageMode::Memory),
] {
let scratch = ScratchDir::new("convert");
let path = scratch.graph_path();
seed_graph(&path, recorded);
let mut steps = Vec::new();
let started = start_graph(
&path,
Some(requested),
false,
explicit(DurabilityLevel::Off),
&mut |step| steps.push(step),
)
.expect("portable conversion at startup");
assert_eq!(
started.live_mode, requested,
"the server must serve the mode the operator asked for"
);
assert_eq!(
started.converted_from,
Some(recorded),
"a conversion the operator did not see is as bad as an ignored flag"
);
assert_eq!(
steps,
vec![
StartupStep::LeaseAcquired,
StartupStep::GraphOpened,
StartupStep::SessionOpened
],
"converting must not disturb the acquire-then-open order"
);
}
}
#[test]
fn disk_request_on_a_portable_graph_is_refused_with_the_core_reason() {
let scratch = ScratchDir::new("disk-request");
let path = scratch.graph_path();
seed_graph(&path, StorageMode::Memory);
let error = match start(&path, Some(StorageMode::Disk)) {
Ok(_) => panic!("a disk request on a `.kgl` must not be silently ignored"),
Err(error) => format!("{error:#}"),
};
assert!(
error.contains("enable_disk_mode()") && error.contains("directory"),
"the refusal must carry the core reason and remedy: {error}"
);
start(&path, None).expect("the path stays openable after a refusal");
}
#[test]
fn readonly_startup_succeeds_beside_a_held_lease() {
let scratch = ScratchDir::new("readonly");
let path = scratch.graph_path();
let held = GraphWriterLease::acquire(&path, Duration::ZERO).expect("writer lease");
let mut steps = Vec::new();
let started = start_graph(
&path,
Some(StorageMode::Memory),
true,
explicit(DurabilityLevel::Off),
&mut |step| steps.push(step),
)
.expect("readonly startup beside a live writer");
assert_eq!(
steps,
vec![StartupStep::GraphOpened, StartupStep::SessionOpened]
);
assert!(
started.writer_lease.is_none(),
"a readonly server must not take write ownership"
);
drop(held);
}
fn commit_write(session: &Session, query: &str) {
use kglite::api::session::{execute_mut, CommitOutcome, ExecuteOptions};
let params = std::collections::HashMap::new();
let mut tx = session.begin();
execute_mut(
tx.working_mut().expect("a fresh transaction is writable"),
query,
&ExecuteOptions::eager(¶ms),
)
.expect("the mutation runs");
match session.commit(tx, true) {
CommitOutcome::Committed { .. } => {}
other => panic!("the commit must publish: {other:?}"),
}
}
fn person_count(session: &Session) -> i64 {
use kglite::api::session::{execute_read, ExecuteOptions};
let params = std::collections::HashMap::new();
let snapshot = session.snapshot();
let outcome = execute_read(
&snapshot,
"MATCH (p:Person) RETURN count(p) AS c",
&ExecuteOptions::eager(¶ms),
)
.expect("the count runs");
match outcome.result.rows.first().and_then(|row| row.first()) {
Some(kglite::api::Value::Int64(c)) => *c,
other => panic!("expected a count, got {other:?}"),
}
}
#[test]
fn a_crashed_durable_session_replays_on_the_next_startup() {
for level in [DurabilityLevel::Full, DurabilityLevel::Normal] {
let scratch = ScratchDir::new("replay");
let path = scratch.graph_path();
seed_graph(&path, StorageMode::Memory);
let first = start_graph(&path, None, false, explicit(level), &mut |_| {})
.expect("durable startup on a saved graph");
commit_write(&first.session, "CREATE (:Person {id: 1, title: 'Zed'})");
assert_eq!(person_count(&first.session), 1);
drop(first);
let reopened = start_graph(&path, None, false, explicit(level), &mut |_| {})
.unwrap_or_else(|e| panic!("durable restart at {}: {e:#}", level.name()));
assert_eq!(
person_count(&reopened.session),
1,
"a committed write must survive a crash-shaped restart at {} with no \
checkpoint in between",
level.name()
);
}
}
#[test]
fn startup_at_off_refuses_an_unrecovered_sidecar() {
let scratch = ScratchDir::new("off-refusal");
let path = scratch.graph_path();
seed_graph(&path, StorageMode::Memory);
let durable = start_graph(
&path,
None,
false,
explicit(DurabilityLevel::Full),
&mut |_| {},
)
.expect("durable startup on a saved graph");
commit_write(&durable.session, "CREATE (:Person {id: 1, title: 'Zed'})");
drop(durable);
let error = match start_graph(
&path,
None,
false,
explicit(DurabilityLevel::Off),
&mut |_| {},
) {
Ok(_) => panic!("serving at off would silently drop the logged commit"),
Err(error) => format!("{error:#}"),
};
assert!(
error.contains("graph.kgl-wal") && error.contains("'full' or 'normal'"),
"the refusal must name the sidecar and the way out: {error}"
);
let recovered = start_graph(
&path,
None,
false,
explicit(DurabilityLevel::Full),
&mut |_| {},
)
.expect("the refusal is about the level, not the path");
assert_eq!(person_count(&recovered.session), 1);
}
#[test]
fn a_default_level_degrades_to_off_for_a_disk_graph() {
let scratch = ScratchDir::new("disk-default");
let path = scratch.0.join("disk-graph");
let started = start_graph(
&path,
Some(StorageMode::Disk),
false,
defaulted(DurabilityLevel::Normal),
&mut |_| {},
)
.expect("a disk graph must still be servable once the default logs");
assert_eq!(started.live_mode, StorageMode::Disk);
assert_eq!(
started.level,
DurabilityLevel::Off,
"the degraded level is what the caller must serve and report"
);
assert_eq!(
started.session.durability(),
None,
"a degraded session must carry no log at all"
);
}
#[test]
fn an_explicit_level_is_still_refused_for_a_disk_graph() {
let scratch = ScratchDir::new("disk-explicit");
let path = scratch.0.join("disk-graph");
let error = match start_graph(
&path,
Some(StorageMode::Disk),
false,
explicit(DurabilityLevel::Normal),
&mut |_| {},
) {
Ok(_) => panic!("an operator who asked for a log must not be given none"),
Err(error) => format!("{error:#}"),
};
assert!(
error.contains("storage='disk'"),
"the refusal must name the reason: {error}"
);
}
}