use std::num::NonZeroU64;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::fs::{File, OpenOptions};
use tokio::io::AsyncWriteExt;
use tokio_util::sync::CancellationToken;
use super::counters::SinkCounters;
use super::{Queue, Record};
const DRAIN_DEADLINE: Duration = Duration::from_secs(5);
pub(super) async fn run(
path: PathBuf,
max_bytes: NonZeroU64,
mut queue: Queue,
drain: CancellationToken,
counters: Arc<SinkCounters>,
) {
let mut writer = Writer::new(path, max_bytes);
loop {
tokio::select! {
received = queue.recv() => match received {
Some(record) => writer.append(record, &counters).await,
None => break,
},
() = drain.cancelled() => {
self::drain(&mut writer, &mut queue, &counters, DRAIN_DEADLINE).await;
break;
}
}
}
}
async fn drain(
writer: &mut Writer,
queue: &mut Queue,
counters: &SinkCounters,
deadline: Duration,
) {
let drained = tokio::time::timeout(deadline, async {
while let Some(record) = queue.try_recv() {
writer.append(record, counters).await;
}
})
.await;
if drained.is_err() {
counters.lose(queue.len() as u64 + 1);
}
}
struct Writer {
path: PathBuf,
max_bytes: u64,
file: Option<File>,
written: u64,
warned: bool,
rollover_failing: bool,
}
impl Writer {
fn new(path: PathBuf, max_bytes: NonZeroU64) -> Writer {
Writer {
path,
max_bytes: max_bytes.get(),
file: None,
written: 0,
warned: false,
rollover_failing: false,
}
}
async fn append(&mut self, record: Record, counters: &SinkCounters) {
let Ok(mut line) = serde_json::to_string(&record) else {
counters.lose(1);
return;
};
line.push('\n');
let cost = line.len() as u64;
if cost >= self.max_bytes {
counters.lose(1);
return;
}
if !self.open(counters).await {
return;
}
if self.written.saturating_add(cost) >= self.max_bytes {
if !self.roll_over().await {
counters.lose(1);
return;
}
if !self.open(counters).await {
return;
}
}
let file = self.file.as_mut().expect("the file was just opened");
let written = async {
file.write_all(line.as_bytes()).await?;
file.flush().await
}
.await;
match written {
Ok(()) => self.written = self.written.saturating_add(cost),
Err(err) => {
tracing::warn!(
path = %self.path.display(),
error = %err,
"a decision record could not be written to the log file"
);
counters.lose(1);
let _ = file.set_len(self.written).await;
self.file = None;
}
}
}
async fn open(&mut self, counters: &SinkCounters) -> bool {
if self.file.is_some() {
return true;
}
match OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(&self.path)
.await
{
Ok(file) => {
self.written = match file.metadata().await {
Ok(metadata) => {
self.warn_if_shared(metadata.permissions().mode());
metadata.len()
}
Err(_) => 0,
};
self.file = Some(file);
true
}
Err(err) => {
tracing::warn!(
path = %self.path.display(),
error = %err,
"the decision log file could not be opened"
);
counters.lose(1);
false
}
}
}
fn warn_if_shared(&mut self, mode: u32) {
if self.warned || mode & 0o077 == 0 {
return;
}
self.warned = true;
tracing::warn!(
path = %self.path.display(),
mode = %format_args!("{:o}", mode & 0o777),
"the decision log file is accessible to users other than its owner"
);
}
async fn roll_over(&mut self) -> bool {
self.file = None;
let mut rolled = self.path.clone().into_os_string();
rolled.push(".1");
if let Err(err) = tokio::fs::rename(&self.path, PathBuf::from(rolled)).await {
if !self.rollover_failing {
self.rollover_failing = true;
tracing::warn!(
path = %self.path.display(),
error = %err,
"the decision log file could not be rolled over; records are dropped \
and counted until it can"
);
}
return false;
}
self.rollover_failing = false;
self.written = 0;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::delivery::{Summary, channel};
#[tokio::test]
async fn tp19_file_drain_deadline_counts_cut_off_records() {
let dir = tempfile::tempdir().expect("a temporary directory");
let path = dir.path().join("decisions.ndjson");
const QUEUED: u64 = 10_000;
let (sink, mut queue) = channel(QUEUED as usize);
for index in 0..QUEUED as usize {
sink.push(padded(index, 0));
}
let mut writer = Writer::new(path.clone(), NonZeroU64::MAX);
let counters = SinkCounters::new();
drain(&mut writer, &mut queue, &counters, Duration::ZERO).await;
let written = std::fs::read_to_string(&path)
.map(|text| text.lines().count() as u64)
.unwrap_or(0);
assert!(
written < QUEUED,
"inconclusive: no cut-off happened, all {written} records were written"
);
let dropped = counters.total();
assert!(
(QUEUED..=QUEUED + 1).contains(&(written + dropped)),
"every queued record is either in the file or counted as dropped: \
{written} written, {dropped} dropped"
);
}
fn summary() -> Record {
Record::RequestSummary(Summary {
timestamp: String::new(),
requests: 0,
errors: 0,
bytes: 0,
mean_duration_micros: 0,
window_micros: 0,
dropped_file: 0,
dropped_siem: 0,
})
}
#[tokio::test]
async fn rl7_file_drain_deadline_counts_in_the_window() {
let dir = tempfile::tempdir().expect("a temporary directory");
let (sink, mut queue) = channel(10_000);
for index in 0..10_000 {
sink.push(padded(index, 0));
}
let mut writer = Writer::new(dir.path().join("d.ndjson"), NonZeroU64::MAX);
let counters = SinkCounters::new();
drain(&mut writer, &mut queue, &counters, Duration::ZERO).await;
assert!(counters.take_window() > 0);
}
#[tokio::test]
async fn rl7_file_write_error_counts_in_the_window() {
let mut writer = Writer::new(PathBuf::from("/dev/full"), NonZeroU64::MAX);
let counters = SinkCounters::new();
writer.append(summary(), &counters).await;
assert_eq!(counters.take_window(), 1);
}
fn padded(index: usize, pad: usize) -> Record {
Record::RequestDecided(crate::delivery::Decision {
timestamp: String::new(),
request_id: index.to_string(),
method: String::new(),
ecosystem: "npm",
package: String::new(),
version: String::new(),
status: 503,
result: "POLICY_UNAVAILABLE",
reason: "x".repeat(pad),
blocklist_revision: 0,
cache: "unattempted",
duration_micros: 0,
bytes: 0,
consumer: None,
})
}
fn set_rename_failing(rolled: &std::path::Path, failing: bool) {
if failing {
if rolled.is_file() {
std::fs::remove_file(rolled).expect("the rolled generation is removed");
}
if !rolled.is_dir() {
std::fs::create_dir(rolled).expect("a directory where `.1` would go");
}
} else if rolled.is_dir() {
std::fs::remove_dir(rolled).expect("the blocking directory is removed");
}
}
fn last_request_id(path: &std::path::Path) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
let line = text.lines().last()?;
let record: serde_json::Value = serde_json::from_str(line).ok()?;
record["request_id"].as_str().map(str::to_owned)
}
proptest::proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(64))]
#[test]
fn the_ceiling_holds_and_every_record_is_written_or_counted(
max_bytes in 200u64..4_000,
steps in proptest::collection::vec((0usize..2_500, proptest::bool::ANY), 1..40),
) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("a runtime");
runtime.block_on(async {
let dir = tempfile::tempdir().expect("a temporary directory");
let path = dir.path().join("d.ndjson");
let rolled = dir.path().join("d.ndjson.1");
let mut writer = Writer::new(path.clone(), NonZeroU64::new(max_bytes).unwrap());
let counters = SinkCounters::new();
let mut written = 0u64;
for (index, &(pad, rename_fails)) in steps.iter().enumerate() {
set_rename_failing(&rolled, rename_fails);
let dropped_before = counters.total();
writer.append(padded(index, pad), &counters).await;
for generation in [&path, &rolled] {
if generation.is_file() {
let len = std::fs::metadata(generation).unwrap().len();
proptest::prop_assert!(
len <= max_bytes,
"{} is {len} bytes against a ceiling of {max_bytes}",
generation.display()
);
}
}
let landed = last_request_id(&path) == Some(index.to_string());
match (landed, counters.total() - dropped_before) {
(true, 0) => written += 1,
(false, 1) => {}
other => proptest::prop_assert!(
false,
"record {index} must be written or counted, exactly once: \
(written, counted) = {other:?}"
),
}
}
proptest::prop_assert_eq!(written + counters.total(), steps.len() as u64);
Ok(())
})?;
}
}
const PARTIAL_WRITE_PATH: &str = "PROBATION_PARTIAL_WRITE_PATH";
#[test]
fn a_failed_write_leaves_no_torn_fragment() {
const LIMIT: usize = 1024;
let dir = tempfile::tempdir().expect("a temporary directory");
let path = dir.path().join("d.ndjson");
let head = r#"{"event":"request_decided","pad":""#;
let tail = "\"}\n";
let before = format!(
"{head}{}{tail}",
"x".repeat(LIMIT - 64 - head.len() - tail.len())
);
std::fs::write(&path, &before).expect("the earlier record is written");
let child = std::process::Command::new("prlimit")
.arg(format!("--fsize={LIMIT}:{LIMIT}"))
.arg("--")
.arg(std::env::current_exe().expect("this test binary"))
.args([
"--exact",
"delivery::file::tests::partial_write_child",
"--include-ignored",
"--test-threads=1",
])
.env(PARTIAL_WRITE_PATH, &path)
.env("LLVM_PROFILE_FILE", "/dev/null")
.output()
.expect("prlimit runs");
assert!(
child.status.success(),
"the child append ran: {}\n{}",
child.status,
String::from_utf8_lossy(&child.stdout)
);
assert_eq!(
std::fs::read_to_string(&path).expect("the log file"),
before,
"a write that failed partway leaves no fragment behind"
);
}
#[tokio::test]
#[ignore = "run under prlimit by a_failed_write_leaves_no_torn_fragment"]
async fn partial_write_child() {
let Some(path) = std::env::var_os(PARTIAL_WRITE_PATH) else {
return;
};
let _sigxfsz = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(25))
.expect("a SIGXFSZ handler");
let counters = SinkCounters::new();
let mut writer = Writer::new(PathBuf::from(path), NonZeroU64::MAX);
writer.append(padded(1, 400), &counters).await;
assert_eq!(counters.total(), 1, "the failed write is counted");
}
#[tokio::test]
async fn run_returns_when_its_queue_closes() {
let dir = tempfile::tempdir().expect("a temporary directory");
let (sink, queue) = channel(1);
drop(sink);
run(
dir.path().join("d.ndjson"),
NonZeroU64::MAX,
queue,
CancellationToken::new(),
Arc::new(SinkCounters::new()),
)
.await;
}
#[tokio::test]
async fn rl7_file_unopenable_counts_in_the_window() {
let dir = tempfile::tempdir().expect("a temporary directory");
let mut writer = Writer::new(dir.path().join("missing/d.ndjson"), NonZeroU64::MAX);
let counters = SinkCounters::new();
writer.append(summary(), &counters).await;
assert_eq!(counters.take_window(), 1);
}
}