Skip to content

Commit e8bcbad

Browse files
authored
test: stabilize qualification closeout (#277)
* test: stabilize cloud qualification closeout * test: isolate cloud upload outage * test: wait for backpressure outcome * test: keep timeout MSRV-clean
1 parent 75dcc39 commit e8bcbad

5 files changed

Lines changed: 132 additions & 151 deletions

File tree

src/engine/tests.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,11 @@ fn should_preserve_partitioned_compaction_across_simulated_cloud_reopen() -> Mid
662662
OpenOptions::cloud_simulated(temp_dir.path(), "partitioned-bucket", "partitioned-prefix")
663663
.background_compaction(false)
664664
.with_memtable_size_limit(64 * 1024)
665+
// This is a multi-output durability qualification, not a response
666+
// deadline test. Windows hosted runners can execute it alongside
667+
// the intentionally large compaction resource proof, so retain a
668+
// bounded but explicit allowance for that filesystem contention.
669+
.runtime_response_timeout(Duration::from_mins(3))
665670
.build()
666671
});
667672

src/runtime/hybrid_persistence.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,9 @@ impl HybridPersistence for HybridStorage {
860860
}
861861

862862
fn delete_sst_object_blocking(&self, sst_name: &str) -> MidgeResult<()> {
863+
crate::failpoints::fail_point!("midge::cloud::inject_fail_sst_delete", |_| Err(
864+
MidgeError::Internal("failpoint: cloud SST delete failed".to_string())
865+
));
863866
self.delete_immutable_object_blocking(&crate::sst::object_key(sst_name))
864867
}
865868
}

tests/backpressure.rs

Lines changed: 40 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ use cntryl_midge::{
66
};
77
use common::*;
88
use std::sync::atomic::{AtomicBool, Ordering};
9-
use std::sync::{Arc, Barrier, Mutex, OnceLock};
10-
use std::time::Duration;
9+
use std::sync::{Arc, Mutex, OnceLock};
10+
use std::time::{Duration, Instant};
1111
use tempfile::TempDir;
1212

