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, StreamRuntimeOutcome, StreamStateContract, StreamUpdateContext,
StreamUpdateError, VersionedStateCodec, WriteContext, WriteOutcome, WriterError,
};
pub const DEFAULT_MAX_VALUE_BYTES: usize = 1024 * 1024;
const INITIAL_GROWTH_BYTES: usize = 256;
#[derive(Clone, Copy, Debug)]
pub struct JsonArrayFormat {
max_value_bytes: usize,
}
impl JsonArrayFormat {
#[must_use]
pub const fn new() -> Self {
Self {
max_value_bytes: DEFAULT_MAX_VALUE_BYTES,
}
}
#[must_use]
pub const fn with_max_value_bytes(mut self, max_value_bytes: usize) -> Self {
self.max_value_bytes = max_value_bytes;
self
}
}
impl Default for JsonArrayFormat {
fn default() -> Self {
Self::new()
}
}
const READER_SCHEMA: &str = "oxide-batch.json-array-reader-position";
const READER_CODEC: &str = "oxide-batch.json-array-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)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct WriterPosition {
committed_bytes: u64,
committed_items: u64,
}
impl WriterPosition {
const START: Self = Self {
committed_bytes: 1,
committed_items: 0,
};
}
const WRITER_SCHEMA: &str = "oxide-batch.json-array-writer-position";
const WRITER_CODEC: &str = "oxide-batch.json-array-writer-position-codec";
#[derive(Clone, Copy)]
struct WriterPositionSchema;
impl VersionedStateCodec<WriterPosition> 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: &WriterPosition) -> Result<Vec<u8>, StateCodecError> {
serde_json::to_vec(&serde_json::json!({
"committed_bytes": value.committed_bytes,
"committed_items": value.committed_items,
}))
.map_err(|_| StateCodecError::InvalidPayload)
}
fn decode(&self, payload: &[u8]) -> Result<WriterPosition, StateCodecError> {
let value: Value =
serde_json::from_slice(payload).map_err(|_| StateCodecError::InvalidPayload)?;
let committed_bytes = value
.get("committed_bytes")
.and_then(Value::as_u64)
.ok_or(StateCodecError::InvalidPayload)?;
let committed_items = value
.get("committed_items")
.and_then(Value::as_u64)
.ok_or(StateCodecError::InvalidPayload)?;
Ok(WriterPosition {
committed_bytes,
committed_items,
})
}
}
#[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)
}
fn skip_whitespace<R: BufRead>(reader: &mut R) -> io::Result<(u64, Option<u8>)> {
let mut skipped: u64 = 0;
loop {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok((skipped, None));
}
let stop = available
.iter()
.position(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r'));
if let Some(index) = stop {
let byte = available[index];
reader.consume(index);
skipped += index as u64;
return Ok((skipped, Some(byte)));
}
let length = available.len();
reader.consume(length);
skipped += length as u64;
}
}
fn grow_to<R: BufRead>(reader: &mut R, buffer: &mut Vec<u8>, target: usize) -> io::Result<bool> {
while buffer.len() < target {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(true);
}
let budget = target - buffer.len();
let take = available.len().min(budget);
buffer.extend_from_slice(&available[..take]);
reader.consume(take);
}
Ok(false)
}
enum ParsedElement {
Value { value: Value, consumed: u64 },
FailClosed,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ValueFraming {
Delimited,
NeedMore,
Invalid,
}
pub struct JsonArrayReader<Src> {
source: BufReader<Src>,
buffer: Vec<u8>,
max_value_bytes: usize,
position: Arc<Mutex<u64>>,
consumed_absolute: u64,
expect_separator: bool,
done: bool,
started: bool,
}
impl<Src: Read + Seek> JsonArrayReader<Src> {
fn reseek_to(&mut self, target: u64) -> Result<(), ReaderError> {
if self.source.seek(SeekFrom::Start(target)).is_err() {
self.started = false;
return Err(ReaderError::with_category(
FailureCategory::TransientInfrastructure,
));
}
self.consumed_absolute = target;
Ok(())
}
fn restore_read_state(
&mut self,
position: u64,
expect_separator: bool,
) -> Result<(), ReaderError> {
self.reseek_to(position)?;
self.expect_separator = expect_separator;
self.done = false;
Ok(())
}
fn consume_closing_bracket(&mut self) -> Result<(), ReaderError> {
self.source.consume(1);
self.consumed_absolute += 1;
let (skipped, byte) = skip_whitespace(&mut self.source)
.map_err(|_| ReaderError::with_category(FailureCategory::TransientInfrastructure))?;
self.consumed_absolute += skipped;
if byte.is_some() {
return Err(ReaderError::new());
}
self.done = true;
Ok(())
}
fn prove_value_framing(&mut self, offset: usize) -> Result<ValueFraming, ReaderError> {
let suffix = &self.buffer[offset..];
let first_non_whitespace = suffix
.iter()
.position(|byte| !matches!(byte, b' ' | b'\t' | b'\n' | b'\r'));
if let Some(index) = first_non_whitespace {
return Ok(match suffix[index] {
b',' | b']' => ValueFraming::Delimited,
_ if index == 0 => ValueFraming::NeedMore,
_ => ValueFraming::Invalid,
});
}
let buffered_whitespace = !suffix.is_empty();
let (skipped, byte) = skip_whitespace(&mut self.source)
.map_err(|_| ReaderError::with_category(FailureCategory::TransientInfrastructure))?;
Ok(match byte {
Some(b',' | b']') => ValueFraming::Delimited,
Some(_) if buffered_whitespace || skipped > 0 => ValueFraming::Invalid,
Some(_) => ValueFraming::NeedMore,
None => ValueFraming::Invalid,
})
}
fn ensure_started(&mut self) -> Result<(), ReaderError> {
if self.started {
return Ok(());
}
let target = *self.position.lock().unwrap_or_else(PoisonError::into_inner);
self.reseek_to(target)?;
if target == 0 {
let (skipped, byte) = skip_whitespace(&mut self.source).map_err(|_| {
ReaderError::with_category(FailureCategory::TransientInfrastructure)
})?;
self.consumed_absolute += skipped;
if byte != Some(b'[') {
return Err(ReaderError::new());
}
self.source.consume(1);
self.consumed_absolute += 1;
let (skipped, byte) = skip_whitespace(&mut self.source).map_err(|_| {
ReaderError::with_category(FailureCategory::TransientInfrastructure)
})?;
self.consumed_absolute += skipped;
match byte {
Some(b']') => {
self.consume_closing_bracket()?;
}
Some(_) => {
self.expect_separator = false;
}
None => return Err(ReaderError::new()),
}
self.started = true;
Ok(())
} else {
self.expect_separator = true;
self.started = true;
Ok(())
}
}
fn resolve_separator(&mut self) -> Result<bool, ReaderError> {
let (skipped, byte) = skip_whitespace(&mut self.source)
.map_err(|_| ReaderError::with_category(FailureCategory::TransientInfrastructure))?;
self.consumed_absolute += skipped;
match byte {
Some(b']') => {
self.consume_closing_bracket()?;
Ok(false)
}
Some(b',') => {
self.source.consume(1);
self.consumed_absolute += 1;
let (skipped, byte) = skip_whitespace(&mut self.source).map_err(|_| {
ReaderError::with_category(FailureCategory::TransientInfrastructure)
})?;
self.consumed_absolute += skipped;
if byte.is_none() {
return Err(ReaderError::new());
}
Ok(true)
}
_ => Err(ReaderError::new()),
}
}
fn parse_element(&mut self) -> Result<ParsedElement, ReaderError> {
if self.max_value_bytes == 0 {
return Ok(ParsedElement::FailClosed);
}
self.buffer.clear();
let mut target = INITIAL_GROWTH_BYTES.min(self.max_value_bytes);
loop {
let hit_eof = grow_to(&mut self.source, &mut self.buffer, target).map_err(|_| {
ReaderError::with_category(FailureCategory::TransientInfrastructure)
})?;
let mut stream =
serde_json::Deserializer::from_slice(&self.buffer).into_iter::<Value>();
let result = stream.next();
let offset = stream.byte_offset();
drop(stream);
match result {
Some(Ok(value)) => match self.prove_value_framing(offset) {
Ok(ValueFraming::Delimited) => {
return Ok(ParsedElement::Value {
value,
consumed: offset as u64,
});
}
Ok(ValueFraming::NeedMore)
if !hit_eof && self.buffer.len() < self.max_value_bytes =>
{
let next = target.saturating_mul(2).min(self.max_value_bytes);
if next <= target {
return Ok(ParsedElement::FailClosed);
}
target = next;
}
Ok(ValueFraming::NeedMore | ValueFraming::Invalid) => {
return Ok(ParsedElement::FailClosed);
}
Err(error) => return Err(error),
},
Some(Err(error))
if error.is_eof() && !hit_eof && self.buffer.len() < self.max_value_bytes =>
{
let next = target.saturating_mul(2).min(self.max_value_bytes);
if next <= target {
return Ok(ParsedElement::FailClosed);
}
target = next;
}
_ => return Ok(ParsedElement::FailClosed),
}
}
}
}
impl<I, Src> crate::ItemReader<I> for JsonArrayReader<Src>
where
I: From<Value> + 'static,
Src: Read + Seek + Send,
{
async fn read(&mut self, _context: ReadContext<'_>) -> Result<ReadOutcome<I>, ReaderError> {
self.ensure_started()?;
if self.done {
return Ok(ReadOutcome::EndOfInput);
}
let checkpoint_before_read = self.consumed_absolute;
let expect_separator_before_read = self.expect_separator;
if self.expect_separator {
match self.resolve_separator() {
Ok(true) => {}
Ok(false) => return Ok(ReadOutcome::EndOfInput),
Err(error) => {
self.restore_read_state(checkpoint_before_read, expect_separator_before_read)?;
return Err(error);
}
}
}
match self.parse_element() {
Ok(ParsedElement::Value { value, consumed }) => {
let new_position = self.consumed_absolute + consumed;
self.reseek_to(new_position)?;
*self.position.lock().unwrap_or_else(PoisonError::into_inner) =
self.consumed_absolute;
self.expect_separator = true;
Ok(ReadOutcome::Item(value.into()))
}
Ok(ParsedElement::FailClosed) => {
self.restore_read_state(checkpoint_before_read, expect_separator_before_read)?;
Err(ReaderError::new())
}
Err(error) => {
self.restore_read_state(checkpoint_before_read, expect_separator_before_read)?;
Err(error)
}
}
}
}
pub struct JsonArrayReaderStream {
position: Arc<Mutex<u64>>,
namespace: ComponentStreamIdentity,
}
impl crate::ItemStream for JsonArrayReaderStream {
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 json_array_reader<I, Src>(
source: Src,
format: JsonArrayFormat,
identity: ComponentStreamIdentity,
) -> (
JsonArrayReader<Src>,
JsonArrayReaderStream,
StreamStateContract,
)
where
I: From<Value> + 'static,
Src: Read + Seek + Send,
{
let position = Arc::new(Mutex::new(0));
let reader = JsonArrayReader {
source: BufReader::new(source),
buffer: Vec::new(),
max_value_bytes: format.max_value_bytes,
position: Arc::clone(&position),
consumed_absolute: 0,
expect_separator: false,
done: false,
started: false,
};
let stream = JsonArrayReaderStream {
position,
namespace: identity,
};
let contract = StreamStateContract::new(reader_position_codec());
(reader, stream, contract)
}
pub fn json_array_file_reader<I>(
path: impl AsRef<Path>,
format: JsonArrayFormat,
identity: ComponentStreamIdentity,
) -> io::Result<(
JsonArrayReader<File>,
JsonArrayReaderStream,
StreamStateContract,
)>
where
I: From<Value> + 'static,
{
let file = File::open(path)?;
Ok(json_array_reader::<I, File>(file, format, identity))
}
struct WriterState {
file: File,
committed_bytes: u64,
committed_items: u64,
}
pub struct JsonArrayWriter {
state: Arc<Mutex<WriterState>>,
}
impl<I> crate::ItemWriter<I> for JsonArrayWriter
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);
}
if items.is_empty() {
return Ok(WriteOutcome::Written);
}
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let mut has_items = state.committed_items > 0;
for item in items {
if has_items {
state.file.write_all(b",").map_err(|_| WriterError::new())?;
}
has_items = true;
let value: Value = item.clone().into();
serde_json::to_writer(&mut state.file, &value).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;
state.committed_items = state.committed_items.saturating_add(items.len() as u64);
Ok(WriteOutcome::Written)
}
}
pub struct JsonArrayWriterStream {
state: Arc<Mutex<WriterState>>,
namespace: ComponentStreamIdentity,
}
impl crate::ItemStream for JsonArrayWriterStream {
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::<WriterPosition>(&codec)
.map_err(|_| StreamOpenError::new())?;
let actual_len = state
.file
.metadata()
.map_err(|_| StreamOpenError::new())?
.len();
if actual_len < restored.committed_bytes {
return Err(StreamOpenError::with_category(FailureCategory::Invariant));
}
if actual_len != restored.committed_bytes {
state
.file
.set_len(restored.committed_bytes)
.map_err(|_| StreamOpenError::new())?;
}
(restored, StreamOpenOutcome::Restored)
} else {
state.file.set_len(0).map_err(|_| StreamOpenError::new())?;
state
.file
.seek(SeekFrom::Start(0))
.map_err(|_| StreamOpenError::new())?;
state
.file
.write_all(b"[")
.map_err(|_| StreamOpenError::new())?;
state.file.sync_data().map_err(|_| StreamOpenError::new())?;
(WriterPosition::START, StreamOpenOutcome::Initial)
};
state
.file
.seek(SeekFrom::Start(target.committed_bytes))
.map_err(|_| StreamOpenError::new())?;
state.committed_bytes = target.committed_bytes;
state.committed_items = target.committed_items;
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 = WriterPosition {
committed_bytes: state.committed_bytes,
committed_items: state.committed_items,
};
drop(state);
ComponentStateEnvelope::encode(
self.namespace.clone(),
¤t,
&codec,
StateLimits::default(),
)
.map_err(|_| StreamUpdateError::new())
}
async fn close(
&self,
context: StreamCloseContext<'_>,
) -> Result<StreamCloseOutcome, StreamCloseError> {
if context.outcome() == StreamRuntimeOutcome::Committed {
let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let committed_bytes = state.committed_bytes;
state
.file
.seek(SeekFrom::Start(committed_bytes))
.map_err(|_| StreamCloseError::new())?;
state
.file
.write_all(b"]")
.map_err(|_| StreamCloseError::new())?;
state
.file
.sync_data()
.map_err(|_| StreamCloseError::new())?;
}
Ok(StreamCloseOutcome::Closed)
}
}
pub fn json_array_writer(
path: impl AsRef<Path>,
identity: ComponentStreamIdentity,
) -> io::Result<(JsonArrayWriter, JsonArrayWriterStream, StreamStateContract)> {
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)?;
let state = Arc::new(Mutex::new(WriterState {
file,
committed_bytes: 0,
committed_items: 0,
}));
let writer = JsonArrayWriter {
state: Arc::clone(&state),
};
let stream = JsonArrayWriterStream {
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)]
use std::thread;
use serde_json::json;
use super::*;
use crate::{ItemStream, ItemWriter, StopSource};
fn identity() -> ComponentStreamIdentity {
ComponentStreamIdentity::new("oxide-batch.json-array-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-json-array-unit-{name}-{nonce}.json"
))
}
#[test]
fn concurrent_write_is_fully_serialized_by_one_lock_never_a_stale_comma_decision() {
let path = temp_path("serialized");
let (writer, stream, _contract) = json_array_writer(&path, 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 has_items = guard.committed_items > 0;
if has_items {
guard.file.write_all(b",").unwrap();
}
let value = json!(1);
serde_json::to_writer(&mut guard.file, &value).unwrap();
guard.file.sync_data().unwrap();
guard.committed_bytes = guard.file.stream_position().unwrap();
guard.committed_items = guard.committed_items.saturating_add(1);
drop(guard);
handle.join().unwrap();
futures_executor::block_on(stream.close(StreamCloseContext::new(
&open_stop_token,
StreamRuntimeOutcome::Committed,
)))
.unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(
bytes,
br"[1,2]".to_vec(),
"the concurrent write's comma decision must reflect this thread's already-committed \
item -- never a stale \"no items yet\" decision that would produce \"[12]\""
);
let _ = std::fs::remove_file(&path);
}
}