use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::destination::LogDestination;
use super::error::DrainError;
pub const MANIFEST_FILENAME: &str = ".drain-manifest.json";
pub const MANIFEST_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestEntry {
pub relative_file: String,
pub size: u64,
pub mtime_unix: i64,
pub sha256: String,
pub uploaded_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum SkipReason {
SourceTooLarge,
CompressedTooLarge,
}
impl SkipReason {
pub fn limit_name(self) -> &'static str {
match self {
Self::SourceTooLarge => "max_file_bytes",
Self::CompressedTooLarge => "max_wire_bytes",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkipRecord {
pub relative_file: String,
pub size: u64,
pub mtime_unix: i64,
pub reason: SkipReason,
pub decided_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrainManifest {
pub version: u32,
pub entries: Vec<ManifestEntry>,
#[serde(default)]
pub skips: Vec<SkipRecord>,
}
impl Default for DrainManifest {
fn default() -> Self {
Self {
version: MANIFEST_VERSION,
entries: Vec::new(),
skips: Vec::new(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatDecision {
SkipUnchanged,
NeedsHash,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ManifestOrigin {
Remote,
LocalCache,
Absent,
}
impl DrainManifest {
fn index(&self) -> HashMap<&str, &ManifestEntry> {
self.entries
.iter()
.map(|e| (e.relative_file.as_str(), e))
.collect()
}
pub fn decide(&self, relative_file: &str, size: u64, mtime_unix: i64) -> StatDecision {
match self.index().get(relative_file) {
Some(entry) if entry.size == size && entry.mtime_unix == mtime_unix => {
StatDecision::SkipUnchanged
}
_ => StatDecision::NeedsHash,
}
}
pub fn digest_matches(&self, relative_file: &str, sha256: &str) -> bool {
self.index()
.get(relative_file)
.is_some_and(|entry| entry.sha256 == sha256)
}
pub fn skip_recorded(&self, relative_file: &str, size: u64, mtime_unix: i64) -> bool {
self.skips.iter().any(|s| {
s.relative_file == relative_file && s.size == size && s.mtime_unix == mtime_unix
})
}
pub fn record_skip(&mut self, record: SkipRecord) {
match self
.skips
.iter_mut()
.find(|s| s.relative_file == record.relative_file)
{
Some(existing) => *existing = record,
None => self.skips.push(record),
}
self.skips
.sort_by(|a, b| a.relative_file.cmp(&b.relative_file));
}
pub fn forget_skip(&mut self, relative_file: &str) -> bool {
let before = self.skips.len();
self.skips.retain(|s| s.relative_file != relative_file);
self.skips.len() != before
}
pub fn record(&mut self, entry: ManifestEntry) {
match self
.entries
.iter_mut()
.find(|e| e.relative_file == entry.relative_file)
{
Some(existing) => *existing = entry,
None => self.entries.push(entry),
}
self.entries
.sort_by(|a, b| a.relative_file.cmp(&b.relative_file));
}
pub async fn load(
dest: &dyn LogDestination,
state_dir: &Path,
remote_key: &str,
cache_key: &str,
) -> Result<Self, DrainError> {
Self::load_with_origin(dest, state_dir, remote_key, cache_key)
.await
.map(|(manifest, _)| manifest)
}
pub async fn load_with_origin(
dest: &dyn LogDestination,
state_dir: &Path,
remote_key: &str,
cache_key: &str,
) -> Result<(Self, ManifestOrigin), DrainError> {
let cache_path = Self::cache_path(state_dir, dest, cache_key);
if let Some(raw) = dest.get(remote_key).await? {
match Self::decode(&raw) {
Ok(remote) => {
write_cache(&cache_path, &remote);
return Ok((remote, ManifestOrigin::Remote));
}
Err(reason) => {
tracing::warn!(
key = %remote_key,
%reason,
"log-drain manifest is undecodable; treating as absent and re-uploading"
);
}
}
}
Ok(match read_cache(&cache_path) {
Some(cached) => (cached, ManifestOrigin::LocalCache),
None => (Self::default(), ManifestOrigin::Absent),
})
}
pub async fn spot_check(&self, dest: &dyn LogDestination, key_prefix: &str) -> Option<String> {
let entry = self.entries.get(sample_index(self.entries.len())?)?;
let key = format!("{key_prefix}/{}", entry.relative_file);
match dest.head(&key).await {
Ok(Some(_)) => None,
Ok(None) => Some(key),
Err(e) => {
tracing::debug!(
%key,
error = %e,
"log-drain manifest spot check could not reach the destination"
);
None
}
}
}
pub fn cache_path(state_dir: &Path, dest: &dyn LogDestination, cache_key: &str) -> PathBuf {
state_dir
.join("log-drain")
.join(dest.cache_namespace())
.join(cache_key)
.join("manifest.json")
}
pub async fn save(
&self,
dest: &dyn LogDestination,
state_dir: &Path,
remote_key: &str,
cache_key: &str,
) -> Result<(), DrainError> {
let body = serde_json::to_vec_pretty(self).map_err(|e| DrainError::Manifest {
key: remote_key.to_string(),
reason: format!("could not serialise: {e}"),
})?;
dest.put(
remote_key,
bytes::Bytes::from(body),
super::destination::PutMeta {
content_type: Some("application/json".to_string()),
content_encoding: None,
},
)
.await?;
write_cache(&Self::cache_path(state_dir, dest, cache_key), self);
Ok(())
}
fn decode(raw: &[u8]) -> Result<Self, String> {
let parsed: Self = serde_json::from_slice(raw).map_err(|e| e.to_string())?;
if parsed.version != MANIFEST_VERSION {
return Err(format!(
"schema version {} is not the supported {MANIFEST_VERSION}",
parsed.version
));
}
Ok(parsed)
}
}
fn sample_index(len: usize) -> Option<usize> {
if len == 0 {
return None;
}
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos() as usize);
Some(nanos % len)
}
fn read_cache(path: &Path) -> Option<DrainManifest> {
let raw = std::fs::read(path).ok()?;
DrainManifest::decode(&raw).ok()
}
fn write_cache(path: &Path, manifest: &DrainManifest) {
let Some(parent) = path.parent() else { return };
if let Err(e) = std::fs::create_dir_all(parent) {
tracing::warn!(path = %parent.display(), error = %e, "log-drain cache dir unwritable");
return;
}
match serde_json::to_vec_pretty(manifest) {
Ok(body) => {
if let Err(e) = std::fs::write(path, body) {
tracing::warn!(path = %path.display(), error = %e, "log-drain cache write failed");
}
}
Err(e) => tracing::warn!(error = %e, "log-drain cache serialisation failed"),
}
}