use std::collections::BTreeMap;
use std::error::Error;
use std::num::{NonZeroU32, NonZeroU64, NonZeroUsize};
use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use haematite::db::{DatabaseConfig, ReadOnlyDatabase};
use haematite::store::NodeCacheBudget;
use serde_json::value::RawValue;
use serde_json::{Value, json};
use super::{ChangesRequest, Context as ChangesContext, Moment, Settings as ChangesSettings, list};
use crate::serve::access::{self, Standalone};
use crate::serve::protocol::{
Change, CollectionWrite, DocumentWrite, Mutation, QueryPage, QueryRequest, Selection, Version,
};
use crate::serve::query::admission::Slots;
use crate::serve::query::run::{self, Context as QueryContext};
use crate::serve::query::{Rows, Seal, Settings as QuerySettings};
use crate::serve::storage::{Catalogue, Failure, Storage, after_held};
type TestResult = Result<(), Box<dyn Error>>;
const BOUND: Duration = Duration::from_secs(60);
struct Fixture {
directory: tempfile::TempDir,
storage: Storage,
reader: ReadOnlyDatabase,
catalogue: Catalogue,
}
fn head() -> Selection {
Selection {
branch: "main".to_owned(),
version: None,
}
}
fn write(
storage: &mut Storage,
request_id: &str,
change: Change,
) -> Result<Version, Box<dyn Error>> {
let mutation = Mutation {
request_id: request_id.to_owned(),
branch: "main".to_owned(),
expected: storage.capture(&head())?.version,
change,
};
Ok(storage.mutate(&mutation)?.version)
}
fn put(id: &str) -> Result<Change, Box<dyn Error>> {
Ok(Change::Put(DocumentWrite {
collection: "records".to_owned(),
document_id: id.to_owned(),
document: RawValue::from_string(format!(r#"{{"id":"{id}"}}"#))?,
}))
}
fn fixture(ids: &[&str]) -> Result<Fixture, Box<dyn Error>> {
let directory = tempfile::tempdir()?;
let mut storage = Storage::create(
DatabaseConfig {
data_dir: directory.path().join("database"),
shard_count: 2,
distributed: None,
executor_threads: Some(1),
node_cache_budget: Some(NodeCacheBudget::Unlimited),
retention: haematite::Retention::reclaim(0),
},
NodeCacheBudget::Unlimited,
fixture_long_wait_interval(),
)?;
let declare = Change::Collection(CollectionWrite {
collection: "records".to_owned(),
schema: None,
});
write(&mut storage, "declare", declare)?;
for id in ids {
write(&mut storage, id, put(id)?)?;
}
let reader = storage.reader()?;
let catalogue = storage.catalogue();
Ok(Fixture {
directory,
storage,
reader,
catalogue,
})
}
fn across_a_pass<T: Send>(
storage: &mut Storage,
skip: usize,
read: impl FnOnce() -> Result<T, Failure> + Send,
between: impl FnOnce(&mut Storage) -> TestResult,
) -> Result<Result<T, Failure>, Box<dyn Error>> {
let (held, reached) = mpsc::channel::<()>();
let (resume, resumed) = mpsc::channel::<()>();
std::thread::scope(|scope| -> Result<Result<T, Failure>, Box<dyn Error>> {
let reading = scope.spawn(move || {
after_held::set(skip, move || {
if held.send(()).is_ok() {
let _resumed = resumed.recv_timeout(BOUND);
}
});
read()
});
let moved = reached
.recv_timeout(BOUND)
.map_err(|_timeout| Box::<dyn Error>::from("the read held no capture"))
.and_then(|()| between(storage));
let _told = resume.send(());
let answer = reading
.join()
.map_err(|_panic| "the reading thread panicked")?;
moved.map(|()| answer)
})
}
fn move_on_and_pass(storage: &mut Storage) -> TestResult {
write(storage, "later", put("later")?)?;
storage.activate_reclaim()?;
storage.quiesce_reclaim()?;
Ok(())
}
fn query_settings() -> QuerySettings {
QuerySettings {
time_ms: NonZeroU64::MIN.saturating_add(999),
memory_bytes: NonZeroU64::MIN.saturating_add((1 << 26) - 1),
outputs_per_record: NonZeroU64::MIN,
scan_records: NonZeroU64::MIN.saturating_add(98),
max_running: NonZeroUsize::MIN,
worker: PathBuf::from("/no/such/haematite-jq"),
}
}
fn origins(previous: Option<&Value>, rows: u64) -> Result<QueryRequest, Box<dyn Error>> {
let mut selection = head();
let continuation = if let Some(answer) = previous {
selection.version = Some(serde_json::from_value(
answer["selection"]["version"].clone(),
)?);
Some(answer["next"].as_str().ok_or("no next")?.to_owned())
} else {
None
};
Ok(QueryRequest {
selection,
query: ".".to_owned(),
parameters: BTreeMap::new(),
page: QueryPage {
rows: NonZeroU64::new(rows).ok_or("zero rows")?,
bytes: NonZeroU64::MIN.saturating_add((1 << 20) - 1),
},
continuation,
rows: Rows::Origins,
})
}
fn query(
reader: &ReadOnlyDatabase,
catalogue: &Catalogue,
seal: &Seal,
request: &QueryRequest,
) -> Result<Value, Failure> {
let access = access::request(&Standalone)?;
let settings = query_settings();
let slots = Slots::new(NonZeroUsize::MIN);
let context = QueryContext {
access: &access,
reader,
catalogue,
slots: &slots,
settings: Some(&settings),
max_page: NonZeroU32::MIN.saturating_add(99),
seal,
};
let text = run::run(&context, request, None)?;
serde_json::from_str(text.get()).map_err(Failure::storage)
}
fn given(answer: &Value) -> Result<Vec<String>, Box<dyn Error>> {
let rows = answer["rows"].as_array().ok_or("no rows array")?;
rows.iter()
.map(|row| {
let origin = &row["origin"];
let collection = origin["collection"].as_str().ok_or("no collection")?;
let id = origin["id"].as_str().ok_or("no id")?;
Ok(format!("{collection}/{id}"))
})
.collect()
}
#[test]
fn a_page_already_begun_finishes_on_the_roots_it_captured() -> TestResult {
let mut fixture = fixture(&["r1", "r2", "r3"])?;
let read_at = fixture.storage.capture(&head())?.version;
let seal = Seal::new()?;
let request = origins(None, 100)?;
let (reader, catalogue) = (&fixture.reader, &fixture.catalogue);
let answer = across_a_pass(
&mut fixture.storage,
0,
move || query(reader, catalogue, &seal, &request),
move_on_and_pass,
)??;
assert_eq!(given(&answer)?, ["records/r1", "records/r2", "records/r3"]);
assert_eq!(answer["selection"]["version"], json!(read_at));
assert_eq!(fixture.storage.live_read_holds(), 0);
Ok(())
}
#[test]
fn the_next_page_of_a_kept_version_is_answered_after_a_pass() -> TestResult {
let mut fixture = fixture(&["r1", "r2", "r3"])?;
let seal = Seal::new()?;
let first = query(
&fixture.reader,
&fixture.catalogue,
&seal,
&origins(None, 2)?,
)?;
assert_eq!(fixture.storage.live_read_holds(), 0);
move_on_and_pass(&mut fixture.storage)?;
let second = query(
&fixture.reader,
&fixture.catalogue,
&seal,
&origins(Some(&first), 2)?,
)?;
assert_eq!(given(&second)?, ["records/r3"]);
assert_eq!(
second["selection"]["version"],
first["selection"]["version"]
);
assert_eq!(fixture.storage.live_read_holds(), 0);
Ok(())
}
#[test]
fn the_next_page_of_a_released_version_is_refused_by_name() -> TestResult {
let mut fixture = fixture(&["r1", "r2", "r3"])?;
let seal = Seal::new()?;
let first = query(
&fixture.reader,
&fixture.catalogue,
&seal,
&origins(None, 2)?,
)?;
let version: Version = serde_json::from_value(first["selection"]["version"].clone())?;
move_on_and_pass(&mut fixture.storage)?;
let released = fixture.storage.capture(&Selection {
branch: "main".to_owned(),
version: Some(version),
})?;
let mut taken = 0_usize;
for shard in &released.shards {
let hex = shard.head.to_string();
let (prefix, name) = hex.split_at(2);
let store = fixture
.directory
.path()
.join("database")
.join(format!("shard-{}", shard.shard_id))
.join("store");
match std::fs::remove_file(store.join(prefix).join(name)) {
Ok(()) => taken += 1,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
assert!(taken > 0, "no root of the version was taken");
let refused = query(
&fixture.reader,
&fixture.catalogue,
&seal,
&origins(Some(&first), 2)?,
);
let failure = refused
.err()
.ok_or("a page was answered from a released version")?;
assert_eq!(failure.code, "version_missing", "{}", failure.message);
let named = format!("version {}.{}", version.generation, version.sequence);
assert!(failure.message.contains(&named), "{}", failure.message);
assert_eq!(fixture.storage.live_read_holds(), 0);
Ok(())
}
fn at(version: Version) -> Moment {
Moment::At(Selection {
branch: "main".to_owned(),
version: Some(version),
})
}
#[test]
fn a_changes_page_is_whole_after_a_pass_between_capture_and_answer() -> TestResult {
let mut fixture = fixture(&["r1"])?;
let from = fixture.storage.capture(&head())?.version;
let to = write(&mut fixture.storage, "r2", put("r2")?)?;
let request = ChangesRequest {
from: at(from),
to: at(to),
rows: Rows::Origins,
page: QueryPage {
rows: NonZeroU64::MIN.saturating_add(9),
bytes: NonZeroU64::MIN.saturating_add((1 << 20) - 1),
},
continuation: None,
};
let seal = Seal::new()?;
let settings = ChangesSettings::default();
let (reader, catalogue) = (&fixture.reader, &fixture.catalogue);
let answer = across_a_pass(
&mut fixture.storage,
1,
|| {
let access = access::request(&Standalone)?;
let context = ChangesContext {
access: &access,
reader,
catalogue,
settings: &settings,
seal: &seal,
};
list(&context, &request, None).and_then(|text| {
serde_json::from_str::<Value>(text.get()).map_err(Failure::storage)
})
},
move_on_and_pass,
)??;
assert_eq!(given(&answer)?, ["records/r2"]);
assert_eq!(answer["rows"][0]["kind"], "added");
assert_eq!(answer["rows"][0]["after"], json!(to));
assert_eq!(fixture.storage.live_read_holds(), 0);
Ok(())
}
const fn fixture_long_wait_interval() -> std::time::Duration {
std::time::Duration::from_secs(1)
}