use loonfs_api::v0::CompletedUploadPart;
use loonfs_api::{StorageChecksum, UploadId};
use loonfs_client::{MultipartUploadJournal, MultipartUploadResume};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
const XDG_STATE_SUBDIR: &str = "loonfs";
const LEGACY_STATE_SUBDIR: &str = ".loonfs/state";
const UPLOADS_SUBDIR: &str = "uploads";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct UploadState {
upload_id: String,
part_size_bytes: u64,
parts: Vec<StatePart>,
source: SourceIdentity,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct StatePart {
part_number: u32,
etag: String,
crc64nvme: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct SourceIdentity {
size_bytes: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
modified_ms: Option<u64>,
}
impl SourceIdentity {
pub(crate) fn of(path: &Path) -> std::io::Result<Self> {
let metadata = std::fs::metadata(path)?;
let modified_ms = metadata.modified().ok().and_then(|modified| {
modified
.duration_since(std::time::UNIX_EPOCH)
.ok()
.map(|since| since.as_millis() as u64)
});
Ok(Self {
size_bytes: metadata.len(),
modified_ms,
})
}
}
#[derive(Debug)]
pub(crate) struct UploadJournal {
path: PathBuf,
source: SourceIdentity,
state: Mutex<Option<UploadState>>,
}
impl UploadJournal {
pub(crate) fn for_upload(
profile: &str,
namespace: &str,
remote_path: &str,
local_path: &Path,
source: SourceIdentity,
) -> Option<Self> {
let key = StorageChecksum::sha256(
format!(
"{profile}\u{0}{namespace}\u{0}{remote_path}\u{0}{}",
local_path.display()
)
.as_bytes(),
)
.value;
Some(Self {
path: uploads_dir()?.join(format!("{key}.json")),
source,
state: Mutex::new(None),
})
}
pub(crate) fn resume(&self) -> Option<MultipartUploadResume> {
let recorded = std::fs::read(&self.path).ok()?;
let recorded: UploadState = serde_json::from_slice(&recorded).ok().or_else(|| {
self.forget();
None
})?;
if recorded.source != self.source {
self.forget();
return None;
}
let upload_id = UploadId::parse(&recorded.upload_id).ok().or_else(|| {
self.forget();
None
})?;
let resume = MultipartUploadResume {
upload_id,
part_size_bytes: recorded.part_size_bytes,
parts: recorded
.parts
.iter()
.map(|part| CompletedUploadPart {
part_number: part.part_number,
etag: part.etag.clone(),
crc64nvme: part.crc64nvme.clone(),
})
.collect(),
};
*self.lock() = Some(recorded);
Some(resume)
}
pub(crate) fn forget(&self) {
let _ = std::fs::remove_file(&self.path);
*self.lock() = None;
}
fn flush(&self, state: &UploadState) {
let Some(parent) = self.path.parent() else {
return;
};
if std::fs::create_dir_all(parent).is_err() {
return;
}
if let Ok(encoded) = serde_json::to_vec(state) {
let _ = std::fs::write(&self.path, encoded);
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Option<UploadState>> {
self.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
impl MultipartUploadJournal for UploadJournal {
fn began(&self, upload_id: &UploadId, part_size_bytes: u64) {
let state = UploadState {
upload_id: upload_id.to_string(),
part_size_bytes,
parts: Vec::new(),
source: self.source.clone(),
};
self.flush(&state);
*self.lock() = Some(state);
}
fn part_completed(&self, part: &CompletedUploadPart) {
let mut held = self.lock();
let Some(state) = held.as_mut() else {
return;
};
state.parts.push(StatePart {
part_number: part.part_number,
etag: part.etag.clone(),
crc64nvme: part.crc64nvme.clone(),
});
self.flush(state);
}
}
fn uploads_dir() -> Option<PathBuf> {
if let Some(state_home) = absolute_env_path("XDG_STATE_HOME") {
return Some(state_home.join(XDG_STATE_SUBDIR).join(UPLOADS_SUBDIR));
}
let home = absolute_env_path("HOME")?;
Some(home.join(LEGACY_STATE_SUBDIR).join(UPLOADS_SUBDIR))
}
fn absolute_env_path(name: &str) -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os(name)?);
path.is_absolute().then_some(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn identity(size_bytes: u64, modified_ms: u64) -> SourceIdentity {
SourceIdentity {
size_bytes,
modified_ms: Some(modified_ms),
}
}
fn journal_at(dir: &Path, remote_path: &str, source: SourceIdentity) -> UploadJournal {
UploadJournal {
path: dir.join(format!("{}.json", remote_path.replace('/', "_"))),
source,
state: Mutex::new(None),
}
}
fn part(part_number: u32) -> CompletedUploadPart {
CompletedUploadPart {
part_number,
etag: format!("\"etag-{part_number}\""),
crc64nvme: "00000000000000".to_owned(),
}
}
fn upload_id() -> UploadId {
UploadId::parse("upl_00000000000000000000000000000001").expect("valid upload id")
}
#[test]
fn a_record_hands_the_next_run_the_parts_that_landed() {
let dir = tempfile::tempdir().expect("tempdir");
let source = identity(1024, 7);
let journal = journal_at(dir.path(), "/big.bin", source.clone());
assert_eq!(journal.resume(), None, "nothing recorded resumes nothing");
journal.began(&upload_id(), 1024 * 1024);
journal.part_completed(&part(1));
journal.part_completed(&part(2));
let resumed = journal_at(dir.path(), "/big.bin", source.clone())
.resume()
.expect("a record of the same upload");
assert_eq!(resumed.upload_id, upload_id());
assert_eq!(resumed.part_size_bytes, 1024 * 1024);
assert_eq!(
resumed
.parts
.iter()
.map(|p| p.part_number)
.collect::<Vec<_>>(),
vec![1, 2]
);
let second = journal_at(dir.path(), "/big.bin", source.clone());
second.resume().expect("a record to pick up");
second.part_completed(&part(3));
assert_eq!(
journal_at(dir.path(), "/big.bin", source)
.resume()
.expect("a record")
.parts
.iter()
.map(|p| p.part_number)
.collect::<Vec<_>>(),
vec![1, 2, 3]
);
journal.forget();
assert_eq!(journal.resume(), None, "a committed upload keeps nothing");
}
#[test]
fn a_source_that_changed_invalidates_its_record() {
let dir = tempfile::tempdir().expect("tempdir");
let journal = journal_at(dir.path(), "/big.bin", identity(1024, 7));
journal.began(&upload_id(), 1024 * 1024);
journal.part_completed(&part(1));
let rewritten = journal_at(dir.path(), "/big.bin", identity(1024, 8));
assert_eq!(rewritten.resume(), None, "a newer file is a different file");
assert!(
!rewritten.path.exists(),
"an invalidated record is removed rather than left to mislead"
);
let resized = journal_at(dir.path(), "/big.bin", identity(2048, 7));
assert_eq!(resized.resume(), None, "a longer file is a different file");
}
#[test]
fn an_unreadable_record_is_discarded() {
let dir = tempfile::tempdir().expect("tempdir");
let journal = journal_at(dir.path(), "/big.bin", identity(1024, 7));
std::fs::write(&journal.path, b"{\"upload_id\":").expect("write torn record");
assert_eq!(journal.resume(), None);
assert!(!journal.path.exists());
}
#[test]
fn the_record_is_named_by_what_decides_which_upload_it_is() {
let source = identity(1024, 7);
let path = |profile, namespace, remote, local| {
UploadJournal::for_upload(profile, namespace, remote, Path::new(local), source.clone())
.map(|journal| journal.path)
};
let baseline = path("default", "demo", "/big.bin", "/tmp/big.bin");
assert!(baseline.is_some(), "a home directory names a state file");
assert_eq!(
baseline,
path("default", "demo", "/big.bin", "/tmp/big.bin")
);
assert_ne!(baseline, path("other", "demo", "/big.bin", "/tmp/big.bin"));
assert_ne!(
baseline,
path("default", "prod", "/big.bin", "/tmp/big.bin")
);
assert_ne!(
baseline,
path("default", "demo", "/other.bin", "/tmp/big.bin")
);
assert_ne!(
baseline,
path("default", "demo", "/big.bin", "/tmp/other.bin")
);
}
}