use anyhow::{Context, Result, anyhow};
use onlyne_config::layout::RoleWorkspace;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq)]
pub struct ContentRecord {
pub seq: u64,
pub task_id: String,
pub session_id: Option<String>,
pub at: String,
pub record: Value,
}
pub trait ContentSink: Send + Sync {
fn publish(&self, record: ContentRecord);
}
#[derive(Clone, Default)]
pub(crate) struct ContentWriter {
inner: Arc<Mutex<ContentWriterState>>,
}
#[derive(Default)]
struct ContentWriterState {
sink: Option<Arc<dyn ContentSink>>,
heads: BTreeMap<PathBuf, u64>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ContentIndexEntry {
seq: u64,
task_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
at: String,
journal: String,
offset: u64,
len: u64,
}
impl ContentWriter {
pub(crate) fn set_sink(&self, sink: Arc<dyn ContentSink>) {
self.inner.lock().sink = Some(sink);
}
pub(crate) fn append(
&self,
workspace: &Path,
task_id: &str,
session_id: Option<&str>,
journal: &Path,
record: &Value,
at: &str,
) -> std::io::Result<()> {
let mut state = self.inner.lock();
let head = match state.heads.get(workspace).copied() {
Some(head) => head,
None => indexed_head(workspace)?,
};
let seq = head
.checked_add(1)
.ok_or_else(|| std::io::Error::other("content sequence exhausted"))?;
state.heads.insert(workspace.to_path_buf(), seq);
let (offset, len) = append_record(journal, record)?;
let journal_name = journal
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("journal has no UTF-8 file name: {}", journal.display()),
)
})?;
let entry = ContentIndexEntry {
seq,
task_id: task_id.to_string(),
session_id: session_id.map(str::to_string),
at: at.to_string(),
journal: journal_name.to_string(),
offset,
len,
};
append_index(workspace, &entry)?;
if let Some(sink) = state.sink.as_ref() {
sink.publish(ContentRecord {
seq,
task_id: task_id.to_string(),
session_id: session_id.map(str::to_string),
at: at.to_string(),
record: record.clone(),
});
}
Ok(())
}
}
fn indexed_head(workspace: &Path) -> std::io::Result<u64> {
let path = RoleWorkspace::resolve(workspace).content_index_path();
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0),
Err(error) => return Err(error),
};
text.lines()
.filter(|line| !line.trim().is_empty())
.try_fold(0, |head, line| {
let entry: ContentIndexEntry = serde_json::from_str(line)
.map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
Ok(head.max(entry.seq))
})
}
fn append_record(path: &Path, record: &Value) -> std::io::Result<(u64, u64)> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let body = serde_json::to_vec(record).map_err(std::io::Error::other)?;
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
let offset = file.metadata()?.len();
file.write_all(&body)?;
file.write_all(b"\n")?;
file.flush()?;
Ok((offset, body.len() as u64))
}
fn append_index(workspace: &Path, entry: &ContentIndexEntry) -> std::io::Result<()> {
let path = RoleWorkspace::resolve(workspace).content_index_path();
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let mut line = serde_json::to_vec(entry).map_err(std::io::Error::other)?;
line.push(b'\n');
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
file.write_all(&line)?;
file.flush()
}
pub fn read_content_records(workspace: &Path) -> Result<Vec<ContentRecord>> {
let index_path = RoleWorkspace::resolve(workspace).content_index_path();
let text = match std::fs::read_to_string(&index_path) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(error).with_context(|| format!("read {}", index_path.display())),
};
let logs = index_path
.parent()
.ok_or_else(|| anyhow!("content index has no parent: {}", index_path.display()))?;
let mut records = Vec::new();
for (line_number, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let entry: ContentIndexEntry = serde_json::from_str(line).with_context(|| {
format!(
"decode content index {} line {}",
index_path.display(),
line_number + 1
)
})?;
let path = safe_journal_path(logs, &entry.journal)?;
let record = read_indexed_record(&path, entry.offset, entry.len)
.with_context(|| format!("read content seq {} from {}", entry.seq, path.display()))?;
records.push(ContentRecord {
seq: entry.seq,
task_id: entry.task_id,
session_id: entry.session_id,
at: entry.at,
record,
});
}
records.sort_by_key(|record| record.seq);
for pair in records.windows(2) {
if pair[0].seq == pair[1].seq {
return Err(anyhow!("duplicate content sequence {}", pair[0].seq));
}
}
Ok(records)
}
fn safe_journal_path(logs: &Path, journal: &str) -> Result<PathBuf> {
let name = Path::new(journal);
if name.components().count() != 1
|| name.file_name().and_then(|part| part.to_str()) != Some(journal)
{
return Err(anyhow!("invalid content journal name {journal:?}"));
}
Ok(logs.join(name))
}
fn read_indexed_record(path: &Path, offset: u64, len: u64) -> Result<Value> {
let len: usize = len
.try_into()
.map_err(|_| anyhow!("indexed content length {len} does not fit memory"))?;
let mut file = File::open(path)?;
file.seek(SeekFrom::Start(offset))?;
let mut bytes = vec![0; len];
file.read_exact(&mut bytes)?;
let mut newline = [0_u8; 1];
file.read_exact(&mut newline)?;
if newline[0] != b'\n' {
return Err(anyhow!("indexed content is not one complete JSONL record"));
}
serde_json::from_slice(&bytes).context("decode indexed journal record")
}