Skip to content

Commit 71b9746

Browse files
committed
🐛 fix(storage): honor reclaim-guard expiry on admission
A reclaim guard carries a 300-second lease. Arming honors it, so a lapsed row is available to the next collector, but reference admission read no clock and rejected a commit whenever a row was present. A confirmed orphan purge that died between deleting the bytes and disarming its guard left a row nothing ever clears, and every later publication of that digest failed with BlobReclaiming for as long as the store lived. The admission check now takes the store's clock and blocks only while the lease holds, dropping the lapsed row in the same write transaction it admits past so an abandoned guard cannot outlive its lease. MetaStore gained the clock as an injectable field rather than reading wall time at the call site, which is what lets a test step across a lease boundary instead of waiting one out. The release pass in the purge no longer asks the blob store whether the bytes are present. An expired lease already proves its collector let go, and requiring absence was exactly what kept a failed delete's guard alive forever. Dropping the presence probe also removes the digest parse and the head request that only existed to answer it. Guarding the guard table's existence keeps the check from creating it, so publishing a blob reference on a fresh store no longer materializes a distributed domain table.
1 parent da3120a commit 71b9746

7 files changed

Lines changed: 232 additions & 70 deletions

File tree

crates/peryx-ha-distributed/src/reclaim_guard.rs

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ pub fn purge_orphaned_blobs(
5151
));
5252
}
5353

54-
release_absent_expired_guards(meta, blobs, now)?;
54+
release_expired_guards(meta, now)?;
5555
let guard = ReclaimGuard {
5656
expires_at_unix: now.saturating_add(RECLAIM_GUARD_LEASE_SECS),
5757
};
@@ -84,25 +84,12 @@ pub fn purge_orphaned_blobs(
8484
Ok(report(selected))
8585
}
8686

