use std::collections::HashSet;
use std::io::Write;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use super::codec::Codec;
use super::error::PersistError;
use super::wal::{
RECORD_CHECKPOINT, RECORD_COMMIT, RECORD_MARK, WAL_HEADER_BYTES, WalHeader, encode_commit,
encode_mark, frame_record,
};
use crate::graph::PersistentStorage;
use crate::graph::TraversalGraph;
use crate::graph::change::ChangeSet;
use crate::graph::hooks::{CommitHook, HookError, JournalHook, JournalSlot, Mark};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Durability {
#[default]
EveryCommit,
Interval(Duration),
Os,
}
#[derive(Debug, Clone, Default)]
pub struct DurableEnd(Arc<DurableInner>);
#[derive(Debug, Default)]
struct DurableInner {
version: AtomicU64,
segment: AtomicU64,
offset: AtomicU64,
commit_seq: AtomicU64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct DurablePosition {
pub segment: u64,
pub offset: u64,
pub commit_seq: u64,
}
impl DurableEnd {
pub fn offset(&self) -> u64 {
self.position().offset
}
pub fn commit_seq(&self) -> u64 {
self.position().commit_seq
}
pub fn position(&self) -> DurablePosition {
let inner = &*self.0;
loop {
let before = inner.version.load(Ordering::Acquire);
if before % 2 == 1 {
std::hint::spin_loop();
continue;
}
let position = DurablePosition {
segment: inner.segment.load(Ordering::Relaxed),
offset: inner.offset.load(Ordering::Relaxed),
commit_seq: inner.commit_seq.load(Ordering::Relaxed),
};
std::sync::atomic::fence(Ordering::Acquire);
if inner.version.load(Ordering::Relaxed) == before {
return position;
}
}
}
fn advance(&self, segment: u64, offset: u64, commit_seq: u64) {
let inner = &*self.0;
let version = inner.version.load(Ordering::Relaxed);
inner
.version
.store(version.wrapping_add(1), Ordering::Relaxed);
std::sync::atomic::fence(Ordering::Release);
inner.segment.store(segment, Ordering::Relaxed);
inner.offset.store(offset, Ordering::Relaxed);
inner.commit_seq.store(commit_seq, Ordering::Relaxed);
inner
.version
.store(version.wrapping_add(2), Ordering::Release);
}
}
pub(crate) trait JournalFiles<W>: Send + Sync {
fn before_record(
&mut self,
writer: &mut W,
offset: u64,
next_seq: u64,
) -> std::io::Result<Option<(u64, u64)>>;
fn record_written(&mut self, bytes: u64);
fn mark_written(&mut self, mark: &Mark);
fn discard(&mut self, writer: &mut W, segment: u64, offset: u64) -> std::io::Result<()>;
}
#[derive(Debug, Clone, Default)]
pub struct SharedBuffer(Arc<parking_lot::Mutex<Vec<u8>>>);
impl SharedBuffer {
pub fn new() -> Self {
Self::default()
}
pub fn contents(&self) -> Vec<u8> {
self.0.lock().clone()
}
pub fn len(&self) -> usize {
self.0.lock().len()
}
pub fn is_empty(&self) -> bool {
self.0.lock().is_empty()
}
}
impl Write for SharedBuffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
type SyncFn<W> = Box<dyn FnMut(&mut W) -> std::io::Result<()> + Send + Sync>;
type TruncateFn<W> = Box<dyn FnMut(&mut W, u64) -> std::io::Result<()> + Send + Sync>;
pub struct Journal<W: Write + Send + Sync + 'static> {
writer: W,
durability: Durability,
sync: SyncFn<W>,
truncate: Option<TruncateFn<W>>,
codec: Codec,
last_sync: Option<web_time::Instant>,
poisoned: Option<String>,
marks: HashSet<String>,
durable: DurableEnd,
offset: u64,
segment: u64,
last_seq: u64,
record: Vec<u8>,
pending: Option<u64>,
files: Option<Box<dyn JournalFiles<W>>>,
}
impl<W: Write + Send + Sync + 'static> std::fmt::Debug for Journal<W> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Journal")
.field("durability", &self.durability)
.field("offset", &self.offset)
.field("poisoned", &self.poisoned)
.finish_non_exhaustive()
}
}
impl<W: Write + Send + Sync + 'static> Journal<W> {
pub fn new(writer: W, durability: Durability) -> Self {
Self {
writer,
durability,
sync: Box::new(|writer: &mut W| writer.flush()),
truncate: None,
codec: Codec::Lz4,
last_sync: None,
poisoned: None,
marks: HashSet::new(),
durable: DurableEnd::default(),
offset: 0,
segment: 0,
last_seq: 0,
record: Vec::new(),
pending: None,
files: None,
}
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn with_files(mut self, files: Box<dyn JournalFiles<W>>) -> Self {
self.files = Some(files);
self
}
pub fn with_sync(
mut self,
sync: impl FnMut(&mut W) -> std::io::Result<()> + Send + Sync + 'static,
) -> Self {
self.sync = Box::new(sync);
self
}
pub fn with_truncate(
mut self,
truncate: impl FnMut(&mut W, u64) -> std::io::Result<()> + Send + Sync + 'static,
) -> Self {
self.truncate = Some(Box::new(truncate));
self
}
pub fn with_known_marks<I: IntoIterator<Item = S>, S: Into<String>>(
mut self,
names: I,
) -> Self {
self.marks.extend(names.into_iter().map(Into::into));
self
}
pub fn with_codec(mut self, codec: Codec) -> Self {
self.codec = codec;
self
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn with_durable_end(mut self, durable: DurableEnd) -> Self {
self.durable = durable;
self
}
pub fn durable_end(&self) -> DurableEnd {
self.durable.clone()
}
pub fn attach(mut self, graph: &mut TraversalGraph) -> Result<DurableEnd, PersistError> {
let first_commit_seq = Self::check_attach(graph)?;
let header = WalHeader {
graph_id: graph.graph_id(),
first_commit_seq,
created_at: crate::graph::undo::now_micros(),
};
self.writer
.write_all(&header.encode())
.and_then(|()| self.writer.flush())
.and_then(|()| (self.sync)(&mut self.writer))
.map_err(|err| PersistError::io("writing the journal header", err))?;
self.register(graph, first_commit_seq, WAL_HEADER_BYTES as u64)
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn continuing(
mut self,
last_commit_seq: u64,
segment: u64,
offset: u64,
) -> (Self, DurableEnd) {
let durable = self.prime(last_commit_seq, segment, offset);
(self, durable)
}
fn check_attach(graph: &TraversalGraph) -> Result<u64, PersistError> {
if graph.undo.hooks.has_journal() {
return Err(crate::error::GraphError::Unsupported {
operation: "a second journal on one graph",
}
.into());
}
if graph.undo_depth() > 0 {
return Err(crate::error::GraphError::Unsupported {
operation: "attaching a journal while a unit is open",
}
.into());
}
Ok(graph.last_commit_seq().checked_add(1).ok_or(
crate::error::GraphError::SequenceOverflow {
sequence: crate::error::COMMIT_SEQUENCE,
},
)?)
}
fn register(
mut self,
graph: &mut TraversalGraph,
segment: u64,
offset: u64,
) -> Result<DurableEnd, PersistError> {
let durable = self.prime(graph.last_commit_seq(), segment, offset);
graph.attach_journal(JournalSlot::new(Box::new(self)))?;
Ok(durable)
}
fn prime(&mut self, last_commit_seq: u64, segment: u64, offset: u64) -> DurableEnd {
self.offset = offset;
self.segment = segment;
self.last_seq = last_commit_seq;
self.last_sync = Some(web_time::Instant::now());
self.durable.advance(segment, offset, self.last_seq);
self.durable.clone()
}
fn refuse(&mut self, start: u64, reason: String) -> HookError {
self.pending = None;
let segment = self.segment;
let cut = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
if let Some(files) = &mut self.files {
Some(files.discard(&mut self.writer, segment, start))
} else {
self.truncate
.as_mut()
.map(|truncate| truncate(&mut self.writer, start))
}
}))
.unwrap_or_else(|payload| {
Some(Err(std::io::Error::other(format!(
"the cut panicked: {}",
panic_text(payload.as_ref())
))))
});
let reason = match cut {
Some(Ok(())) => {
self.offset = start;
self.durable.advance(self.segment, start, self.last_seq);
format!("{reason} (the refused record was cut off again)")
}
Some(Err(err)) => format!(
"{reason}; cutting the refused record off failed too ({err}), its position is \
recorded so a recovery never replays it"
),
None => reason,
};
self.poison(reason)
}
fn poison(&mut self, reason: String) -> HookError {
self.poisoned = Some(reason.clone());
Box::new(PersistError::Poisoned { reason })
}
fn append(&mut self, commit_seq: u64, next_seq: u64) -> Result<(), HookError> {
if let Some(reason) = &self.poisoned {
return Err(Box::new(PersistError::Poisoned {
reason: reason.clone(),
}));
}
if let Some(files) = &mut self.files {
match files.before_record(&mut self.writer, self.offset, next_seq) {
Ok(None) => {}
Ok(Some((segment, offset))) => {
self.segment = segment;
self.offset = offset;
self.last_sync = Some(web_time::Instant::now());
self.durable.advance(segment, offset, self.last_seq);
}
Err(err) => {
return Err(Box::new(PersistError::io(
"starting the next journal segment",
err,
)));
}
}
}
let start = self.offset;
self.pending = Some(start);
let durable_seq = commit_seq.max(self.last_seq);
if let Err(err) = self
.writer
.write_all(&self.record)
.and_then(|()| self.writer.flush())
{
let reason = format!("writing a journal record failed: {err}");
return Err(self.refuse(start, reason));
}
self.offset += self.record.len() as u64;
let sync_now = match self.durability {
Durability::EveryCommit => true,
Durability::Interval(every) => {
self.last_sync.is_none_or(|last| last.elapsed() >= every)
}
Durability::Os => false,
};
if sync_now {
if let Err(err) = (self.sync)(&mut self.writer) {
self.offset = start;
let reason = format!("syncing the journal failed: {err}");
return Err(self.refuse(start, reason));
}
self.last_sync = Some(web_time::Instant::now());
self.durable.advance(self.segment, self.offset, durable_seq);
} else if self.durability == Durability::Os {
self.durable.advance(self.segment, self.offset, durable_seq);
}
self.pending = None;
if let Some(files) = &mut self.files {
files.record_written(self.record.len() as u64);
}
Ok(())
}
fn guarded<T>(
&mut self,
what: &str,
write: impl FnOnce(&mut Self) -> Result<T, HookError>,
) -> Result<T, HookError> {
self.pending = None;
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| write(&mut *self))) {
Ok(result) => result,
Err(payload) => {
let reason = format!("a panic while {what}: {}", panic_text(payload.as_ref()));
tracing::error!("the journal is poisoned by {reason}");
match self.pending.take() {
Some(start) => drop(self.refuse(start, reason)),
None => drop(self.poison(reason)),
}
std::panic::resume_unwind(payload)
}
}
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn sync_all(&mut self) -> Result<(), HookError> {
if let Some(reason) = &self.poisoned {
return Err(Box::new(PersistError::Poisoned {
reason: reason.clone(),
}));
}
if let Err(err) = self
.writer
.flush()
.and_then(|()| (self.sync)(&mut self.writer))
{
return Err(self.poison(format!("syncing the journal failed: {err}")));
}
self.last_sync = Some(web_time::Instant::now());
self.durable
.advance(self.segment, self.offset, self.last_seq);
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Journal<std::fs::File> {
pub fn for_file(file: std::fs::File, durability: Durability) -> Self {
use std::io::{Seek, SeekFrom};
let base = file.metadata().map(|meta| meta.len()).unwrap_or(0);
Journal::new(file, durability)
.with_sync(|file: &mut std::fs::File| file.sync_data())
.with_truncate(move |file: &mut std::fs::File, len| {
file.set_len(base + len)?;
file.seek(SeekFrom::Start(base + len))?;
file.sync_all()
})
}
}
impl<W: Write + Send + Sync + 'static> CommitHook for Journal<W> {
fn name(&self) -> &str {
"journal"
}
fn before_commit(&mut self, changes: &ChangeSet) -> Result<(), HookError> {
if let Some(reason) = &self.poisoned {
return Err(Box::new(PersistError::Poisoned {
reason: reason.clone(),
}));
}
self.guarded("writing a commit record", |journal| {
let payload =
encode_commit(changes, journal.codec).map_err(|err| Box::new(err) as HookError)?;
journal.record.clear();
frame_record(
&mut journal.record,
RECORD_COMMIT,
changes.commit_seq(),
&payload,
)
.map_err(|err| Box::new(err) as HookError)?;
journal.append(changes.commit_seq(), changes.commit_seq())?;
journal.last_seq = changes.commit_seq();
Ok(())
})
}
}
impl<W: Write + Send + Sync + 'static> JournalHook for Journal<W> {
fn record_mark(&mut self, mark: &Mark) -> Result<(), HookError> {
if self.marks.contains(mark.name()) {
return Err(
format!("the mark '{}' already exists in this journal", mark.name()).into(),
);
}
self.guarded("writing a mark record", |journal| {
journal.record.clear();
frame_record(
&mut journal.record,
RECORD_MARK,
mark.commit_seq(),
&encode_mark(mark),
)
.map_err(|err| Box::new(err) as HookError)?;
journal.append(mark.commit_seq(), journal.last_seq.saturating_add(1))?;
journal.marks.insert(mark.name().to_string());
if let Some(files) = &mut journal.files {
files.mark_written(mark);
}
Ok(())
})
}
fn record_checkpoint(&mut self, commit_seq: u64, time: i64) -> Result<(), HookError> {
self.guarded("writing a checkpoint record", |journal| {
let mut payload = Vec::with_capacity(16);
payload.extend_from_slice(&commit_seq.to_le_bytes());
payload.extend_from_slice(&time.to_le_bytes());
journal.record.clear();
frame_record(&mut journal.record, RECORD_CHECKPOINT, commit_seq, &payload)
.map_err(|err| Box::new(err) as HookError)?;
journal.append(commit_seq, journal.last_seq.saturating_add(1))
})
}
fn sync_now(&mut self) -> Result<(), HookError> {
self.guarded("syncing the journal", |journal| journal.sync_all())
}
}
pub(crate) fn panic_text(payload: &(dyn std::any::Any + Send)) -> &str {
payload
.downcast_ref::<&str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(String::as_str))
.unwrap_or("(no message)")
}