1313
static BACKPRESSURE_STRESS_TEST_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
@@ -334,7 +334,7 @@ fn should_prevent_oom_by_rejecting_writes_when_budget_exceeded() {
334334
.unwrap_or_else(std::sync::PoisonError::into_inner);
335335

336336
// Act
337-
let results = std::cell::RefCell::new(Vec::<(String, u64, usize, bool)>::new());
337+
let results = std::cell::RefCell::new(Vec::<(String, u64, u64, usize, bool)>::new());
338338
for_each_storage_mode(&["local"], |mode, opts| {
339339
let mut opts = opts;
340340
opts = opts.memory_budget(512 * 1024); // 512KB instead of 2MB for faster backpressure trigger
@@ -350,72 +350,61 @@ fn should_prevent_oom_by_rejecting_writes_when_budget_exceeded() {
350350
let cf = engine.create_column_family("test").expect("create cf");
351351
let cf_id = cf.id();
352352

353-
let worker_count = 1;
354-
let max_attempts_per_worker = 64;
355-
let shutdown = Arc::new(AtomicBool::new(false));
356-
let barrier = Arc::new(Barrier::new(worker_count));
357-
let mut handles = Vec::new();
358-
359-
for worker_id in 0..worker_count {
360-
let shutdown_clone = shutdown.clone();
361-
let engine_clone = Arc::clone(&engine);
362-
let barrier_clone = Arc::clone(&barrier);
363-
let write_options = buffered_write_options(mode);
353+
let max_attempts = 64;
354+
let write_options = buffered_write_options(mode);
355+
let mut total_writes = 0;
356+
let mut total_stalls = 0;
364357

365-
handles.push(std::thread::spawn(move || {
366-
let mut total_writes = 0;
367-
let mut total_stalls = 0;
368-
369-
barrier_clone.wait();
370-
while !shutdown_clone.load(Ordering::Relaxed) {
371-
let key = format!("worker_{worker_id}_key_{total_writes}");
372-
let value = vec![0u8; 8192]; // 8KB for faster memory budget exhaustion
373-
let mut txn = engine_clone
374-
.begin_tx(cf_id, TransactionMode::ReadWrite)
375-
.expect("begin");
376-
txn.put(key.as_bytes().to_vec(), value.clone(), None)
377-
.expect("put");
378-
379-
match txn.commit(write_options) {
380-
Ok(()) => total_writes += 1,
381-
Err(MidgeError::WriteStall(_)) => {
382-
total_stalls += 1;
383-
std::thread::sleep(Duration::from_millis(10));
384-
}
385-
Err(e) => panic!("unexpected: {e:?}"),
386-
}
358+
while total_writes + total_stalls < max_attempts {
359+
let key = format!("key_{total_writes}");
360+
let value = vec![0u8; 8192]; // 8KB for faster memory budget exhaustion
361+
let mut txn = engine
362+
.begin_tx(cf_id, TransactionMode::ReadWrite)
363+
.expect("begin");
364+
txn.put(key.as_bytes().to_vec(), value, None).expect("put");
387365

388-
if total_writes + total_stalls >= max_attempts_per_worker {
389-
break;
390-
}
366+
match txn.commit(write_options) {
367+
Ok(()) => total_writes += 1,
368+
Err(MidgeError::WriteStall(_)) => {
369+
total_stalls += 1;
370+
std::thread::sleep(Duration::from_millis(10));
391371
}
392-
393-
(total_writes, total_stalls)
394-
}));
372+
Err(e) => panic!("unexpected: {e:?}"),
373+
}
395374
}
396375

397-
std::thread::sleep(Duration::from_secs(2));
398-
shutdown.store(true, Ordering::Relaxed);
399-
let total_stalls = handles
400-
.into_iter()
401-
.map(|handle| handle.join().expect("panic").1)
402-
.sum();
376+
// Natural flush publication is asynchronous. Wait for the actual acceptance
377+
// condition instead of assuming a fixed sleep covers hosted-runner contention.
378+
let deadline = Instant::now() + Duration::from_secs(30);
379+
let metrics = loop {
380+
let metrics = engine.get_runtime_metrics().expect("runtime metrics");
381+
if total_stalls > 0 || (metrics.sst_count > 0 && !metrics.write_stalled) {
382+
break metrics;
383+
}
384+
assert!(
385+
Instant::now() < deadline,
386+
"local pressure was not rejected or naturally flushed: writes={total_writes}, stalls={total_stalls}, ssts={}, write_stalled={}",
387+
metrics.sst_count,
388+
metrics.write_stalled
389+
);
390+
std::thread::sleep(Duration::from_millis(10));
391+
};
403392

404-
let metrics = engine.get_runtime_metrics().expect("runtime metrics");
405393
results.borrow_mut().push((
406394
mode.to_string(),
395+
total_writes,
407396
total_stalls,
408397
metrics.sst_count,
409398
metrics.write_stalled,
410399
));
411400
});
412401

413402
// Assert
414-
for (mode, total_stalls, sst_count, write_stalled) in results.into_inner() {
403+
for (mode, total_writes, total_stalls, sst_count, write_stalled) in results.into_inner() {
415404
assert_eq!(mode, "local");
416405
assert!(
417406
total_stalls > 0 || (sst_count > 0 && !write_stalled),
418-
"Expected local mode to either reject writes under hard pressure or relieve pressure via natural flush"
407+
"Expected local mode to either reject writes under hard pressure or relieve pressure via natural flush: writes={total_writes}, stalls={total_stalls}, ssts={sst_count}, write_stalled={write_stalled}"
419408
);
420409
}
421410
}

tests/engine_gc_cloud.rs

Lines changed: 32 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -189,20 +189,13 @@ fn should_not_collect_cloud_objects_referenced_by_manifest() {
189189
}
190190

191191
/// Simulates a cloud provider outage that specifically affects deleting
192-
/// GC'd (orphaned) objects, without disturbing anything else, by chmod-ing
193-
/// the simulated cloud bucket read-only for the exact window between
194-
/// compaction's manifest publish and its orphan cleanup. This mirrors
195-
/// `should_handle_cloud_unavailable_during_eviction` in
196-
/// `tests/hybrid_storage.rs`, which uses the same "the simulated cloud
197-
/// backend is just a filesystem directory" trick for upload outages; here
198-
/// we pin the outage to the GC boundary using the existing
199-
/// `slice6::after_manifest_persist_before_sst_gc` failpoint so the prior
200-
/// (successful) upload of the compacted output isn't itself blocked.
192+
/// GC'd (orphaned) objects, without disturbing the output upload that must
193+
/// precede manifest publication. A provider-boundary failpoint keeps this
194+
/// deterministic even when the process has permission to delete read-only
195+
/// files, as root does inside the Docker qualification image.
201196
#[test]
202-
#[cfg(all(feature = "failpoints", unix))]
197+
#[cfg(feature = "failpoints")]
203198
fn should_handle_gc_when_cloud_delete_fails() {
204-
use std::os::unix::fs::PermissionsExt;
205-
206199
let _guard = failpoint_test_lock()
207200
.lock()
208201
.unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -217,7 +210,7 @@ fn should_handle_gc_when_cloud_delete_fails() {
217210
.build()
218211
.expect("build simulated cloud options");
219212
let l0_batch_size = options.l0_compaction_trigger();
220-
let engine = Engine::open(options).expect("open simulated cloud engine");
213+
let mut engine = Engine::open(options).expect("open simulated cloud engine");
221214
let cf = engine.create_column_family("test").expect("create cf");
222215

223216
// Write exactly one configured L0 batch. This isolates the delete failure
@@ -247,52 +240,23 @@ fn should_handle_gc_when_cloud_delete_fails() {
247240
"expected one configured L0 batch to be mirrored to cloud storage, got {before_objects:?}"
248241
);
249242

250-
// Arm a failpoint at the exact boundary between compaction's manifest
251-
// publish (which already uploaded the compacted output) and its
252-
// orphan-SST GC pass, and make the bucket read-only there so only the
253-
// orphan delete fails.
243+
// Arm only the remote SST delete boundary. The compacted output upload
244+
// and manifest authority switch remain real simulated-cloud operations.
254245
let scenario = fail::FailScenario::setup();
255-
let original_permissions = std::fs::metadata(&cloud_sst_dir)
256-
.expect("stat cloud sst dir")
257-
.permissions();
258-
let outage_dir = cloud_sst_dir.clone();
259-
fail::cfg_callback("slice6::after_manifest_persist_before_sst_gc", move || {
260-
std::fs::set_permissions(&outage_dir, std::fs::Permissions::from_mode(0o500))
261-
.expect("simulate cloud delete outage via read-only bucket");
262-
})
263-
.expect("configure cloud delete outage failpoint");
246+
fail::cfg("midge::cloud::inject_fail_sst_delete", "return")
247+
.expect("configure cloud delete outage failpoint");
264248

265249
// Act: compaction should orphan the selected input SSTs and try (and fail)
266-
// to delete them from the now-read-only cloud bucket.
250+
// to delete them from cloud storage.
267251
let compact_result = engine.compact_all();
268252

269-
// Give the async cloud-delete worker time to attempt (and fail) the
270-
// delete before we inspect the bucket.
271-
thread::sleep(Duration::from_millis(300));
272-
273-
fail::remove("slice6::after_manifest_persist_before_sst_gc");
274-
std::fs::set_permissions(&cloud_sst_dir, original_permissions)
275-
.expect("restore cloud sst permissions");
276-
scenario.teardown();
277-
278253
// Assert: compaction tolerates the delete failure rather than
279254
// propagating it as an error.
280255
assert!(
281256
compact_result.is_ok(),
282257
"compact_all should tolerate a cloud delete failure: {compact_result:?}"
283258
);
284259

285-
// Assert: the orphaned objects are still present in cloud storage
286-
// because their delete genuinely failed and was retained for retry,
287-
// not silently skipped or corrupted.
288-
let after_objects = sst_object_names(&cloud_sst_dir);
289-
let retained: Vec<_> = before_objects.intersection(&after_objects).collect();
290-
assert!(
291-
!retained.is_empty(),
292-
"expected the orphaned cloud objects whose delete failed to remain \
293-
in cloud storage for retry, got {after_objects:?}"
294-
);
295-
296260
// Assert: engine remains fully functional; no data was lost.
297261
let tx = engine
298262
.begin_tx(cf.id(), TransactionMode::ReadOnly)
@@ -304,6 +268,27 @@ fn should_handle_gc_when_cloud_delete_fails() {
304268
"data lost after cloud delete failure"
305269
);
306270
}
271+
drop(tx);
272+
273+
// Shutdown joins every cloud-delete worker while the outage remains
274+
// armed, so the filesystem observation cannot race an unattempted delete.
275+
engine
276+
.shutdown(Duration::from_secs(10))
277+
.expect("shutdown after failed cloud delete");
278+
let after_objects = sst_object_names(&cloud_sst_dir);
279+
let retained: Vec<_> = before_objects.intersection(&after_objects).collect();
280+
281+
fail::remove("midge::cloud::inject_fail_sst_delete");
282+
scenario.teardown();
283+
284+
// Assert: the orphaned objects are still present in cloud storage
285+
// because their delete genuinely failed and was retained for retry,
286+
// not silently skipped or corrupted.
287+
assert!(
288+
!retained.is_empty(),
289+
"expected the orphaned cloud objects whose delete failed to remain \
290+
in cloud storage for retry, got {after_objects:?}"
291+
);
307292

308293
eprintln!("✓ Engine gracefully handled cloud delete failure");
309294
}

0 commit comments

Comments
 (0)