use std::{
cmp::Reverse,
collections::{BinaryHeap, HashMap, HashSet, VecDeque},
fs::File,
io::{BufRead, BufReader, Read, Seek, SeekFrom},
path::{Path, PathBuf},
sync::Arc,
};
use jiff::Timestamp;
use miette::{IntoDiagnostic as _, Result, WrapErr as _};
use tracing::debug;
use uuid::Uuid;
use super::{
paths::{self, AuditFile},
record::{ContextRecord, QuerySource, Record, RecordKind, SEPARATOR, TERMINATOR},
tailscale::TailscalePeer,
};
const REVERSE_CHUNK: usize = 64 * 1024;
const MAX_RECORD_BYTES: usize = 64 * 1024 * 1024;
const MAX_WINDOW_LOG: u32 = 23;
const MOST_REPORTED: usize = 4096;
const MOST_AT_ONE_INSTANT: usize = 4096;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Renamed {
pub file: PathBuf,
pub names: Uuid,
pub records: Uuid,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Held {
pub record: Record,
pub json: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unreadable {
pub file: PathBuf,
pub at: u64,
pub error: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Skipped {
pub file: PathBuf,
pub at: u64,
pub bytes: usize,
}
#[derive(Debug, Clone)]
pub enum FramedItem {
Record {
record: Record,
json: String,
at: u64,
},
Skipped {
at: u64,
bytes: usize,
},
Failed {
at: u64,
error: String,
},
}
impl FramedItem {
pub fn into_record(self) -> Option<Record> {
match self {
Self::Record { record, .. } => Some(record),
Self::Skipped { .. } | Self::Failed { .. } => None,
}
}
}
pub struct FramedReader<R> {
inner: R,
offset: u64,
started: bool,
failed: bool,
}
impl<R: BufRead> FramedReader<R> {
pub fn new(inner: R) -> Self {
Self {
inner,
offset: 0,
started: false,
failed: false,
}
}
}
impl<R: BufRead> FramedReader<R> {
fn take_record(&mut self, buf: &mut Vec<u8>) -> std::io::Result<usize> {
let limit = MAX_RECORD_BYTES as u64 + 1;
self.inner.by_ref().take(limit).read_until(SEPARATOR, buf)
}
fn drain_to_separator(&mut self) -> std::io::Result<u64> {
const CHUNK: u64 = 64 * 1024;
let mut skipped = 0;
let mut scratch = Vec::with_capacity(CHUNK as usize);
loop {
scratch.clear();
let taken = self
.inner
.by_ref()
.take(CHUNK)
.read_until(SEPARATOR, &mut scratch)?;
skipped += taken as u64;
if taken == 0 || scratch.last() == Some(&SEPARATOR) {
return Ok(skipped);
}
}
}
}
impl<R: BufRead> Iterator for FramedReader<R> {
type Item = FramedItem;
fn next(&mut self) -> Option<FramedItem> {
if self.failed {
return None;
}
loop {
let mut buf = Vec::new();
let read = match self.take_record(&mut buf) {
Ok(0) => return None,
Ok(read) => read,
Err(err) => {
debug!(?err, "reading audit file");
self.failed = true;
return Some(FramedItem::Failed {
at: self.offset,
error: err.to_string(),
});
}
};
if buf.len() > MAX_RECORD_BYTES {
let at = self.offset;
let mut bytes = read as u64;
match self.drain_to_separator() {
Ok(further) => bytes += further,
Err(err) => {
debug!(?err, "reading past an oversized audit record");
self.failed = true;
}
}
self.offset += bytes;
self.started = true;
return Some(FramedItem::Skipped {
at,
bytes: bytes as usize,
});
}
let at = self.offset;
self.offset += read as u64;
if buf.last() == Some(&SEPARATOR) {
buf.pop();
}
if !self.started {
self.started = true;
if buf.is_empty() {
continue;
}
return Some(FramedItem::Skipped {
at,
bytes: buf.len(),
});
}
if buf.last() == Some(&TERMINATOR) {
buf.pop();
}
if buf.is_empty() {
continue;
}
return Some(parse(buf, at));
}
}
}
fn parse(buf: Vec<u8>, at: u64) -> FramedItem {
let bytes = buf.len();
match String::from_utf8(buf) {
Ok(json) => match serde_json::from_str::<Record>(&json) {
Ok(record) => FramedItem::Record { record, json, at },
Err(_) => FramedItem::Skipped { at, bytes },
},
Err(_) => FramedItem::Skipped { at, bytes },
}
}
pub struct ReverseFramedReader {
file: File,
remaining: u64,
back: VecDeque<u8>,
given: u64,
length: u64,
ready: VecDeque<FramedItem>,
done: bool,
}
impl ReverseFramedReader {
pub fn open(path: &Path) -> Result<Self> {
let file = File::open(path)
.into_diagnostic()
.wrap_err_with(|| format!("opening {}", path.display()))?;
let remaining = file.metadata().into_diagnostic()?.len();
let length = remaining;
Ok(Self {
file,
remaining,
back: VecDeque::new(),
given: 0,
length,
ready: VecDeque::new(),
done: false,
})
}
fn fill(&mut self) -> Result<()> {
let take = self.remaining.min(REVERSE_CHUNK as u64) as usize;
let start = self.remaining - take as u64;
let mut chunk = vec![0u8; take];
self.file.seek(SeekFrom::Start(start)).into_diagnostic()?;
self.file.read_exact(&mut chunk).into_diagnostic()?;
self.remaining = start;
let looked_at = self.back.len();
self.back.extend(chunk.iter().rev());
let mut from = looked_at;
while let Some(found) = self
.back
.iter()
.skip(from)
.position(|byte| *byte == SEPARATOR)
{
let index = from + found;
let mut record: Vec<u8> = self.back.drain(..index).collect();
self.back.pop_front();
record.reverse();
from = 0;
self.given += index as u64 + 1;
let at = self.length - self.given + 1;
if record.last() == Some(&TERMINATOR) {
record.pop();
}
if !record.is_empty() {
self.ready.push_back(parse(record, at));
}
}
if self.back.len() > MAX_RECORD_BYTES {
let bytes = self.back.len();
self.given += bytes as u64;
self.ready.push_back(FramedItem::Skipped {
at: self.length - self.given,
bytes,
});
self.back.clear();
}
if self.remaining == 0 {
self.done = true;
if !self.back.is_empty() {
self.ready.push_back(FramedItem::Skipped {
at: 0,
bytes: self.back.len(),
});
self.back.clear();
}
}
Ok(())
}
}
impl Iterator for ReverseFramedReader {
type Item = FramedItem;
fn next(&mut self) -> Option<FramedItem> {
loop {
if let Some(item) = self.ready.pop_front() {
return Some(item);
}
if self.done {
return None;
}
if let Err(err) = self.fill() {
debug!(?err, "reading audit segment backwards");
self.done = true;
return Some(FramedItem::Failed {
at: self.remaining,
error: err.to_string(),
});
}
}
}
}
pub fn open(path: &Path, kind: AuditFile) -> Result<Box<dyn Iterator<Item = FramedItem>>> {
let file = File::open(path)
.into_diagnostic()
.wrap_err_with(|| format!("opening {}", path.display()))?;
Ok(match kind {
AuditFile::Segment { .. } => Box::new(FramedReader::new(BufReader::new(file))),
AuditFile::DayFile { .. } => {
let mut decoder = zstd::Decoder::new(file)
.into_diagnostic()
.wrap_err_with(|| format!("decompressing {}", path.display()))?;
decoder
.window_log_max(MAX_WINDOW_LOG)
.into_diagnostic()
.wrap_err_with(|| format!("bounding the window of {}", path.display()))?;
Box::new(FramedReader::new(BufReader::new(decoder)))
}
})
}
#[derive(Debug, Clone)]
pub struct Stored {
pub record: Record,
pub json: String,
pub hash: String,
pub instance: Option<Uuid>,
}
#[derive(Debug, Clone)]
pub struct Entry {
pub ts: Timestamp,
pub seq: u64,
pub instance: Option<Uuid>,
pub query: String,
pub source: QuerySource,
pub sys_user: String,
pub db_user: String,
pub writemode: bool,
pub ots: Option<String>,
pub tailscale: Vec<TailscalePeer>,
}
const HEADS_REMEMBERED: usize = 4096;
#[derive(Debug, Default)]
struct Threads {
head: HashMap<String, Uuid>,
remembered: VecDeque<String>,
context: HashMap<Uuid, Arc<Held>>,
}
impl Threads {
fn attribute(
&mut self,
record: &Record,
hash: &str,
json: &str,
named: Option<Uuid>,
) -> (Option<Uuid>, bool) {
let renamed = match (named, record.instance()) {
(Some(named), Some(recorded)) => named != recorded,
_ => false,
};
let instance = record
.instance()
.or_else(|| self.head.get(&record.prev).copied())
.or(named);
if !record.prev.is_empty() {
self.head.remove(&record.prev);
}
if let Some(instance) = instance {
if self.head.insert(hash.to_owned(), instance).is_none() {
self.remembered.push_back(hash.to_owned());
while self.remembered.len() > HEADS_REMEMBERED {
if let Some(oldest) = self.remembered.pop_front() {
self.head.remove(&oldest);
}
}
}
if matches!(record.kind, RecordKind::Context(_)) {
self.context.insert(
instance,
Arc::new(Held {
record: record.clone(),
json: json.to_owned(),
}),
);
}
}
(instance, renamed)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Range {
pub since: Option<Timestamp>,
pub until: Option<Timestamp>,
}
impl Range {
fn holds(&self, ts: Timestamp) -> bool {
self.since.is_none_or(|since| ts >= since) && self.until.is_none_or(|until| ts <= until)
}
fn could_hold(&self, kind: AuditFile) -> bool {
let date = kind.date();
self.since
.is_none_or(|since| date >= super::writer::date_of(since))
&& self
.until
.is_none_or(|until| date <= super::writer::date_of(until))
}
}
struct Source {
iter: Box<dyn Iterator<Item = FramedItem>>,
origin: PathBuf,
instance: Option<Uuid>,
head: Option<(Record, String)>,
read: u64,
}
impl Source {
fn fill(&mut self, skipped: &mut Vec<Skipped>, unreadable: &mut Vec<Unreadable>) {
while self.head.is_none() {
match self.iter.next() {
None => break,
Some(FramedItem::Record { record, json, .. }) => self.head = Some((record, json)),
Some(FramedItem::Skipped { at, bytes }) => {
if skipped.len() < MOST_REPORTED {
skipped.push(Skipped {
file: self.origin.clone(),
at,
bytes,
});
}
}
Some(FramedItem::Failed { at, error }) => {
debug!(origin = ?self.origin, at, %error, "audit file stopped being readable");
if unreadable.len() < MOST_REPORTED {
unreadable.push(Unreadable {
file: self.origin.clone(),
at,
error,
});
}
}
}
}
}
}
pub struct Reader {
waiting: Vec<(PathBuf, AuditFile)>,
renamed: Vec<Renamed>,
unreadable: Vec<Unreadable>,
sources: Vec<Source>,
queue: BinaryHeap<Reverse<(Timestamp, u64, usize)>>,
threads: Threads,
skipped: Vec<Skipped>,
range: Range,
at: Option<Timestamp>,
seen: HashSet<String>,
}
impl Reader {
pub fn open(dir: &Path) -> Result<Self> {
Self::open_range(dir, Range::default())
}
pub fn open_range(dir: &Path, range: Range) -> Result<Self> {
let mut waiting: Vec<(PathBuf, AuditFile)> = paths::list(dir)?
.into_iter()
.filter(|(_, kind)| range.could_hold(*kind))
.collect();
waiting.reverse();
let mut reader = Self {
waiting,
renamed: Vec::new(),
unreadable: Vec::new(),
sources: Vec::new(),
queue: BinaryHeap::new(),
threads: Threads::default(),
skipped: Vec::new(),
range,
at: None,
seen: HashSet::new(),
};
reader.open_next_day();
Ok(reader)
}
fn open_next_day(&mut self) {
let Some((_, next)) = self.waiting.last() else {
return;
};
let date = next.date();
while let Some((path, kind)) = self.waiting.last() {
if kind.date() != date {
break;
}
let (path, kind) = (path.clone(), *kind);
self.waiting.pop();
match open(&path, kind) {
Ok(iter) => {
let index = self.sources.len();
self.sources.push(Source {
iter,
origin: path,
instance: kind.instance(),
head: None,
read: 0,
});
self.advance(index);
}
Err(err) => {
debug!(?err, ?path, "skipping unreadable audit file");
if self.unreadable.len() < MOST_REPORTED {
self.unreadable.push(Unreadable {
file: path,
at: 0,
error: format!("{err}"),
});
}
}
}
}
}
fn advance(&mut self, index: usize) {
let mut skipped = std::mem::take(&mut self.skipped);
let mut unreadable = std::mem::take(&mut self.unreadable);
self.sources[index].fill(&mut skipped, &mut unreadable);
self.skipped = skipped;
self.unreadable = unreadable;
if let Some((record, _)) = &self.sources[index].head {
let ts = record.ts;
let read = self.sources[index].read;
self.sources[index].read += 1;
self.queue.push(Reverse((ts, read, index)));
}
}
pub fn skipped(&self) -> &[Skipped] {
&self.skipped
}
pub fn renamed(&self) -> &[Renamed] {
&self.renamed
}
pub fn unreadable(&self) -> &[Unreadable] {
&self.unreadable
}
pub fn context_of(&self, instance: Uuid) -> Option<&ContextRecord> {
self.context_record_of(instance).map(|held| {
let RecordKind::Context(context) = &held.record.kind else {
unreachable!("only context records are kept as context")
};
context
})
}
pub fn context_record_of(&self, instance: Uuid) -> Option<&Arc<Held>> {
self.threads.context.get(&instance)
}
pub fn entries(self) -> impl Iterator<Item = Entry> {
Entries { reader: self }
}
}
impl Iterator for Reader {
type Item = Stored;
fn next(&mut self) -> Option<Stored> {
loop {
if self.queue.is_empty() {
if self.waiting.is_empty() {
return None;
}
self.sources.clear();
self.open_next_day();
continue;
}
let Reverse((ts, _, index)) = self.queue.pop()?;
let (record, json) = self.sources[index].head.take()?;
self.advance(index);
let hash = super::record::hash(&json);
if self.at != Some(ts) {
self.at = Some(ts);
self.seen.clear();
}
if self.seen.len() < MOST_AT_ONE_INSTANT && !self.seen.insert(hash.clone()) {
continue;
}
let named = self.sources[index].instance;
let (instance, renamed) = self.threads.attribute(&record, &hash, &json, named);
if renamed && self.renamed.len() < MOST_REPORTED {
self.renamed.push(Renamed {
file: self.sources[index].origin.clone(),
names: named.expect("a name to disagree with"),
records: instance.expect("a record that said so"),
});
}
if !self.range.holds(ts) {
continue;
}
return Some(Stored {
record,
json,
hash,
instance,
});
}
}
}
struct Entries {
reader: Reader,
}
impl Iterator for Entries {
type Item = Entry;
fn next(&mut self) -> Option<Entry> {
loop {
let stored = self.reader.next()?;
let RecordKind::Query(query) = &stored.record.kind else {
continue;
};
let context = stored
.instance
.and_then(|instance| self.reader.context_of(instance));
return Some(Entry {
ts: stored.record.ts,
seq: stored.record.seq,
instance: stored.instance,
query: query.query.clone(),
source: query.source.clone(),
sys_user: context.map(|c| c.sys_user.clone()).unwrap_or_default(),
db_user: context.map(|c| c.db_user.clone()).unwrap_or_default(),
writemode: context.is_some_and(|c| c.writemode),
ots: context.and_then(|c| c.ots.clone()),
tailscale: context.map(|c| c.tailscale.clone()).unwrap_or_default(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audit::{
record::{FORMAT_VERSION, QueryRecord, frame},
writer::{Context, Writer},
};
fn context() -> Context {
Context {
sys_user: "felix".into(),
db_user: "tamanu".into(),
writemode: false,
ots: None,
}
}
fn framed(records: &[Record]) -> Vec<u8> {
records
.iter()
.flat_map(|r| frame(&r.to_json().unwrap()))
.collect()
}
fn record(seq: u64, query: &str) -> Record {
Record {
v: FORMAT_VERSION,
seq,
ts: Timestamp::UNIX_EPOCH + jiff::SignedDuration::from_secs(seq as i64),
prev: String::new(),
kind: RecordKind::Query(QueryRecord {
query: query.into(),
source: QuerySource::Typed,
}),
}
}
fn read_all(bytes: &[u8]) -> Vec<FramedItem> {
FramedReader::new(BufReader::new(bytes)).collect()
}
#[test]
fn framing_round_trips() {
let records = vec![record(0, "select 1;"), record(1, "select 2;")];
let items = read_all(&framed(&records));
let read: Vec<_> = items.into_iter().filter_map(|i| i.into_record()).collect();
assert_eq!(read, records);
}
#[test]
fn an_empty_file_reads_as_no_records() {
assert!(read_all(b"").is_empty());
}
#[test]
fn a_torn_final_record_is_reported_and_the_rest_survive() {
let mut bytes = framed(&[record(0, "select 1;"), record(1, "select 2;")]);
bytes.extend_from_slice(&[SEPARATOR]);
bytes.extend_from_slice(br#"{"v":1,"seq":2,"ts":"1970-01-0"#);
let items = read_all(&bytes);
assert_eq!(items.len(), 3);
assert!(matches!(items[2], FramedItem::Skipped { .. }));
let read: Vec<_> = items.into_iter().filter_map(|i| i.into_record()).collect();
assert_eq!(read.len(), 2);
}
#[test]
fn damage_in_the_middle_does_not_swallow_the_records_after_it() {
let mut bytes = framed(&[record(0, "before;")]);
bytes.push(SEPARATOR);
bytes.extend_from_slice(b"not json at all");
bytes.push(TERMINATOR);
bytes.extend_from_slice(&framed(&[record(2, "after;")]));
let items = read_all(&bytes);
let queries: Vec<_> = items
.iter()
.cloned()
.filter_map(|i| i.into_record())
.filter_map(|r| match r.kind {
RecordKind::Query(q) => Some(q.query),
_ => None,
})
.collect();
assert_eq!(queries, vec!["before;", "after;"]);
assert_eq!(
items
.iter()
.filter(|i| matches!(i, FramedItem::Skipped { .. }))
.count(),
1
);
}
#[test]
fn junk_before_the_first_separator_is_reported() {
let mut bytes = b"leading junk".to_vec();
bytes.extend_from_slice(&framed(&[record(0, "select 1;")]));
let items = read_all(&bytes);
assert!(matches!(items[0], FramedItem::Skipped { at: 0, bytes: 12 }));
assert_eq!(items.len(), 2);
}
#[test]
fn a_record_missing_its_newline_still_parses() {
let json = record(0, "select 1;").to_json().unwrap();
let mut bytes = vec![SEPARATOR];
bytes.extend_from_slice(json.as_bytes());
let items = read_all(&bytes);
assert_eq!(items.len(), 1);
let FramedItem::Record { json: read, .. } = &items[0] else {
panic!("expected a record");
};
assert_eq!(*read, json);
}
#[test]
fn no_more_than_a_record_is_ever_buffered() {
let endless = std::io::repeat(b'x');
let mut reader = FramedReader::new(BufReader::new(endless));
let mut buf = Vec::new();
reader.take_record(&mut buf).unwrap();
assert!(
buf.len() <= MAX_RECORD_BYTES + 1,
"buffered {} bytes",
buf.len()
);
}
#[test]
fn a_run_of_bytes_with_no_separator_is_skipped_rather_than_held() {
let mut bytes = vec![b'x'; MAX_RECORD_BYTES + 1024];
bytes.extend_from_slice(&framed(&[record(0, "after the junk;")]));
let items = read_all(&bytes);
assert!(matches!(items[0], FramedItem::Skipped { .. }));
let queries: Vec<_> = items
.into_iter()
.filter_map(|i| i.into_record())
.filter_map(|r| match r.kind {
RecordKind::Query(q) => Some(q.query),
_ => None,
})
.collect();
assert_eq!(queries, vec!["after the junk;"]);
}
#[test]
fn reading_backwards_yields_the_newest_record_first() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("segment");
let records: Vec<_> = (0..200)
.map(|i| record(i, &format!("select {i};")))
.collect();
std::fs::write(&path, framed(&records)).unwrap();
let read: Vec<_> = ReverseFramedReader::open(&path)
.unwrap()
.filter_map(|i| i.into_record())
.collect();
let mut expected = records;
expected.reverse();
assert_eq!(read, expected);
}
#[test]
fn reading_backwards_handles_a_record_larger_than_a_chunk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("segment");
let big = "x".repeat(REVERSE_CHUNK * 3);
let records = vec![
record(0, "small;"),
record(1, &big),
record(2, "also small;"),
];
std::fs::write(&path, framed(&records)).unwrap();
let read: Vec<_> = ReverseFramedReader::open(&path)
.unwrap()
.filter_map(|i| i.into_record())
.collect();
let mut expected = records;
expected.reverse();
assert_eq!(read, expected);
}
#[test]
fn reading_backwards_matches_reading_forwards() {
let dir = tempfile::tempdir().unwrap();
let mut writer = Writer::new(dir.path());
for i in 0..50 {
writer.query(&context(), format!("select {i};"), QuerySource::Typed);
}
drop(writer);
let path = paths::list(dir.path()).unwrap()[0].0.clone();
let forwards: Vec<_> = FramedReader::new(BufReader::new(File::open(&path).unwrap()))
.filter_map(|i| i.into_record())
.collect();
let mut backwards: Vec<_> = ReverseFramedReader::open(&path)
.unwrap()
.filter_map(|i| i.into_record())
.collect();
backwards.reverse();
assert_eq!(forwards, backwards);
}
#[test]
fn the_merge_orders_concurrent_sessions_by_time() {
let dir = tempfile::tempdir().unwrap();
let mut one = Writer::new(dir.path());
let mut two = Writer::new(dir.path());
one.query(&context(), "one a;".into(), QuerySource::Typed);
two.query(&context(), "two a;".into(), QuerySource::Typed);
one.query(&context(), "one b;".into(), QuerySource::Typed);
two.query(&context(), "two b;".into(), QuerySource::Typed);
drop(one);
drop(two);
let queries: Vec<_> = Reader::open(dir.path())
.unwrap()
.entries()
.map(|e| e.query)
.collect();
assert_eq!(queries, vec!["one a;", "two a;", "one b;", "two b;"]);
}
#[test]
fn every_entry_carries_the_context_of_its_own_session() {
let dir = tempfile::tempdir().unwrap();
let mut one = Writer::new(dir.path());
let mut two = Writer::new(dir.path());
let alice = Context {
sys_user: "alice".into(),
db_user: "tamanu".into(),
writemode: false,
ots: None,
};
let bob = Context {
sys_user: "bob".into(),
db_user: "tamanu".into(),
writemode: true,
ots: Some("Carol".into()),
};
one.query(&alice, "from alice;".into(), QuerySource::Typed);
two.query(&bob, "from bob;".into(), QuerySource::Typed);
one.query(&alice, "alice again;".into(), QuerySource::Typed);
drop(one);
drop(two);
let entries: Vec<_> = Reader::open(dir.path()).unwrap().entries().collect();
let by_query = |q: &str| entries.iter().find(|e| e.query == q).unwrap().clone();
assert_eq!(by_query("from alice;").sys_user, "alice");
assert!(!by_query("from alice;").writemode);
assert_eq!(by_query("from bob;").sys_user, "bob");
assert!(by_query("from bob;").writemode);
assert_eq!(by_query("from bob;").ots.as_deref(), Some("Carol"));
assert_eq!(by_query("alice again;").sys_user, "alice");
}
#[test]
fn records_are_attributed_to_their_session() {
let dir = tempfile::tempdir().unwrap();
let mut one = Writer::new(dir.path());
let mut two = Writer::new(dir.path());
let (a, b) = (one.instance(), two.instance());
one.query(&context(), "one;".into(), QuerySource::Typed);
two.query(&context(), "two;".into(), QuerySource::Typed);
drop(one);
drop(two);
for stored in Reader::open(dir.path()).unwrap() {
assert!(
stored.instance == Some(a) || stored.instance == Some(b),
"every record is attributed"
);
}
}
#[test]
fn a_time_range_narrows_the_stream() {
let dir = tempfile::tempdir().unwrap();
let mut writer = Writer::new(dir.path());
writer.query(&context(), "first;".into(), QuerySource::Typed);
let cut = Timestamp::now();
std::thread::sleep(std::time::Duration::from_millis(5));
writer.query(&context(), "second;".into(), QuerySource::Typed);
drop(writer);
let entries: Vec<_> = Reader::open_range(
dir.path(),
Range {
since: Some(cut),
until: None,
},
)
.unwrap()
.entries()
.map(|e| e.query)
.collect();
assert_eq!(entries, vec!["second;"]);
}
#[test]
fn an_entry_outside_the_range_still_gets_its_context() {
let dir = tempfile::tempdir().unwrap();
let mut writer = Writer::new(dir.path());
writer.query(&context(), "first;".into(), QuerySource::Typed);
let cut = Timestamp::now();
std::thread::sleep(std::time::Duration::from_millis(5));
writer.query(&context(), "second;".into(), QuerySource::Typed);
drop(writer);
let entry = Reader::open_range(
dir.path(),
Range {
since: Some(cut),
until: None,
},
)
.unwrap()
.entries()
.next()
.unwrap();
assert_eq!(entry.sys_user, "felix");
assert_eq!(entry.db_user, "tamanu");
}
#[test]
fn an_empty_directory_reads_as_an_empty_log() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(Reader::open(dir.path()).unwrap().count(), 0);
}
}