Skip to content

Commit d5ba397

Browse files
committed
Implement bitcache compact.
1 parent a8c1a2c commit d5ba397

4 files changed

Lines changed: 152 additions & 0 deletions

File tree

‎rust/lib/bitcache-core/src/repository.rs‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,15 @@ pub trait Repository: Send + Sync {
160160
async { Ok(false) }
161161
}
162162

163+
/// Performs backend-specific repository maintenance.
164+
///
165+
/// The default implementation is a no-op. Backends may override this to
166+
/// compact or otherwise optimize their physical storage without changing
167+
/// the repository's logical contents.
168+
fn compact(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send {
169+
async { Ok(()) }
170+
}
171+
163172
/// Removes all blobs, resetting the repository to an empty state.
164173
fn clear(&mut self) -> impl Future<Output = Result<(), Self::Error>> + Send;
165174

‎rust/lib/bitcache-fs/src/repository.rs‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,76 @@ impl FsRepository {
417417
}
418418
}
419419

420+
/// Rewrites an uncompressed blob using maximum XZ compression.
421+
#[cfg(feature = "tokio")]
422+
async fn compact_blob(&self, id: &Id) -> Result<(), RepositoryError> {
423+
use async_compression::{Level, tokio::write::XzEncoder};
424+
use bitcache_core::tokio::{
425+
fs::File,
426+
io::{AsyncWriteExt, copy},
427+
};
428+
429+
let source_name = Self::path(id);
430+
let source_file = match self.0.open(&source_name) {
431+
Ok(file) => file,
432+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
433+
Err(error) => return Err(error.into()),
434+
};
435+
let extended = self.read_extended(&source_name, &source_file)?;
436+
let mut source_file = File::from_std(source_file.into_std());
437+
let (temp_name, temp_file) = self.create_temp_file()?;
438+
let temp_file = File::from_std(temp_file.into_std());
439+
let mut encoder = XzEncoder::with_quality(temp_file, Level::Best);
440+
441+
let result: Result<(), RepositoryError> = async {
442+
copy(&mut source_file, &mut encoder).await?;
443+
encoder.shutdown().await?;
444+
Ok(())
445+
}
446+
.await;
447+
drop(encoder);
448+
drop(source_file);
449+
450+
if let Err(error) = result {
451+
let _ = self.0.remove_file(&temp_name);
452+
return Err(error);
453+
}
454+
455+
let temp_file = match self.0.open(&temp_name) {
456+
Ok(file) => file,
457+
Err(error) => {
458+
let _ = self.0.remove_file(&temp_name);
459+
return Err(error.into());
460+
},
461+
};
462+
if let Err(error) = self.prepare_temp(&temp_name, &temp_file, &extended) {
463+
drop(temp_file);
464+
let _ = self.0.remove_file(&temp_name);
465+
return Err(error);
466+
}
467+
drop(temp_file);
468+
469+
let compressed_name = Self::compressed_path(id);
470+
match self.0.remove_file(&compressed_name) {
471+
Ok(()) => (),
472+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => (),
473+
Err(error) => {
474+
let _ = self.0.remove_file(&temp_name);
475+
return Err(error.into());
476+
},
477+
}
478+
if let Err(error) = self.0.hard_link(&temp_name, &self.0, &compressed_name) {
479+
let _ = self.0.remove_file(&temp_name);
480+
return Err(error.into());
481+
}
482+
self.0.remove_file(&temp_name)?;
483+
match self.0.remove_file(source_name) {
484+
Ok(()) => Ok(()),
485+
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
486+
Err(error) => Err(error.into()),
487+
}
488+
}
489+
420490
/// Collects all physical blob IDs, including expired blobs.
421491
fn collect_physical_ids(&self) -> Result<Vec<Id>, RepositoryError> {
422492
let mut ids = Vec::new();
@@ -758,6 +828,19 @@ impl Repository for FsRepository {
758828
self.write_existing_extended(&blob.name, &blob.file, &blob.extended)
759829
}
760830

831+
async fn compact(&mut self) -> Result<(), Self::Error> {
832+
#[cfg(feature = "tokio")]
833+
{
834+
for id in self.collect_physical_ids()? {
835+
self.compact_blob(&id).await?;
836+
}
837+
Ok(())
838+
}
839+
840+
#[cfg(not(feature = "tokio"))]
841+
Ok(())
842+
}
843+
761844
async fn clear(&mut self) -> Result<(), Self::Error> {
762845
for id in self.collect_physical_ids()? {
763846
for encoding in [BlobEncoding::Uncompressed, BlobEncoding::Xz] {

‎rust/lib/bitcache-fs/tests/repository.rs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,54 @@ async fn test_compressed_storage_listing_and_uncompressed_preference() {
249249
assert!(!compressed_path.exists());
250250
}
251251

252+
#[tokio::test]
253+
async fn test_compact_compresses_uncompressed_blobs() {
254+
let temp_dir = TestDir::new();
255+
let repository_path = temp_dir.path().join("repository");
256+
let mut repository = FsRepository::create(repository_path.to_str().unwrap()).unwrap();
257+
let data = b"legacy uncompressed blob\n".repeat(1_024);
258+
let id = Id::of(&data);
259+
let uncompressed_path = uncompressed_blob_path(&repository_path, &id);
260+
let compressed_path = blob_path(&repository_path, &id);
261+
fs::write(&uncompressed_path, &data).unwrap();
262+
set_xattr(
263+
&uncompressed_path,
264+
"user.bitcache.media-type",
265+
b"text/plain",
266+
);
267+
268+
repository.compact().await.unwrap();
269+
270+
assert!(!uncompressed_path.exists());
271+
assert!(compressed_path.exists());
272+
let compacted_metadata = fs::metadata(&compressed_path).unwrap();
273+
assert_eq!(compacted_metadata.permissions().mode() & 0o777, 0o444);
274+
assert_eq!(read_blob_file(&repository, &id).await, data);
275+
assert_eq!(
276+
repository
277+
.get(&id)
278+
.await
279+
.unwrap()
280+
.unwrap()
281+
.metadata()
282+
.media_type(),
283+
Some("text/plain")
284+
);
285+
assert!(fs::read_dir(&repository_path).unwrap().all(|entry| {
286+
!entry
287+
.unwrap()
288+
.file_name()
289+
.to_string_lossy()
290+
.starts_with(".put-")
291+
}));
292+
293+
repository.compact().await.unwrap();
294+
assert_eq!(
295+
fs::metadata(&compressed_path).unwrap().ino(),
296+
compacted_metadata.ino()
297+
);
298+
}
299+
252300
#[tokio::test]
253301
async fn test_fs_repository_expiry_and_media_type_round_trip() {
254302
let temp_dir = TestDir::new();

‎rust/lib/bitcache/src/main.rs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ enum Command {
134134
ids: Vec<Id>,
135135
},
136136

137+
/// Compact the repository's physical storage.
138+
///
139+
/// Filesystem repositories rewrite uncompressed blobs using maximum XZ
140+
/// compression. Other repository backends may perform no maintenance.
141+
Compact {},
142+
137143
/// Remove all blobs from the repository.
138144
///
139145
/// As a safety measure, this requires the `--force` flag; without it,
@@ -474,6 +480,12 @@ pub async fn main() -> Result<(), SysexitsError> {
474480
Ok(())
475481
},
476482

483+
Command::Compact {} => {
484+
let mut repository = bitcache::open_env("BITCACHE_URL", "file:.bitcache").await?;
485+
repository.compact().await?;
486+
Ok(())
487+
},
488+
477489
Command::Clear { force } => {
478490
if !force {
479491
eprintln!("bitcache: refusing to clear the repository without --force");

0 commit comments

Comments
 (0)