Release v0.2.1
This commit is contained in:
parent
ffa8bc93ba
commit
048df6355e
10 changed files with 1467 additions and 254 deletions
198
src/zip_cache.rs
198
src/zip_cache.rs
|
|
@ -1,4 +1,4 @@
|
|||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, File};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -24,10 +24,18 @@ pub struct ArchiveSource {
|
|||
|
||||
pub struct ZipCache {
|
||||
directory: PathBuf,
|
||||
quota_bytes: u64,
|
||||
quota_bytes: Cell<u64>,
|
||||
pinned: RefCell<HashMap<PathBuf, usize>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct CacheCleanupReport {
|
||||
pub removed_files: usize,
|
||||
pub removed_bytes: u64,
|
||||
pub retained_open_files: usize,
|
||||
pub remaining_bytes: u64,
|
||||
}
|
||||
|
||||
struct CachePin {
|
||||
path: PathBuf,
|
||||
cache: Weak<ZipCache>,
|
||||
|
|
@ -37,12 +45,18 @@ impl Drop for CachePin {
|
|||
fn drop(&mut self) {
|
||||
if let Some(cache) = self.cache.upgrade() {
|
||||
let mut pinned = cache.pinned.borrow_mut();
|
||||
let mut released = false;
|
||||
if let Some(count) = pinned.get_mut(&self.path) {
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
pinned.remove(&self.path);
|
||||
released = true;
|
||||
}
|
||||
}
|
||||
drop(pinned);
|
||||
if released {
|
||||
let _ = cache.enforce_quota(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,18 +66,24 @@ impl ZipCache {
|
|||
fs::create_dir_all(&directory)?;
|
||||
for entry in fs::read_dir(&directory)? {
|
||||
let path = entry?.path();
|
||||
if path
|
||||
let incomplete_download = path
|
||||
.extension()
|
||||
.is_some_and(|extension| extension == "part")
|
||||
{
|
||||
.is_some_and(|extension| extension == "part");
|
||||
let stale_temporary = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("temporary-"));
|
||||
if incomplete_download || stale_temporary {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
Ok(Rc::new(Self {
|
||||
let cache = Rc::new(Self {
|
||||
directory,
|
||||
quota_bytes: quota_gb.saturating_mul(1024 * 1024 * 1024),
|
||||
quota_bytes: Cell::new(quota_gb.saturating_mul(1024 * 1024 * 1024)),
|
||||
pinned: RefCell::new(HashMap::new()),
|
||||
}))
|
||||
});
|
||||
cache.enforce_quota(None)?;
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
pub fn prepare<F, P>(self: &Rc<Self>, entry: BrowserEntry, progress: P, callback: F)
|
||||
|
|
@ -83,7 +103,8 @@ impl ZipCache {
|
|||
return;
|
||||
}
|
||||
|
||||
if self.quota_bytes == 0 || entry.size.is_some_and(|size| size > self.quota_bytes) {
|
||||
let quota_bytes = self.quota_bytes.get();
|
||||
if quota_bytes == 0 || entry.size.is_some_and(|size| size > quota_bytes) {
|
||||
self.download_temporary(source, entry, true, progress, callback);
|
||||
} else {
|
||||
self.download_persistent(source, entry, progress, callback);
|
||||
|
|
@ -127,7 +148,11 @@ impl ZipCache {
|
|||
|| ".part".to_owned(),
|
||||
|extension| format!(".part.{extension}"),
|
||||
);
|
||||
let temporary = match tempfile::Builder::new().suffix(&suffix).tempfile() {
|
||||
let temporary = match tempfile::Builder::new()
|
||||
.prefix("temporary-")
|
||||
.suffix(&suffix)
|
||||
.tempfile_in(&self.directory)
|
||||
{
|
||||
Ok(file) => Rc::new(file.into_temp_path()),
|
||||
Err(error) => {
|
||||
callback(Err(error.into()));
|
||||
|
|
@ -183,6 +208,12 @@ impl ZipCache {
|
|||
}
|
||||
|
||||
let part_path = self.directory.join(format!("{key}.part"));
|
||||
if let Some(required_bytes) = entry.size
|
||||
&& let Err(error) = self.make_room_for(required_bytes)
|
||||
{
|
||||
callback(Err(error));
|
||||
return;
|
||||
}
|
||||
let destination = gio::File::for_path(&part_path);
|
||||
let cache = Rc::clone(self);
|
||||
source.copy_async(
|
||||
|
|
@ -200,7 +231,7 @@ impl ZipCache {
|
|||
final_path.display()
|
||||
)
|
||||
})?;
|
||||
cache.enforce_quota(&final_path)?;
|
||||
cache.enforce_quota(Some(&final_path))?;
|
||||
Ok(cache.lease(final_path, entry.uri, entry.name))
|
||||
});
|
||||
if result.is_err() {
|
||||
|
|
@ -211,11 +242,35 @@ impl ZipCache {
|
|||
);
|
||||
}
|
||||
|
||||
fn enforce_quota(&self, protected: &Path) -> Result<()> {
|
||||
pub fn clear_persistent(&self) -> Result<CacheCleanupReport> {
|
||||
self.cleanup_to_limit(0, None)
|
||||
}
|
||||
|
||||
fn make_room_for(&self, required_bytes: u64) -> Result<()> {
|
||||
let limit = self.quota_bytes.get().saturating_sub(required_bytes);
|
||||
let report = self.cleanup_to_limit(limit, None)?;
|
||||
if report.remaining_bytes > limit {
|
||||
bail!("열려 있는 ZIP 때문에 다운로드에 필요한 캐시 공간을 확보할 수 없습니다");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn enforce_quota(&self, protected: Option<&Path>) -> Result<CacheCleanupReport> {
|
||||
self.cleanup_to_limit(self.quota_bytes.get(), protected)
|
||||
}
|
||||
|
||||
fn cleanup_to_limit(&self, limit: u64, protected: Option<&Path>) -> Result<CacheCleanupReport> {
|
||||
let mut files = Vec::new();
|
||||
let mut total = 0_u64;
|
||||
for entry in fs::read_dir(&self.directory)? {
|
||||
let path = entry?.path();
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with("temporary-"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if path.extension().is_none_or(|extension| extension != "zip") {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -224,18 +279,26 @@ impl ZipCache {
|
|||
files.push((metadata.modified().ok(), metadata.len(), path));
|
||||
}
|
||||
files.sort_by_key(|(modified, _, path)| (*modified, path.clone()));
|
||||
let mut report = CacheCleanupReport::default();
|
||||
for (_, size, path) in files {
|
||||
if total <= self.quota_bytes {
|
||||
if total <= limit {
|
||||
break;
|
||||
}
|
||||
if path != protected
|
||||
&& !self.pinned.borrow().contains_key(&path)
|
||||
&& fs::remove_file(&path).is_ok()
|
||||
{
|
||||
total = total.saturating_sub(size);
|
||||
if protected == Some(path.as_path()) {
|
||||
continue;
|
||||
}
|
||||
if self.pinned.borrow().contains_key(&path) {
|
||||
report.retained_open_files += 1;
|
||||
continue;
|
||||
}
|
||||
fs::remove_file(&path)
|
||||
.with_context(|| format!("캐시 파일을 삭제할 수 없습니다: {}", path.display()))?;
|
||||
total = total.saturating_sub(size);
|
||||
report.removed_files += 1;
|
||||
report.removed_bytes = report.removed_bytes.saturating_add(size);
|
||||
}
|
||||
Ok(())
|
||||
report.remaining_bytes = total;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn lease(
|
||||
|
|
@ -351,7 +414,7 @@ mod tests {
|
|||
fs::write(&second, [0_u8; 8]).unwrap();
|
||||
let cache = Rc::new(ZipCache {
|
||||
directory: directory.path().to_path_buf(),
|
||||
quota_bytes: 8,
|
||||
quota_bytes: Cell::new(8),
|
||||
pinned: RefCell::new(HashMap::new()),
|
||||
});
|
||||
|
||||
|
|
@ -360,13 +423,104 @@ mod tests {
|
|||
"file:///first.zip".into(),
|
||||
"first.zip".into(),
|
||||
);
|
||||
cache.enforce_quota(&second).unwrap();
|
||||
cache.enforce_quota(Some(&second)).unwrap();
|
||||
assert!(first.exists());
|
||||
assert!(second.exists());
|
||||
|
||||
drop(lease);
|
||||
cache.enforce_quota(&second).unwrap();
|
||||
cache.enforce_quota(Some(&second)).unwrap();
|
||||
assert!(!first.exists());
|
||||
assert!(second.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lru_removes_oldest_cache_file_first() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let oldest = directory.path().join("oldest.zip");
|
||||
let newest = directory.path().join("newest.zip");
|
||||
fs::write(&oldest, [0_u8; 8]).unwrap();
|
||||
fs::write(&newest, [0_u8; 8]).unwrap();
|
||||
filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
|
||||
filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
|
||||
let cache = ZipCache {
|
||||
directory: directory.path().to_path_buf(),
|
||||
quota_bytes: Cell::new(8),
|
||||
pinned: RefCell::new(HashMap::new()),
|
||||
};
|
||||
|
||||
let report = cache.enforce_quota(None).unwrap();
|
||||
|
||||
assert_eq!(report.removed_files, 1);
|
||||
assert!(!oldest.exists());
|
||||
assert!(newest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_reserves_space_before_writing() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let oldest = directory.path().join("oldest.zip");
|
||||
let newest = directory.path().join("newest.zip");
|
||||
fs::write(&oldest, [0_u8; 8]).unwrap();
|
||||
fs::write(&newest, [0_u8; 8]).unwrap();
|
||||
filetime::set_file_mtime(&oldest, FileTime::from_unix_time(1, 0)).unwrap();
|
||||
filetime::set_file_mtime(&newest, FileTime::from_unix_time(2, 0)).unwrap();
|
||||
let cache = ZipCache {
|
||||
directory: directory.path().to_path_buf(),
|
||||
quota_bytes: Cell::new(16),
|
||||
pinned: RefCell::new(HashMap::new()),
|
||||
};
|
||||
|
||||
cache.make_room_for(8).unwrap();
|
||||
|
||||
assert!(!oldest.exists());
|
||||
assert!(newest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_quota_clears_existing_cache_on_startup() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cached = directory.path().join("cached.zip");
|
||||
fs::write(&cached, [0_u8; 8]).unwrap();
|
||||
|
||||
let _cache = ZipCache::new(directory.path().to_path_buf(), 0).unwrap();
|
||||
|
||||
assert!(!cached.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn startup_removes_stale_temporary_downloads() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let temporary = directory.path().join("temporary-abcd.part.mp4");
|
||||
fs::write(&temporary, [0_u8; 8]).unwrap();
|
||||
|
||||
let _cache = ZipCache::new(directory.path().to_path_buf(), 10).unwrap();
|
||||
|
||||
assert!(!temporary.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_clear_keeps_open_archives() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let open = directory.path().join("open.zip");
|
||||
let unused = directory.path().join("unused.zip");
|
||||
let temporary = directory.path().join("temporary-download.part.zip");
|
||||
fs::write(&open, [0_u8; 8]).unwrap();
|
||||
fs::write(&unused, [0_u8; 8]).unwrap();
|
||||
fs::write(&temporary, [0_u8; 8]).unwrap();
|
||||
let cache = Rc::new(ZipCache {
|
||||
directory: directory.path().to_path_buf(),
|
||||
quota_bytes: Cell::new(16),
|
||||
pinned: RefCell::new(HashMap::new()),
|
||||
});
|
||||
let lease = cache.lease(open.clone(), "file:///open.zip".into(), "open.zip".into());
|
||||
|
||||
let report = cache.clear_persistent().unwrap();
|
||||
|
||||
assert_eq!(report.removed_files, 1);
|
||||
assert_eq!(report.retained_open_files, 1);
|
||||
assert!(open.exists());
|
||||
assert!(!unused.exists());
|
||||
assert!(temporary.exists());
|
||||
drop(lease);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue