fix(ci): preserve heal quorum retries and synchronize background tests (#8422)

This commit is contained in:
cxymds
2026-10-08 20:03:33 +08:00
committed by GitHub
parent 99ae115731
commit a9670ebb4d
4 changed files with 132 additions and 63 deletions
+30 -31
View File
@@ -407,11 +407,15 @@ async fn wait_for_restore_completion(
// runtime (`worker_threads = 1`), so no concurrent test can mutate process environment during the
// `env::set_var` / `env::remove_var` window.
#[allow(unsafe_code)]
async fn with_forced_immediate_enqueue_timeout<F, Fut>(test_fn: F)
async fn with_forced_immediate_enqueue_timeout<F, Fut>(backend: &MockWarmBackend, test_fn: F)
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = ()>,
{
let transition_state = get_global_transition_state();
let missed_before = transition_state.missed_immediate_tasks();
let compensation_before = transition_state.compensation_scheduled_tasks();
let put_barrier = backend.arm_put_barrier().await;
let original = env::var_os(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT);
unsafe {
env::set_var(ENV_TEST_FORCE_IMMEDIATE_TRANSITION_ENQUEUE_TIMEOUT, "1");
@@ -428,6 +432,20 @@ where
if let Err(err) = result {
std::panic::resume_unwind(err);
}
assert!(
transition_state.missed_immediate_tasks() > missed_before,
"the operation should exercise the forced immediate enqueue failure"
);
assert!(
transition_state.compensation_scheduled_tasks() > compensation_before,
"the operation should schedule automatic compensation backfill"
);
// Separate automatic discovery and queue latency from the metadata commit deadline.
put_barrier.wait_until_paused().await;
assert_eq!(backend.object_count().await, 1, "compensation should store the body before committing");
put_barrier.release();
}
async fn with_get_codec_streaming_remote_probe_env<F, Fut>(test_fn: F)
@@ -1641,7 +1659,7 @@ async fn immediate_transition_timeout_eventually_completes_via_compensation() {
.await
.expect("Failed to set lifecycle configuration");
with_forced_immediate_enqueue_timeout(|| async {
with_forced_immediate_enqueue_timeout(&backend, || async {
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
})
.await;
@@ -1650,6 +1668,7 @@ async fn immediate_transition_timeout_eventually_completes_via_compensation() {
.await
.expect("object should eventually transition after compensation backfill");
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
assert_eq!(info.transitioned_object.status, "complete");
assert_eq!(info.transitioned_object.tier, tier_name);
assert!(backend.contains(&info.transitioned_object.name).await);
@@ -1689,37 +1708,13 @@ async fn compensation_driven_copy_still_completes_transition() {
.build()
.unwrap();
let transition_state = get_global_transition_state();
let missed_before = transition_state.missed_immediate_tasks();
let compensation_before = transition_state.compensation_scheduled_tasks();
let put_barrier = backend.arm_put_barrier().await;
with_forced_immediate_enqueue_timeout(|| async {
with_forced_immediate_enqueue_timeout(&backend, || async {
Box::pin(usecase.execute_copy_object(build_request(copy_input, Method::PUT)))
.await
.expect("Failed to copy object through usecase");
})
.await;
assert!(
transition_state.missed_immediate_tasks() > missed_before,
"copy should exercise the forced immediate enqueue failure"
);
assert!(
transition_state.compensation_scheduled_tasks() > compensation_before,
"copy should schedule automatic compensation backfill"
);
// Observe automatic compensation reaching the tier before timing its final
// metadata commit; discovery and queue latency are separate from that commit.
put_barrier.wait_until_paused().await;
assert_eq!(
backend.object_count().await,
1,
"compensation should store the copied body before committing"
);
put_barrier.release();
let info = wait_for_transition(&ecstore, dst_bucket.as_str(), dst_object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("copied object should eventually transition after compensation backfill");
@@ -1792,7 +1787,7 @@ async fn compensation_driven_complete_multipart_upload_still_transitions_inner()
.build()
.unwrap();
with_forced_immediate_enqueue_timeout(|| async {
with_forced_immediate_enqueue_timeout(&backend, || async {
Box::pin(usecase.execute_complete_multipart_upload(build_request(complete_input, Method::POST)))
.await
.expect("Failed to complete multipart upload through usecase");
@@ -1803,6 +1798,7 @@ async fn compensation_driven_complete_multipart_upload_still_transitions_inner()
.await
.expect("multipart object should eventually transition after compensation backfill");
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
assert_eq!(info.transitioned_object.status, "complete");
assert_eq!(info.transitioned_object.tier, tier_name);
assert!(backend.contains(&info.transitioned_object.name).await);
@@ -1829,7 +1825,7 @@ async fn compensation_driven_transition_still_cleans_remote_tier_on_delete() {
.await
.expect("Failed to set lifecycle configuration");
with_forced_immediate_enqueue_timeout(|| async {
with_forced_immediate_enqueue_timeout(&backend, || async {
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
})
.await;
@@ -1837,6 +1833,7 @@ async fn compensation_driven_transition_still_cleans_remote_tier_on_delete() {
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("object should eventually transition after compensation backfill");
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
let remote_object = transitioned.transitioned_object.name.clone();
assert!(backend.contains(&remote_object).await);
@@ -1890,7 +1887,7 @@ async fn compensation_driven_versioned_delete_still_creates_delete_marker() {
.await
.expect("Failed to set lifecycle configuration");
with_forced_immediate_enqueue_timeout(|| async {
with_forced_immediate_enqueue_timeout(&backend, || async {
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
})
.await;
@@ -1898,6 +1895,7 @@ async fn compensation_driven_versioned_delete_still_creates_delete_marker() {
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("object should eventually transition after compensation backfill");
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
let remote_object = transitioned.transitioned_object.name.clone();
assert!(backend.contains(&remote_object).await);
@@ -1945,7 +1943,7 @@ async fn compensation_driven_delete_marker_still_honors_lifecycle_cleanup() {
.await
.expect("Failed to set transition lifecycle configuration");
with_forced_immediate_enqueue_timeout(|| async {
with_forced_immediate_enqueue_timeout(&backend, || async {
let _ = upload_test_object(&ecstore, bucket.as_str(), object, payload).await;
})
.await;
@@ -1953,6 +1951,7 @@ async fn compensation_driven_delete_marker_still_honors_lifecycle_cleanup() {
let transitioned = wait_for_transition(&ecstore, bucket.as_str(), object, TRANSITION_WAIT_TIMEOUT)
.await
.expect("object should eventually transition after compensation backfill");
assert_eq!(backend.put_count().await, 1, "compensation should upload the version once");
let remote_object = transitioned.transitioned_object.name.clone();
assert!(backend.contains(&remote_object).await);
@@ -701,6 +701,65 @@ mod tests {
})
}
#[tokio::test]
async fn consent_revoke_cancels_retry_and_persists_receipt() {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::Notify;
let temp = tempfile::tempdir().expect("tempdir");
let store = StateStore::new(temp.path());
let (policy_tx, policy_rx) = watch::channel(policy(4, true));
let shutdown = CancellationToken::new();
let attempts = Arc::new(AtomicUsize::new(0));
let release = Arc::new(Notify::new());
let failed = Arc::new(Notify::new());
let runner: Runner = Arc::new({
let attempts = Arc::clone(&attempts);
let release = Arc::clone(&release);
let failed = Arc::clone(&failed);
move |_| {
let attempts = Arc::clone(&attempts);
let release = Arc::clone(&release);
let failed = Arc::clone(&failed);
Box::pin(async move {
attempts.fetch_add(1, Ordering::SeqCst);
release.notified().await;
failed.notify_one();
Err(DiagnosticScheduleError::InventoryUnavailable)
})
}
});
let mut runtime = spawn_schedule(store.clone(), policy_rx, shutdown.clone(), runner);
let mut receipts = runtime.receipts();
wait_running(&mut runtime.status).await;
// Pause only after real state IO completes, then revoke within the retry backoff.
tokio::time::pause();
release.notify_one();
tokio::time::timeout(Duration::from_secs(10), failed.notified())
.await
.expect("first attempt should fail before revocation");
let mut revoked = policy(5, false);
revoked.reason_code = Some(ReasonCode::ConsentInactive);
policy_tx.send(revoked).expect("revoke consent");
tokio::time::resume();
tokio::time::timeout(Duration::from_secs(10), receipts.changed())
.await
.expect("cancel receipt timeout")
.expect("cancel receipt should be published");
let cancelled = receipts.borrow().clone().expect("cancel receipt");
assert_eq!(cancelled.outcome, ReceiptOutcome::Cancelled);
assert_eq!(cancelled.policy_revision, 4);
assert_eq!(cancelled.attempt_count, 1);
assert_eq!(attempts.load(Ordering::SeqCst), 1, "revocation must prevent another attempt");
shutdown.cancel();
runtime.shutdown().await;
let persisted = store.read().await.expect("durable cancelled state");
assert_eq!(persisted.last_receipt, Some(cancelled));
assert!(persisted.active_interval_started_at.is_none());
}
#[tokio::test]
async fn bootstrap_stopped_policy_preserves_a_due_interval_until_fresh_policy_arrives() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -112,31 +112,3 @@ async fn missing_inventory_has_bounded_retries_and_durable_failure_receipt() {
shutdown.cancel();
runtime.shutdown().await;
}
#[tokio::test]
async fn consent_revoke_cancels_retry_and_persists_receipt() {
let temp = tempfile::tempdir().expect("tempdir");
let (policy_tx, policy_rx) = watch::channel(policy(4, "RUNNING", None));
let shutdown = CancellationToken::new();
let runtime = spawn_environment_schedule(temp.path(), policy_rx, shutdown.clone()).expect("scheduler");
let mut status = runtime.status();
tokio::time::timeout(Duration::from_secs(2), async {
while !matches!(&*status.borrow(), DiagnosticScheduleStatus::Running { .. }) {
status.changed().await.expect("schedule remains active");
}
})
.await
.expect("first attempt");
policy_tx
.send(policy(5, "STOPPED", Some("CONSENT_INACTIVE")))
.expect("revoke policy");
let cancelled = receipt(&mut status).await;
assert_eq!(cancelled.outcome, ReceiptOutcome::Cancelled);
let state: serde_json::Value =
serde_json::from_slice(&std::fs::read(temp.path().join("diagnostics/schedule.json")).expect("durable state"))
.expect("state JSON");
assert_eq!(state["lastReceipt"]["outcome"], "CANCELLED");
shutdown.cancel();
runtime.shutdown().await;
}