#[cfg(feature = "csv")]
mod csv;
#[cfg(feature = "json")]
mod jsonl;
use std::fmt;
use std::fs::TryLockError;
use std::future::Future;
use std::io::SeekFrom;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::fs::{File, OpenOptions};
use tokio::io::{AsyncBufReadExt, AsyncSeekExt, AsyncWriteExt, BufReader};
use tokio::sync::Mutex;
#[cfg(feature = "csv")]
pub use self::csv::CsvDataset;
#[cfg(feature = "json")]
pub use self::jsonl::JsonlDataset;
use crate::{Error, ErrorKind, Result};
pub(super) struct FileSink<S, T = File> {
inner: Arc<Inner<S, T>>,
}
struct Inner<S, T> {
open: Mutex<Open<S, T>>,
path: PathBuf,
written: AtomicUsize,
}
struct Open<S, T> {
target: T,
len: u64,
state: S,
}
pub(super) trait Target: Send + 'static {
fn append(&mut self, bytes: &[u8]) -> impl Future<Output = std::io::Result<()>> + Send;
fn truncate(&mut self, len: u64) -> impl Future<Output = std::io::Result<()>> + Send;
}
impl Target for File {
async fn append(&mut self, bytes: &[u8]) -> std::io::Result<()> {
self.write_all(bytes).await?;
self.flush().await
}
async fn truncate(&mut self, len: u64) -> std::io::Result<()> {
self.set_len(len).await?;
self.seek(SeekFrom::Start(len)).await.map(drop)
}
}
impl<S> FileSink<S> {
pub(super) async fn create(path: &Path, state: S) -> Result<Self> {
let mut options = OpenOptions::new();
options.write(true).create(true);
let mut file = open_locked(path, &options).await?;
file.truncate(0)
.await
.map_err(|error| open_error(path, error))?;
Ok(Self::new(file, 0, path, state))
}
pub(super) async fn append(path: &Path, state: impl FnOnce(&[u8]) -> S) -> Result<Self> {
let mut options = OpenOptions::new();
options.read(true).append(true).create(true);
let file = open_locked(path, &options).await?;
let mut first = Vec::new();
let mut reader = BufReader::new(file);
reader
.read_until(b'\n', &mut first)
.await
.map_err(|error| open_error(path, error))?;
let file = reader.into_inner();
let len = file
.metadata()
.await
.map_err(|error| open_error(path, error))?
.len();
Ok(Self::new(file, len, path, state(&first)))
}
}
async fn open_locked(path: &Path, options: &OpenOptions) -> Result<File> {
let file = options
.open(path)
.await
.map_err(|error| open_error(path, error))?;
let file = file.into_std().await;
match file.try_lock() {
Ok(()) => Ok(File::from_std(file)),
Err(TryLockError::WouldBlock) => {
let message = format!("`{}` is already open by another dataset", path.display());
Err(Error::new(ErrorKind::Dataset).with_message(message))
}
Err(TryLockError::Error(error)) => Err(open_error(path, error)),
}
}
impl<S, T: Target> FileSink<S, T> {
fn new(target: T, len: u64, path: &Path, state: S) -> Self {
let open = Open { target, len, state };
let inner = Inner {
open: Mutex::new(open),
path: path.to_owned(),
written: AtomicUsize::new(0),
};
Self {
inner: Arc::new(inner),
}
}
pub(super) fn path(&self) -> &Path {
&self.inner.path
}
pub(super) fn written(&self) -> usize {
self.inner.written.load(Ordering::Relaxed)
}
pub(super) async fn write(
&self,
count: usize,
encode: impl FnOnce(&S) -> Result<(Vec<u8>, S)>,
) -> Result<()> {
let mut open = self.inner.open.lock().await;
let (bytes, state) = encode(&open.state)?;
if let Err(error) = open.target.append(&bytes).await {
let len = open.len;
let _ = open.target.truncate(len).await;
let message = format!("failed to write to `{}`", self.inner.path.display());
return Err(Error::new(ErrorKind::Dataset)
.with_message(message)
.with_source(error));
}
open.len += bytes.len() as u64;
open.state = state;
self.inner.written.fetch_add(count, Ordering::Relaxed);
Ok(())
}
}
fn open_error(path: &Path, error: std::io::Error) -> Error {
let message = format!("failed to open `{}`", path.display());
Error::new(ErrorKind::Dataset)
.with_message(message)
.with_source(error)
}
impl<S, T> fmt::Debug for FileSink<S, T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("FileSink")
.field("path", &self.inner.path)
.field("written", &self.inner.written.load(Ordering::Relaxed))
.finish_non_exhaustive()
}
}
impl<S, T> Clone for FileSink<S, T> {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Flaky {
bytes: Vec<u8>,
failures: usize,
}
impl Target for Flaky {
fn append(&mut self, bytes: &[u8]) -> impl Future<Output = std::io::Result<()>> + Send {
let result = if self.failures == 0 {
self.bytes.extend_from_slice(bytes);
Ok(())
} else {
self.failures -= 1;
self.bytes.extend_from_slice(&bytes[..bytes.len() / 2]);
Err(std::io::Error::other("disk full"))
};
std::future::ready(result)
}
fn truncate(&mut self, len: u64) -> impl Future<Output = std::io::Result<()>> + Send {
self.bytes.truncate(usize::try_from(len).unwrap());
std::future::ready(Ok(()))
}
}
async fn write(sink: &FileSink<bool, Flaky>, line: &str) -> Result<()> {
sink.write(1, |header| {
let mut bytes = if *header {
Vec::new()
} else {
b"head\n".to_vec()
};
bytes.extend_from_slice(line.as_bytes());
Ok((bytes, true))
})
.await
}
#[tokio::test]
async fn failed_writes_leave_no_partial_item_and_no_state_change() {
let flaky = Flaky {
failures: 1,
..Flaky::default()
};
let sink = FileSink::new(flaky, 0, Path::new("test"), false);
let error = write(&sink, "first\n").await.unwrap_err();
assert_eq!(error.kind(), ErrorKind::Dataset);
write(&sink, "second\n").await.unwrap();
write(&sink, "third\n").await.unwrap();
let open = sink.inner.open.lock().await;
assert_eq!(open.target.bytes, b"head\nsecond\nthird\n");
assert_eq!(open.len, 18);
assert_eq!(sink.written(), 2);
}
#[tokio::test]
async fn a_file_opens_in_one_sink_at_a_time() {
let path = std::env::temp_dir().join(format!("spire-lock-{}.txt", std::process::id()));
let first = FileSink::create(&path, ()).await.unwrap();
let clone = first.clone();
let error = FileSink::append(&path, |_| ()).await.unwrap_err();
assert!(error.to_string().contains("already open"), "{error}");
assert!(FileSink::create(&path, ()).await.is_err());
drop(first);
assert!(FileSink::append(&path, |_| ()).await.is_err());
drop(clone);
FileSink::append(&path, |_| ()).await.unwrap();
tokio::fs::remove_file(&path).await.unwrap();
}
}