use std::fs::{self, File, OpenOptions, TryLockError};
use std::marker::PhantomData;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::inbox::{
BackendError, Closed, Disposition, Inbox, InboxBackend, Reply, Sender, Shared, Undelivered,
};
use crate::schema::{Message, SchemaId};
pub const SPOOL_WAIT: Duration = Duration::from_secs(30);
pub const SPOOL_SCHEMA_VERSION: u32 = 1;
const POLL: Duration = Duration::from_millis(20);
const DECLARATION: &str = "spool.json";
const RECEIVER_LOCK: &str = "receiver.lock";
const CLOSED_RECORD: &str = "closed.json";
const OFFER: &str = ".offer.json";
const TAKEN: &str = ".taken.json";
const ANSWER: &str = ".answer.json";
const WITHDRAWN: &str = ".withdrawn";
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Declaration {
schema_version: u32,
schema: SchemaId,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ClosedRecord {
schema_version: u32,
reason: String,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Offer {
schema_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
schema: Option<SchemaId>,
message: Value,
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AnswerDocument {
schema_version: u32,
answer: AnswerBody,
}
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
enum AnswerBody {
Disposition(Value),
Closed(Closed),
Refused {
reason: String,
},
}
pub struct Spool {
dir: PathBuf,
stop: Arc<AtomicBool>,
courier: Option<JoinHandle<()>>,
}
impl std::fmt::Debug for Spool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Spool")
.field("dir", &self.dir)
.finish_non_exhaustive()
}
}
impl Spool {
pub fn bind<M, D>(dir: impl Into<PathBuf>, inbox: &Inbox<M, D>) -> Result<Self, BackendError>
where
M: Message + Send + 'static,
D: Disposition,
{
let dir = dir.into();
fs::create_dir_all(&dir).map_err(|failure| BackendError::io("create", &dir, &failure))?;
let lock_path = dir.join(RECEIVER_LOCK);
let lock = OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(&lock_path)
.map_err(|failure| BackendError::io("open", &lock_path, &failure))?;
match lock.try_lock() {
Ok(()) => {}
Err(TryLockError::WouldBlock) => return Err(BackendError::Bound { path: dir }),
Err(TryLockError::Error(failure)) => {
return Err(BackendError::io("lock", &lock_path, &failure))
}
}
write_document(
&dir.join(DECLARATION),
&Declaration {
schema_version: SPOOL_SCHEMA_VERSION,
schema: M::SCHEMA,
},
)?;
remove_if_present(&dir.join(CLOSED_RECORD))?;
let shared = inbox.shared();
let closing = dir.clone();
shared.on_close(move |closed| close_spool(&closing, closed));
let stop = Arc::new(AtomicBool::new(false));
let courier = {
let serving = dir.clone();
let stop = Arc::clone(&stop);
std::thread::Builder::new()
.name("onemessagebus-spool".to_owned())
.spawn(move || serve(&serving, &shared, &stop, lock))
.map_err(|failure| BackendError::io("start the courier for", &dir, &failure))?
};
Ok(Self {
dir,
stop,
courier: Some(courier),
})
}
#[must_use]
pub fn address(&self) -> &Path {
&self.dir
}
#[must_use]
pub fn connect<M: Message + 'static, D: Disposition>(
address: impl Into<PathBuf>,
) -> Sender<M, D> {
Self::connect_within(address, SPOOL_WAIT)
}
#[must_use]
pub fn connect_within<M: Message + 'static, D: Disposition>(
address: impl Into<PathBuf>,
wait: Duration,
) -> Sender<M, D> {
Sender::over(Connection::<M, D> {
dir: address.into(),
wait,
types: PhantomData,
})
}
pub fn declared(address: &Path) -> Result<Option<SchemaId>, BackendError> {
spool_dir(address)?;
let path = address.join(DECLARATION);
Ok(read_document::<Declaration>(address, &path)?.map(|declaration| declaration.schema))
}
pub fn deliver(address: &Path, message: &Value, wait: Duration) -> Result<Value, Undelivered> {
let schema = Self::declared(address)?;
exchange(address, schema, message.clone(), wait).map(|(disposition, _)| disposition)
}
}
impl Drop for Spool {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(courier) = self.courier.take() {
let _ = courier.join();
}
}
}
struct Connection<M, D> {
dir: PathBuf,
wait: Duration,
types: PhantomData<fn(M) -> D>,
}
impl<M: Message, D: Disposition> InboxBackend<M, D> for Connection<M, D> {
fn send(&self, message: M) -> Result<D, Undelivered> {
let message = serde_json::to_value(&message).map_err(|failure| BackendError::Encoding {
what: "message",
why: failure.to_string(),
})?;
let (disposition, file) = exchange(&self.dir, Some(M::SCHEMA), message, self.wait)?;
serde_json::from_value(disposition).map_err(|failure| {
Undelivered::Backend(BackendError::Unreadable {
at: self.dir.clone(),
file,
why: format!("its disposition is not one this sender reads: {failure}"),
})
})
}
}
fn spool_dir(address: &Path) -> Result<(), BackendError> {
if address.is_dir() {
return Ok(());
}
Err(BackendError::Absent {
what: "a spool",
path: address.to_path_buf(),
why: if address.exists() {
"it is not a directory".to_owned()
} else {
"nothing is there".to_owned()
},
})
}
fn exchange(
dir: &Path,
schema: Option<SchemaId>,
message: Value,
wait: Duration,
) -> Result<(Value, PathBuf), Undelivered> {
spool_dir(dir)?;
if let Some(closed) = closed_record(dir)? {
return Err(Undelivered::Closed(closed));
}
let id = mint();
let offer = dir.join(format!("{id}{OFFER}"));
let taken = dir.join(format!("{id}{TAKEN}"));
let answer = dir.join(format!("{id}{ANSWER}"));
write_document(
&offer,
&Offer {
schema_version: SPOOL_SCHEMA_VERSION,
schema,
message,
},
)?;
let started = Instant::now();
loop {
if let Some(disposition) = read_answer(dir, &answer)? {
return Ok((disposition, answer));
}
if offer.exists() {
if let Some(closed) = closed_record(dir)? {
if withdraw(dir, &id) {
return Err(Undelivered::Closed(closed));
}
continue;
}
if started.elapsed() >= wait {
if withdraw(dir, &id) {
return Err(Undelivered::Backend(BackendError::Elapsed {
spool: dir.to_path_buf(),
waited: wait,
}));
}
continue;
}
} else if !taken.exists() || !receiver_bound(dir) {
if let Some(disposition) = read_answer(dir, &answer)? {
return Ok((disposition, answer));
}
if let Some(closed) = closed_record(dir)? {
let _ = fs::remove_file(&taken);
return Err(Undelivered::Closed(closed));
}
let _ = fs::remove_file(&taken);
return Err(Undelivered::Backend(BackendError::Abandoned {
spool: dir.to_path_buf(),
file: taken,
}));
}
std::thread::sleep(POLL);
}
}
fn read_answer(dir: &Path, path: &Path) -> Result<Option<Value>, Undelivered> {
let Some(document) = read_document::<AnswerDocument>(dir, path)? else {
return Ok(None);
};
let _ = fs::remove_file(path);
match document.answer {
AnswerBody::Disposition(disposition) => Ok(Some(disposition)),
AnswerBody::Closed(closed) => Err(Undelivered::Closed(closed)),
AnswerBody::Refused { reason } => Err(Undelivered::Backend(BackendError::Refused {
spool: dir.to_path_buf(),
why: reason,
})),
}
}
fn closed_record(dir: &Path) -> Result<Option<Closed>, BackendError> {
Ok(
read_document::<ClosedRecord>(dir, &dir.join(CLOSED_RECORD))?
.map(|record| Closed::new(record.reason)),
)
}
fn receiver_bound(dir: &Path) -> bool {
let Ok(lock) = OpenOptions::new().write(true).open(dir.join(RECEIVER_LOCK)) else {
return false;
};
!matches!(lock.try_lock(), Ok(()))
}
fn withdraw(dir: &Path, id: &str) -> bool {
let withdrawn = dir.join(format!("{id}{WITHDRAWN}"));
if fs::rename(dir.join(format!("{id}{OFFER}")), &withdrawn).is_err() {
return false;
}
let _ = fs::remove_file(withdrawn);
true
}
fn serve<M, D>(dir: &Path, shared: &Arc<Shared<M, D>>, stop: &AtomicBool, lock: File)
where
M: Message + Send + 'static,
D: Disposition,
{
while !stop.load(Ordering::SeqCst) && shared.closed().is_none() {
for id in offered(dir) {
take_offer(dir, &id, shared);
}
std::thread::sleep(POLL);
}
drop(lock);
}
fn offered(dir: &Path) -> Vec<String> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut ids: Vec<String> = entries
.flatten()
.filter_map(|entry| {
entry
.file_name()
.to_str()
.and_then(|name| name.strip_suffix(OFFER))
.map(str::to_owned)
})
.collect();
ids.sort();
ids
}
fn take_offer<M, D>(dir: &Path, id: &str, shared: &Arc<Shared<M, D>>)
where
M: Message + Send + 'static,
D: Disposition,
{
let taken = dir.join(format!("{id}{TAKEN}"));
if fs::rename(dir.join(format!("{id}{OFFER}")), &taken).is_err() {
return;
}
let message = match read_offer::<M>(dir, &taken) {
Ok(message) => message,
Err(reason) => {
settle(dir, id, &AnswerBody::Refused { reason });
return;
}
};
let replying = dir.to_path_buf();
let replying_id = id.to_owned();
let _ = shared.offer(
message,
Reply::to(move |answer: Result<D, Closed>| {
let body = match answer {
Ok(disposition) => match serde_json::to_value(&disposition) {
Ok(value) => AnswerBody::Disposition(value),
Err(failure) => AnswerBody::Refused {
reason: format!("its disposition does not serialize: {failure}"),
},
},
Err(closed) => AnswerBody::Closed(closed),
};
settle(&replying, &replying_id, &body);
}),
);
}
fn read_offer<M: Message>(dir: &Path, path: &Path) -> Result<M, String> {
let offer = read_document::<Offer>(dir, path)
.map_err(|failure| failure.to_string())?
.ok_or_else(|| format!("{} was removed before it was read", path.display()))?;
if let Some(schema) = &offer.schema {
if *schema != M::SCHEMA {
return Err(format!(
"the message is {schema}, and this receiver takes {}",
M::SCHEMA
));
}
}
serde_json::from_value(offer.message)
.map_err(|failure| format!("the message is not a {}: {failure}", M::SCHEMA))
}
fn settle(dir: &Path, id: &str, body: &AnswerBody) {
let taken = dir.join(format!("{id}{TAKEN}"));
if !taken.exists() {
return;
}
let _ = write_document(
&dir.join(format!("{id}{ANSWER}")),
&AnswerDocument {
schema_version: SPOOL_SCHEMA_VERSION,
answer: clone_body(body),
},
);
let _ = fs::remove_file(taken);
}
fn clone_body(body: &AnswerBody) -> AnswerBody {
match body {
AnswerBody::Disposition(value) => AnswerBody::Disposition(value.clone()),
AnswerBody::Closed(closed) => AnswerBody::Closed(closed.clone()),
AnswerBody::Refused { reason } => AnswerBody::Refused {
reason: reason.clone(),
},
}
}
fn close_spool(dir: &Path, closed: &Closed) {
let _ = write_document(
&dir.join(CLOSED_RECORD),
&ClosedRecord {
schema_version: SPOOL_SCHEMA_VERSION,
reason: closed.reason.clone(),
},
);
for id in offered(dir) {
if fs::rename(
dir.join(format!("{id}{OFFER}")),
dir.join(format!("{id}{TAKEN}")),
)
.is_ok()
{
settle(dir, &id, &AnswerBody::Closed(closed.clone()));
}
}
}
fn read_document<T: DeserializeOwned>(at: &Path, path: &Path) -> Result<Option<T>, BackendError> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(failure) => return Err(BackendError::io("read", path, &failure)),
};
let unreadable = |why: String| BackendError::Unreadable {
at: at.to_path_buf(),
file: path.to_path_buf(),
why,
};
let document: Value =
serde_json::from_str(&text).map_err(|failure| unreadable(failure.to_string()))?;
let version = document.get("schema_version").and_then(Value::as_u64);
if version != Some(u64::from(SPOOL_SCHEMA_VERSION)) {
return Err(unreadable(format!(
"it declares schema_version {}, and this build reads {SPOOL_SCHEMA_VERSION}",
version.map_or_else(|| "none".to_owned(), |v| v.to_string())
)));
}
serde_json::from_value(document)
.map(Some)
.map_err(|failure| unreadable(failure.to_string()))
}
fn write_document<T: Serialize>(path: &Path, document: &T) -> Result<(), BackendError> {
let text = serde_json::to_string(document).map_err(|failure| BackendError::Encoding {
what: "document",
why: failure.to_string(),
})?;
let mut staging = path.as_os_str().to_owned();
staging.push(".staging");
let staging = PathBuf::from(staging);
fs::write(&staging, text).map_err(|failure| BackendError::io("write", &staging, &failure))?;
fs::rename(&staging, path).map_err(|failure| BackendError::io("rename onto", path, &failure))
}
fn remove_if_present(path: &Path) -> Result<(), BackendError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(failure) => Err(BackendError::io("remove", path, &failure)),
}
}
fn mint() -> String {
static MINTED: AtomicU64 = AtomicU64::new(0);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_nanos());
format!(
"{now:039}-{}-{:020}",
std::process::id(),
MINTED.fetch_add(1, Ordering::Relaxed)
)
}