use std::fs::File;
use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::Path;
use std::sync::{Arc, Mutex, PoisonError};
use serde_json::Value;
use crate::{
CodecId, CodecVersion, ComponentStateEnvelope, ComponentStreamIdentity, DefaultComponentCodec,
FailureCategory, ReadContext, ReadOutcome, ReaderError, RestartabilityDeclaration,
StateCodecError, StateLimits, StateSchemaId, StateSchemaVersion, StateSensitivity,
StreamCloseContext, StreamCloseError, StreamCloseOutcome, StreamOpenContext, StreamOpenError,
StreamOpenOutcome, StreamStateContract, StreamUpdateContext, StreamUpdateError,
VersionedStateCodec, WriteContext, WriteOutcome, WriterError,
};
pub const DEFAULT_MAX_RECORD_BYTES: usize = 1024 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum JsonLinesTerminator {
Lf,
CrLf,
}
impl JsonLinesTerminator {
const fn bytes(self) -> &'static [u8] {
match self {
Self::Lf => b"\n",
Self::CrLf => b"\r\n",
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct JsonLinesFormat {
max_record_bytes: usize,
terminator: JsonLinesTerminator,
}
impl JsonLinesFormat {
#[must_use]
pub const fn new() -> Self {
Self {
max_record_bytes: DEFAULT_MAX_RECORD_BYTES,
terminator: JsonLinesTerminator::Lf,
}
}
#[must_use]
pub const fn with_max_record_bytes(mut self, max_record_bytes: usize) -> Self {
self.max_record_bytes = max_record_bytes;
self
}
#[must_use]
pub const fn with_terminator(mut self, terminator: JsonLinesTerminator) -> Self {
self.terminator = terminator;
self
}
}
impl Default for JsonLinesFormat {
fn default() -> Self {
Self::new()
}
}
const READER_SCHEMA: &str = "oxide-batch.jsonl-reader-position";
const READER_CODEC: &str = "oxide-batch.jsonl-reader-position-codec";
#[derive(Clone, Copy)]
struct ReaderPositionSchema;
impl VersionedStateCodec<u64> for ReaderPositionSchema {
fn schema_id(&self) -> &StateSchemaId {
static SCHEMA: std::sync::OnceLock<StateSchemaId> = std::sync::OnceLock::new();
#[allow(
clippy::unwrap_used,
reason = "fixed literal schema identity cannot fail validation"
)]
SCHEMA.get_or_init(|| StateSchemaId::new(READER_SCHEMA).unwrap())
}
fn current_version(&self) -> StateSchemaVersion {
#[allow(
clippy::unwrap_used,
reason = "fixed literal schema version cannot fail validation"
)]
StateSchemaVersion::new(1).unwrap()
}
fn encode(&self, value: &u64) -> Result<Vec<u8>, StateCodecError> {
serde_json::to_vec(&serde_json::json!({ "byte": value }))
.map_err(|_| StateCodecError::InvalidPayload)
}
fn decode(&self, payload: &[u8]) -> Result<u64, StateCodecError> {
let value: Value =
serde_json::from_slice(payload).map_err(|_| StateCodecError::InvalidPayload)?;
value
.get("byte")
.and_then(Value::as_u64)
.ok_or(StateCodecError::InvalidPayload)
}
}
#[allow(
clippy::unwrap_used,
reason = "fixed literal identities cannot fail validation"
)]
fn reader_position_codec() -> DefaultComponentCodec<ReaderPositionSchema> {
DefaultComponentCodec::new(
ReaderPositionSchema,
CodecId::new(READER_CODEC).unwrap(),
CodecVersion::new(1).unwrap(),
RestartabilityDeclaration::Restartable,
)
.with_sensitivity(StateSensitivity::NonSensitive)
}
const WRITER_SCHEMA: &str = "oxide-batch.jsonl-writer-position";
const WRITER_CODEC: &str = "oxide-batch.jsonl-writer-position-codec";
#[derive(Clone, Copy)]
struct WriterPositionSchema;
impl VersionedStateCodec<u64> for WriterPositionSchema {
fn schema_id(&self) -> &StateSchemaId {
static SCHEMA: std::sync::OnceLock<StateSchemaId> = std::sync::OnceLock::new();
#[allow(
clippy::unwrap_used,
reason = "fixed literal schema identity cannot fail validation"
)]
SCHEMA.get_or_init(|| StateSchemaId::new(WRITER_SCHEMA).unwrap())
}
fn current_version(&self) -> StateSchemaVersion {
#[allow(
clippy::unwrap_used,
reason = "fixed literal schema version cannot fail validation"
)]
StateSchemaVersion::new(1).unwrap()
}
fn encode(&self, value: &u64) -> Result<Vec<u8>, StateCodecError> {
serde_json::to_vec(&serde_json::json!({ "committed_bytes": value }))
.map_err(|_| StateCodecError::InvalidPayload)
}
fn decode(&self, payload: &[u8]) -> Result<u64, StateCodecError> {
let value: Value =
serde_json::from_slice(payload).map_err(|_| StateCodecError::InvalidPayload)?;
value
.get("committed_bytes")
.and_then(Value::as_u64)
.ok_or(StateCodecError::InvalidPayload)
}
}
#[allow(
clippy::unwrap_used,
reason = "fixed literal identities cannot fail validation"
)]
fn writer_position_codec() -> DefaultComponentCodec<WriterPositionSchema> {
DefaultComponentCodec::new(
WriterPositionSchema,
CodecId::new(WRITER_CODEC).unwrap(),
CodecVersion::new(1).unwrap(),
RestartabilityDeclaration::Restartable,
)
.with_sensitivity(StateSensitivity::NonSensitive)
}
enum BoundedLine {
Line { bytes: Vec<u8>, consumed: u64 },
Eof,
TooLong { consumed: u64 },
}
fn copy_bounded(buf: &mut Vec<u8>, segment: &[u8], max_bytes: usize, too_long: &mut bool) {
if *too_long {
return;
}
let budget = max_bytes.saturating_sub(buf.len());
if segment.len() <= budget {
buf.extend_from_slice(segment);
} else {
buf.extend_from_slice(&segment[..budget]);
*too_long = true;
buf.clear();
buf.shrink_to_fit();
}
}
fn read_bounded_line<R: BufRead>(reader: &mut R, max_bytes: usize) -> io::Result<BoundedLine> {
let mut buf: Vec<u8> = Vec::new();
let mut consumed_total: u64 = 0;
let mut too_long = false;
let mut pending_cr = false;
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
if pending_cr {
copy_bounded(&mut buf, b"\r", max_bytes, &mut too_long);
}
if consumed_total == 0 {
return Ok(BoundedLine::Eof);
}
return Ok(if too_long {
BoundedLine::TooLong {
consumed: consumed_total,
}
} else {
BoundedLine::Line {
bytes: buf,
consumed: consumed_total,
}
});
}
if pending_cr {
if available[0] == b'\n' {
reader.consume(1);
consumed_total += 1;
return Ok(if too_long {
BoundedLine::TooLong {
consumed: consumed_total,
}
} else {
BoundedLine::Line {
bytes: buf,
consumed: consumed_total,
}
});
}
copy_bounded(&mut buf, b"\r", max_bytes, &mut too_long);
pending_cr = false;
}
if let Some(newline) = available.iter().position(|&byte| byte == b'\n') {
let payload_end =
newline.saturating_sub(usize::from(newline > 0 && available[newline - 1] == b'\r'));
copy_bounded(
&mut buf,
&available[..payload_end],
max_bytes,
&mut too_long,
);
let take = newline + 1;
reader.consume(take);
consumed_total += take as u64;
return Ok(if too_long {
BoundedLine::TooLong {
consumed: consumed_total,
}
} else {
BoundedLine::Line {
bytes: buf,
consumed: consumed_total,
}
});
}
if available.last() == Some(&b'\r') {
let payload_end = available.len() - 1;
copy_bounded(
&mut buf,
&available[..payload_end],
max_bytes,
&mut too_long,
);
reader.consume(payload_end);
consumed_total += payload_end as u64;
reader.consume(1);
consumed_total += 1;
pending_cr = true;
continue;
}
copy_bounded(&mut buf, available, max_bytes, &mut too_long);
let chunk_len = available.len();
reader.consume(chunk_len);
consumed_total += chunk_len as u64;
}
}
pub struct JsonLinesReader<Src> {
source: BufReader<Src>,
max_record_bytes: usize,
position: Arc<Mutex<u64>>,
needs_seek: bool,
}
impl<Src: Read + Seek> JsonLinesReader<Src> {
fn seek_if_needed(&mut self) -> Result<(), ReaderError> {
if !self.needs_seek {
return Ok(());
}
let target = *self.position.lock().unwrap_or_else(PoisonError::into_inner);
self.source
.seek(SeekFrom::Start(target))
.map_err(|_| ReaderError::with_category(FailureCategory::TransientInfrastructure))?;
self.needs_seek = false;
Ok(())
}
}
impl<I, Src> crate::ItemReader<I> for JsonLinesReader<Src>
where
I: From<Value> + 'static,
Src: Read + Seek + Send,
{
async fn read(&mut self, _context: ReadContext<'_>) -> Result<ReadOutcome<I>, ReaderError> {
self.seek_if_needed()?;
let before = *self.position.lock().unwrap_or_else(PoisonError::into_inner);
let Ok(outcome) = read_bounded_line(&mut self.source, self.max_record_bytes) else {
self.needs_seek = true;
return Err(ReaderError::with_category(
FailureCategory::TransientInfrastructure,
));
};
match outcome {
BoundedLine::Eof => Ok(ReadOutcome::EndOfInput),
BoundedLine::TooLong { consumed } => {
*self.position.lock().unwrap_or_else(PoisonError::into_inner) = before + consumed;
Err(ReaderError::new().with_checkpoint_advanced(true))
}
BoundedLine::Line { bytes, consumed } => {
*self.position.lock().unwrap_or_else(PoisonError::into_inner) = before + consumed;
let value: Value = serde_json::from_slice(&bytes)
.map_err(|_| ReaderError::new().with_checkpoint_advanced(true))?;
Ok(ReadOutcome::Item(value.into()))
}
}
}
}
pub struct JsonLinesReaderStream {
position: Arc<Mutex<u64>>,
namespace: ComponentStreamIdentity,
}
impl crate::ItemStream for JsonLinesReaderStream {
async fn open(
&self,
context: StreamOpenContext<'_>,
) -> Result<StreamOpenOutcome, StreamOpenError> {
let codec = reader_position_codec();
if let Some(envelope) = context.inherited_state() {
let restored = envelope
.decode::<u64>(&codec)
.map_err(|_| StreamOpenError::new())?;
*self.position.lock().unwrap_or_else(PoisonError::into_inner) = restored;
Ok(StreamOpenOutcome::Restored)
} else {
*self.position.lock().unwrap_or_else(PoisonError::into_inner) = 0;
Ok(StreamOpenOutcome::Initial)
}
}
async fn update(
&self,
_context: StreamUpdateContext<'_>,
) -> Result<ComponentStateEnvelope, StreamUpdateError> {
let codec = reader_position_codec();
let current = *self.position.lock().unwrap_or_else(PoisonError::into_inner);
ComponentStateEnvelope::encode(
self.namespace.clone(),
¤t,
&codec,
StateLimits::default(),
)
.map_err(|_| StreamUpdateError::new())
}
async fn close(
&self,
_context: StreamCloseContext<'_>,
) -> Result<StreamCloseOutcome, StreamCloseError> {
Ok(StreamCloseOutcome::Closed)
}
}
pub fn jsonl_reader<I, Src>(
source: Src,
format: JsonLinesFormat,
identity: ComponentStreamIdentity,
) -> (
JsonLinesReader<Src>,
JsonLinesReaderStream,
StreamStateContract,
)
where
I: From<Value> + 'static,
Src: Read + Seek + Send,
{
let position = Arc::new(Mutex::new(0));
let reader = JsonLinesReader {
source: BufReader::new(source),
max_record_bytes: format.max_record_bytes,
position: Arc::clone(&position),
needs_seek: true,
};
let stream = JsonLinesReaderStream {
position,
namespace: identity,
};
let contract = StreamStateContract::new(reader_position_codec());
(reader, stream, contract)
}
pub fn jsonl_file_reader<I>(
path: impl AsRef<Path>,
format: JsonLinesFormat,
identity: ComponentStreamIdentity,
) -> io::Result<(
JsonLinesReader<File>,
JsonLinesReaderStream,
StreamStateContract,
)>
where
I: From<Value> + 'static,
{
let file = File::open(path)?;
Ok(jsonl_reader::<I, File>(file, format, identity))
}
struct JsonLinesWriterState {
file: File,
committed_bytes: u64,
}
pub struct JsonLinesWriter {
state: Arc<Mutex<JsonLinesWriterState>>,
terminator: JsonLinesTerminator,
}
impl<I> crate::ItemWriter<I> for JsonLinesWriter
where
I: Send + Sync + Clone + Into<Value>,
{
async fn write<'a>(
&'a self,
items: &'a [I],
context: WriteContext<'a>,
) -> Result<WriteOutcome, WriterError> {
if context.stop_token().is_stop_requested() {
return Ok(WriteOutcome::Stopped);
}
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
for item in items {
let value: Value = item.clone().into();
serde_json::to_writer(&mut state.file, &value).map_err(|_| WriterError::new())?;
state
.file
.write_all(self.terminator.bytes())
.map_err(|_| WriterError::new())?;
}
state.file.sync_data().map_err(|_| WriterError::new())?;
let candidate_bytes = state
.file
.stream_position()
.map_err(|_| WriterError::new())?;
state.committed_bytes = candidate_bytes;
Ok(WriteOutcome::Written)
}
}
pub struct JsonLinesWriterStream {
state: Arc<Mutex<JsonLinesWriterState>>,
namespace: ComponentStreamIdentity,
}
impl crate::ItemStream for JsonLinesWriterStream {
async fn open(
&self,
context: StreamOpenContext<'_>,
) -> Result<StreamOpenOutcome, StreamOpenError> {
let codec = writer_position_codec();
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let (target, outcome) = if let Some(envelope) = context.inherited_state() {
let restored = envelope
.decode::<u64>(&codec)
.map_err(|_| StreamOpenError::new())?;
(restored, StreamOpenOutcome::Restored)
} else {
(0, StreamOpenOutcome::Initial)
};
let actual_len = state
.file
.metadata()
.map_err(|_| StreamOpenError::new())?
.len();
if actual_len < target {
return Err(StreamOpenError::with_category(FailureCategory::Invariant));
}
if actual_len != target {
state
.file
.set_len(target)
.map_err(|_| StreamOpenError::new())?;
}
state
.file
.seek(SeekFrom::Start(target))
.map_err(|_| StreamOpenError::new())?;
state.committed_bytes = target;
Ok(outcome)
}
async fn update(
&self,
_context: StreamUpdateContext<'_>,
) -> Result<ComponentStateEnvelope, StreamUpdateError> {
let codec = writer_position_codec();
let state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let current = state.committed_bytes;
drop(state);
ComponentStateEnvelope::encode(
self.namespace.clone(),
¤t,
&codec,
StateLimits::default(),
)
.map_err(|_| StreamUpdateError::new())
}
async fn close(
&self,
_context: StreamCloseContext<'_>,
) -> Result<StreamCloseOutcome, StreamCloseError> {
Ok(StreamCloseOutcome::Closed)
}
}
pub fn jsonl_writer(
path: impl AsRef<Path>,
format: JsonLinesFormat,
identity: ComponentStreamIdentity,
) -> io::Result<(JsonLinesWriter, JsonLinesWriterStream, StreamStateContract)> {
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)?;
let state = Arc::new(Mutex::new(JsonLinesWriterState {
file,
committed_bytes: 0,
}));
let writer = JsonLinesWriter {
state: Arc::clone(&state),
terminator: format.terminator,
};
let stream = JsonLinesWriterStream {
state,
namespace: identity,
};
let contract = StreamStateContract::new(writer_position_codec());
Ok((writer, stream, contract))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::thread;
use serde_json::json;
use super::*;
use crate::{ComponentStatePayload, ItemStream, ItemWriter, StopSource};
fn identity() -> ComponentStreamIdentity {
ComponentStreamIdentity::new("oxide-batch.jsonl-writer-unit-test")
.expect("static identity is valid")
}
fn temp_path(name: &str) -> std::path::PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time moves forward")
.as_nanos();
std::env::temp_dir().join(format!("oxide-batch-148-jsonl-unit-{name}-{nonce}.jsonl"))
}
fn committed_byte(envelope: &ComponentStateEnvelope) -> u64 {
let ComponentStatePayload::Inline(payload) = envelope.payload().unwrap() else {
panic!("writer checkpoints must use an inline payload");
};
serde_json::from_slice::<Value>(&payload)
.unwrap()
.get("committed_bytes")
.and_then(Value::as_u64)
.expect("writer checkpoint committed_bytes")
}
#[test]
fn concurrent_write_is_fully_serialized_by_one_lock_never_a_stale_commit() {
let path = temp_path("serialized");
let (writer, stream, _contract) =
jsonl_writer(&path, JsonLinesFormat::new(), identity()).unwrap();
let writer = Arc::new(writer);
let (_open_stop_source, open_stop_token) = StopSource::new();
futures_executor::block_on(stream.open(StreamOpenContext::new(None, &open_stop_token)))
.unwrap();
let mut guard = writer.state.lock().unwrap_or_else(PoisonError::into_inner);
let writer_for_thread = Arc::clone(&writer);
let handle = thread::spawn(move || {
let (_stop_source, stop_token) = StopSource::new();
futures_executor::block_on(
writer_for_thread.write(&[json!(2)], WriteContext::non_transactional(&stop_token)),
)
.unwrap();
});
let value = json!(1);
serde_json::to_writer(&mut guard.file, &value).unwrap();
guard
.file
.write_all(JsonLinesTerminator::Lf.bytes())
.unwrap();
guard.file.sync_data().unwrap();
guard.committed_bytes = guard.file.stream_position().unwrap();
drop(guard);
handle.join().unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(
bytes,
b"1\n2\n".to_vec(),
"the concurrent write must be fully serialized after this thread's own write, never \
interleaved or reordered"
);
let envelope = futures_executor::block_on(
stream.update(crate::StreamUpdateContext::new(&open_stop_token)),
)
.unwrap();
assert_eq!(
committed_byte(&envelope),
bytes.len() as u64,
"the committed checkpoint must exactly match the real file length -- never a stale \
value from whichever call happened to publish its commit last"
);
let _ = std::fs::remove_file(&path);
}
}