87-
fn release_absent_expired_guards(meta: &MetaStore, blobs: &BlobStorage, now: i64) -> Result<(), OrphanPurgeError> {
88-
for (encoded, guard) in meta.reclaim_guards()? {
89-
if !guard.is_expired_at(now) {
90-
continue;
91-
}
92-
let digest = Digest::from_hex(&encoded).ok_or_else(|| OrphanPurgeError::Blob {
93-
operation: "read orphan reclaim guard",
94-
reason: format!("invalid SHA-256 digest {encoded:?}"),
95-
})?;
96-
let absent = blobs
97-
.blocking()
98-
.head(&digest)
99-
.map_err(|error| OrphanPurgeError::Blob {
100-
operation: "inspect guarded blob",
101-
reason: error.to_string(),
102-
})?
103-
.is_none();
104-
if absent {
105-
meta.compare_and_disarm_reclaim_guard(&encoded, guard)?;
87+
/// A lapsed lease proves its collector no longer holds the blob, whether or not the bytes survived
88+
/// the purge that armed it, so the row goes regardless of what the store reports.
89+
fn release_expired_guards(meta: &MetaStore, now: i64) -> Result<(), OrphanPurgeError> {
90+
for (digest, guard) in meta.reclaim_guards()? {
91+
if guard.is_expired_at(now) {
92+
meta.compare_and_disarm_reclaim_guard(&digest, guard)?;
10693
}
10794
}
10895
Ok(())

crates/peryx-ha-distributed/tests/unit/reclaim_guard_tests.rs

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
use std::collections::BTreeSet;
2+
use std::sync::Arc;
3+
use std::sync::atomic::{AtomicI64, Ordering};
24

35
use peryx_ha::{ReclaimGuard, ReclaimGuardArm, ReclaimGuardStore as _};
46
use peryx_storage::blob::BlobStorage;
@@ -118,30 +120,33 @@ fn expired_owner_is_replaced_before_deletion() {
118120
}
119121

120122
#[test]
121-
fn expired_guard_for_an_absent_blob_is_released() {
123+
fn expired_guard_for_a_present_blob_is_released() {
122124
let (_directory, _path, meta, blobs) = stores();
123-
let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
124-
meta.compare_and_arm_reclaim_guards(&[digest], 0, 0, ReclaimGuard { expires_at_unix: 10 })
125+
let kept = blobs.blocking().put_bytes(b"kept").unwrap();
126+
meta.compare_and_arm_reclaim_guards(&[kept.as_str()], 0, 0, ReclaimGuard { expires_at_unix: 10 })
125127
.unwrap();
126128

127-
let report = purge_orphaned_blobs(&meta, &blobs, true, 10, || Ok(BTreeSet::new())).unwrap();
129+
let report = purge_orphaned_blobs(&meta, &blobs, true, 10, || {
130+
Ok(BTreeSet::from([kept.as_str().to_owned()]))
131+
})
132+
.unwrap();
128133

129134
assert!(report.blobs.is_empty());
130-
assert_eq!(meta.reclaim_guard(digest).unwrap(), None);
135+
assert!(blobs.blocking().head(&kept).unwrap().is_some());
136+
assert_eq!(meta.reclaim_guard(kept.as_str()).unwrap(), None);
131137
}
132138

133139
#[test]
134-
fn invalid_expired_guard_stops_the_purge() {
140+
fn expired_guard_for_an_absent_blob_is_released() {
135141
let (_directory, _path, meta, blobs) = stores();
136-
meta.compare_and_arm_reclaim_guards(&["invalid"], 0, 0, ReclaimGuard { expires_at_unix: 10 })
142+
let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
143+
meta.compare_and_arm_reclaim_guards(&[digest], 0, 0, ReclaimGuard { expires_at_unix: 10 })
137144
.unwrap();
138145

139-
let error = purge_orphaned_blobs(&meta, &blobs, true, 10, || Ok(BTreeSet::new())).unwrap_err();
146+
let report = purge_orphaned_blobs(&meta, &blobs, true, 10, || Ok(BTreeSet::new())).unwrap();
140147

141-
assert_eq!(
142-
error.to_string(),
143-
"read orphan reclaim guard: invalid SHA-256 digest \"invalid\""
144-
);
148+
assert!(report.blobs.is_empty());
149+
assert_eq!(meta.reclaim_guard(digest).unwrap(), None);
145150
}
146151

147152
#[test]
@@ -207,24 +212,49 @@ fn deletion_failure_keeps_the_guard_armed() {
207212
}
208213

209214
#[test]
210-
fn guarded_blob_inspection_failure_stops_the_purge() {
215+
fn an_interrupted_purge_stops_rejecting_references_once_its_lease_lapses() {
211216
let directory = tempfile::tempdir().unwrap();
212-
let root = directory.path().join("blobs");
213-
std::fs::create_dir_all(root.join("sha256")).unwrap();
214-
std::fs::write(root.join("sha256/01"), b"not a directory").unwrap();
215-
let meta = MetaStore::open(directory.path().join("peryx.redb")).unwrap();
216-
let digest = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
217-
meta.compare_and_arm_reclaim_guards(&[digest], 0, 0, ReclaimGuard { expires_at_unix: 10 })
218-
.unwrap();
217+
let ticks = Arc::new(AtomicI64::new(10));
218+
let clock = Arc::clone(&ticks);
219+
let meta = MetaStore::open(directory.path().join("peryx.redb"))
220+
.unwrap()
221+
.with_clock(Arc::new(move || clock.load(Ordering::Relaxed)));
222+
let blobs = BlobStorage::filesystem(directory.path().join("blobs"));
223+
let orphan = blobs.blocking().put_bytes(b"orphan").unwrap();
224+
let orphan_path = purge_orphaned_blobs(&meta, &blobs, false, 10, || Ok(BTreeSet::new()))
225+
.unwrap()
226+
.blobs
227+
.pop()
228+
.unwrap()
229+
.path;
230+
let mut scans = 0;
231+
purge_orphaned_blobs(&meta, &blobs, true, 10, || {
232+
scans += 1;
233+
if scans == 2 {
234+
std::fs::remove_file(&orphan_path).unwrap();
235+
std::fs::create_dir(&orphan_path).unwrap();
236+
std::fs::write(orphan_path.join("entry"), b"occupied").unwrap();
237+
}
238+
Ok(BTreeSet::new())
239+
})
240+
.unwrap_err();
241+
let lease = ReclaimGuard {
242+
expires_at_unix: 10 + RECLAIM_GUARD_LEASE_SECS,
243+
};
244+
assert_eq!(meta.reclaim_guard(orphan.as_str()).unwrap(), Some(lease));
219245

220-
let error =
221-
purge_orphaned_blobs(&meta, &BlobStorage::filesystem(root), true, 10, || Ok(BTreeSet::new())).unwrap_err();
246+
ticks.store(lease.expires_at_unix - 1, Ordering::Relaxed);
247+
let rejected = republish(&meta, orphan.as_str()).unwrap_err();
248+
ticks.store(lease.expires_at_unix, Ordering::Relaxed);
249+
republish(&meta, orphan.as_str()).unwrap();
222250

223-
assert!(matches!(
224-
error,
225-
OrphanPurgeError::Blob {
226-
operation: "inspect guarded blob",
227-
..
228-
}
229-
));
251+
assert!(matches!(rejected, MetaError::BlobReclaiming { digest } if digest == orphan.as_str()));
252+
assert_eq!(meta.reclaim_guard(orphan.as_str()).unwrap(), None);
253+
}
254+
255+
fn republish(meta: &MetaStore, digest: &str) -> Result<(), MetaError> {
256+
meta.commit_driver_txn(|txn| {
257+
txn.reference_blob(digest, 6);
258+
Ok::<_, MetaError>(((), vec![b"{}".to_vec()]))
259+
})
230260
}

crates/peryx-storage/src/meta/index.rs

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
use peryx_ha::ArtifactPlacement;
2-
use redb::ReadableTable as _;
1+
use peryx_core::Clock;
2+
use peryx_ha::{ArtifactPlacement, ReclaimGuard};
3+
use redb::{ReadableTable as _, TableHandle as _};
34

45
use super::error::{MetaError, MetaScanError};
56
use super::policy_decision::advance_repository_generations;
@@ -402,7 +403,7 @@ impl MetaStore {
402403
}
403404
Self::enqueue_webhook_events(&txn, &webhooks).map_err(E::from)?;
404405
Self::write_artifact_placements(&txn, &placements).map_err(E::from)?;
405-
check_blob_reclaim_guard(&txn, expected_serial, &journal).map_err(E::from)?;
406+
check_blob_reclaim_guard(&txn, expected_serial, &journal, &self.clock).map_err(E::from)?;
406407
let journal_commit = commit_journal(&txn, &journal)?;
407408
advance_repository_generations(&txn, &policy_inputs).map_err(E::from)?;
408409
if let Some((repository, catalog)) = catalog_generation {
@@ -450,28 +451,47 @@ fn finish_journal(journal: PendingJournal, driver: DriverTxn<'_>) -> Result<Vec<
450451
}
451452
}
452453

454+
/// Rejects a reference only while a collector still holds the blob's lease. A lapsed guard is the
455+
/// residue of a purge that died between arming and disarming, so it is dropped here rather than left
456+
/// to reject every later publication of that digest.
453457
fn check_blob_reclaim_guard(
454458
txn: &redb::WriteTransaction,
455459
expected_serial: Option<u64>,
456460
journal: &[JournalEntry],
461+
clock: &Clock,
457462
) -> Result<(), MetaError> {
458463
let Some(last) = journal.last() else {
459464
return Ok(());
460465
};
461466
if expected_serial.is_some() || last.blobs.is_empty() {
462467
return Ok(());
463468
}
464-
let guards = txn.open_table(BLOB_RECLAIM_GUARD)?;
469+
if !table_exists(txn, &BLOB_RECLAIM_GUARD)? {
470+
return Ok(());
471+
}
472+
let now = clock();
473+
let mut guards = txn.open_table(BLOB_RECLAIM_GUARD)?;
465474
for blob in &last.blobs {
466-
if guards.get(blob.sha256.as_str())?.is_some() {
475+
let held = guards.get(blob.sha256.as_str())?.map(|value| ReclaimGuard {
476+
expires_at_unix: value.value(),
477+
});
478+
let Some(guard) = held else {
479+
continue;
480+
};
481+
if !guard.is_expired_at(now) {
467482
return Err(MetaError::BlobReclaiming {
468483
digest: blob.sha256.clone(),
469484
});
470485
}
486+
guards.remove(blob.sha256.as_str())?;
471487
}
472488
Ok(())
473489
}
474490

491+
fn table_exists(txn: &redb::WriteTransaction, table: &impl redb::TableHandle) -> Result<bool, MetaError> {
492+
Ok(txn.list_tables()?.any(|handle| handle.name() == table.name()))
493+
}
494+
475495
fn update_policy_generation(txn: &redb::WriteTransaction, repository: &str, catalog: u64) -> Result<(), MetaError> {
476496
let mut generations = txn.open_table(POLICY_INPUT_GENERATION)?;
477497
let mut generation = generations

crates/peryx-storage/src/meta/mod.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
44
use std::path::Path;
55
use std::sync::Arc;
6+
use std::time::{SystemTime, UNIX_EPOCH};
67

8+
use peryx_core::Clock;
79
use redb::{Database, ReadOnlyDatabase, ReadableDatabase as _, TableDefinition};
810

911
mod analytics;
@@ -220,9 +222,19 @@ impl DriverBatch {
220222
}
221223
}
222224

223-
#[derive(Debug, Clone)]
225+
#[derive(Clone)]
224226
pub struct MetaStore {
225227
db: Arc<MetaDatabase>,
228+
clock: Clock,
229+
}
230+
231+
impl std::fmt::Debug for MetaStore {
232+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233+
formatter
234+
.debug_struct("MetaStore")
235+
.field("db", &self.db)
236+
.finish_non_exhaustive()
237+
}
226238
}
227239

228240
enum MetaDatabase {
@@ -326,9 +338,17 @@ impl MetaStore {
326338
txn.commit()?;
327339
Ok(Self {
328340
db: Arc::new(MetaDatabase::ReadWrite(db)),
341+
clock: system_clock(),
329342
})
330343
}
331344

345+
/// Replaces the wall clock that decides whether a blob's reclaim-guard lease has lapsed.
346+
#[must_use]
347+
pub fn with_clock(mut self, clock: Clock) -> Self {
348+
self.clock = clock;
349+
self
350+
}
351+
332352
/// Validates distributed persistence without creating domain tables.
333353
///
334354
/// # Errors
@@ -365,6 +385,7 @@ impl MetaStore {
365385
pub fn open_existing(path: impl AsRef<Path>) -> Result<Self, MetaError> {
366386
Ok(Self {
367387
db: Arc::new(MetaDatabase::ReadWrite(Database::open(path)?)),
388+
clock: system_clock(),
368389
})
369390
}
370391

@@ -375,6 +396,16 @@ impl MetaStore {
375396
pub fn open_existing_read_only(path: impl AsRef<Path>) -> Result<Self, MetaError> {
376397
Ok(Self {
377398
db: Arc::new(MetaDatabase::ReadOnly(ReadOnlyDatabase::open(path)?)),
399+
clock: system_clock(),
378400
})
379401
}
380402
}
403+
404+
/// A host clock that predates the epoch must not stop the store from opening.
405+
fn system_clock() -> Clock {
406+
Arc::new(|| {
407+
SystemTime::now()
408+
.duration_since(UNIX_EPOCH)
409+
.map_or(0, |elapsed| i64::try_from(elapsed.as_secs()).unwrap_or(i64::MAX))
410+
})
411+
}

crates/peryx-storage/tests/unit/meta/fault.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,12 +119,14 @@ pub fn create(
119119
write.commit().unwrap();
120120
MetaStore {
121121
db: Arc::new(MetaDatabase::ReadWrite(database)),
122+
clock: super::system_clock(),
122123
}
123124
}
124125

125126
pub fn reopen(inner: &Arc<InMemoryBackend>, fault: &Arc<Fault>) -> MetaStore {
126127
MetaStore {
127128
db: Arc::new(MetaDatabase::ReadWrite(database(inner, fault))),
129+
clock: super::system_clock(),
128130
}
129131
}
130132

crates/peryx-storage/tests/unit/tests/meta/integration_tests.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,23 @@ fn test_reclaim_guard_first_write_creates_only_its_table() {
178178
assert_distributed_tables(&path, &["blob_reclaim_guard"]);
179179
}
180180

181+
#[test]
182+
fn test_referencing_a_blob_creates_no_reclaim_guard_table() {
183+
let dir = tempfile::tempdir().unwrap();
184+
let path = dir.path().join("peryx.redb");
185+
let store = MetaStore::open(&path).unwrap();
186+
store
187+
.commit_driver_txn(|txn| {
188+
txn.put("ref/1", b"points-here")?;
189+
txn.reference_blob("sha256:blob", 4);
190+
Ok::<_, MetaError>(((), vec![b"{}".to_vec()]))
191+
})
192+
.unwrap();
193+
drop(store);
194+
195+
assert_distributed_tables(&path, &["journal", "journal_blobs", "journal_mutations"]);
196+
}
197+
181198
#[test]
182199
fn test_ingress_first_write_creates_only_its_tables() {
183200
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)