remora/crates/remora-attempt/tests/recovery.rs

1213 lines
38 KiB
Rust
Raw Normal View History

use std::net::SocketAddr;
use std::sync::{Arc, Barrier, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use remora_admission::{AdmissionConfig, AdmissionDecision, AdmissionService};
use remora_attempt::{
ActivationOutcome, AttemptConfig, AttemptService, CancelOutcome, CleanupCompletion,
CleanupIdentity, CooperativeStopOutcome, RecoveryConfig, StagingMultipart, StagingObject,
StagingWriteIntent,
};
use remora_contracts::{HttpSourceSubmissionDto, JobSubmissionDto, SourceSubmissionDto};
use remora_coordination::{CoordinationStore, StoreConfig};
use remora_domain::{
BucketName, OpaqueId, RuntimeFailureClass, StorageScope, TargetPrefix, TargetStoreId,
};
use remora_durable::{PoolConfig, connect};
use remora_identity::{DefaultTarget, Principal};
use remora_result::PublicationInput;
use remora_schema::migrate as migrate_schema;
use remora_target::{
CreateObjectRequest, MultipartIdentity, ObjectIdentity, StoredObject, TargetStore,
TargetStoreConfig, attempt_manifest_key_in, attempt_object_key_in,
};
use sqlx::PgPool;
use tokio::io::copy_bidirectional;
use tokio::net::{TcpListener, TcpStream};
use tokio::task::JoinHandle;
struct Harness {
pool: PgPool,
coordination: CoordinationStore,
target: TargetStore,
target_config: TargetStoreConfig,
}
struct CoordinationProxy {
address: SocketAddr,
accept_task: JoinHandle<()>,
connections: Arc<Mutex<Vec<JoinHandle<()>>>>,
}
struct GateFault {
point: remora_attempt::AttemptFaultPoint,
entered: Barrier,
release: Barrier,
}
impl GateFault {
fn new(point: remora_attempt::AttemptFaultPoint) -> Self {
Self {
point,
entered: Barrier::new(2),
release: Barrier::new(2),
}
}
}
impl remora_attempt::AttemptFaultInjector for GateFault {
fn hit(&self, point: remora_attempt::AttemptFaultPoint) {
if point == self.point {
self.entered.wait();
self.release.wait();
}
}
}
impl CoordinationProxy {
async fn start(upstream: String, address: Option<SocketAddr>) -> Self {
let listener = TcpListener::bind(address.unwrap_or_else(|| "127.0.0.1:0".parse().unwrap()))
.await
.unwrap();
let address = listener.local_addr().unwrap();
let connections = Arc::new(Mutex::new(Vec::new()));
let active = connections.clone();
let accept_task = tokio::spawn(async move {
while let Ok((mut downstream, _)) = listener.accept().await {
let Ok(mut upstream_stream) = TcpStream::connect(&upstream).await else {
continue;
};
let task = tokio::spawn(async move {
let _ = copy_bidirectional(&mut downstream, &mut upstream_stream).await;
});
active.lock().unwrap().push(task);
}
});
Self {
address,
accept_task,
connections,
}
}
async fn partition(self) -> SocketAddr {
self.accept_task.abort();
let _ = self.accept_task.await;
for task in self.connections.lock().unwrap().drain(..) {
task.abort();
}
tokio::task::yield_now().await;
self.address
}
fn url(&self) -> String {
format!("redis://{}/", self.address)
}
}
impl Harness {
async fn new(name: &str) -> Option<Self> {
let database_url = required_env("DATABASE_URL")?;
let coordination_url = required_env("REMORA_COORDINATION_URL")?;
let target_config = match TargetStoreConfig::from_env() {
Ok(config) => config,
Err(error) if required() => panic!("target fixture is required: {error}"),
Err(_) => return None,
};
let target = TargetStore::connect(target_config.clone()).expect("connect target");
let pool = connect(
&database_url,
PoolConfig {
max_connections: 16,
acquire_timeout: Duration::from_secs(10),
idle_timeout: Some(Duration::from_secs(30)),
},
)
.await
.expect("connect postgres");
migrate_schema(&pool).await.expect("schema migrate");
sqlx::query("TRUNCATE TABLE jobs CASCADE")
.execute(&pool)
.await
.expect("reset recovery test state");
let coordination = CoordinationStore::connect(
&coordination_url,
format!("{}wp17:{name}:{}:", coordination_namespace(), nonce()),
StoreConfig {
global_limit: 32,
lease_ttl: Duration::from_secs(10),
reserve_ttl: Duration::from_secs(10),
progress_ttl: Duration::from_secs(10),
cancel_ttl: None,
},
)
.await
.expect("connect coordination store");
Some(Self {
pool,
coordination,
target,
target_config,
})
}
fn service(&self, max_attempts: u64) -> AttemptService {
AttemptService::new(
self.pool.clone(),
self.coordination.clone(),
AttemptConfig { max_attempts },
)
.expect("attempt service")
}
async fn job(&self) -> OpaqueId {
let store = TargetStoreId::parse(&self.target_config.target_store_id).unwrap();
let bucket = BucketName::parse(&self.target_config.bucket).unwrap();
let prefix = TargetPrefix::parse("users/recovery", ["_remora"]).unwrap();
let owner = unique_id("owner");
let principal = Principal {
id: owner,
issuer: "https://issuer.example".into(),
subject: unique_id("subject").to_string(),
administrator: false,
scopes: [StorageScope {
store: store.clone(),
bucket: bucket.clone(),
prefix: prefix.clone(),
}]
.into_iter()
.collect(),
};
let admission = AdmissionService::new(
self.pool.clone(),
AdmissionConfig {
default_target: DefaultTarget {
store,
bucket,
prefix,
},
reserved_target_segments: vec!["_remora".into()],
policy_version: "recovery-tests-v1".into(),
},
)
.unwrap();
let outcome = admission
.submit(
&principal,
unique_id("submit").as_str(),
JobSubmissionDto {
source: SourceSubmissionDto::Http(HttpSourceSubmissionDto {
url: "https://example.invalid/recovery".into(),
result_path: "artifact.bin".into(),
credential_id: None,
}),
target: None,
label: Some("recovery-test".into()),
},
)
.await
.expect("submit job");
let AdmissionDecision::Accepted(job) = outcome.decision else {
panic!("job rejected")
};
job.job_id
}
async fn executing(
&self,
service: &AttemptService,
job: &OpaqueId,
) -> remora_attempt::AttemptAuthority {
self.coordination
.enqueue(job.as_str())
.await
.expect("enqueue");
let claim = service
.claim(job, &unique_id("worker"), &unique_id("claimop"))
.await
.expect("claim");
let remora_attempt::ClaimOutcome::Claimed { authority, .. } = claim else {
panic!("claim rejected")
};
assert!(matches!(
service
.activate(&authority, &unique_id("activateop"))
.await
.expect("activate"),
ActivationOutcome::Activated(_)
));
service
.authority(&authority.attempt_id)
.await
.unwrap()
.unwrap()
}
async fn seal_result(
&self,
authority: &remora_attempt::AttemptAuthority,
manifest: StoredObject,
) -> PublicationInput {
let manifest_id = unique_id("manifest");
sqlx::query(
"INSERT INTO result_inventories (
attempt_id, state, manifest_schema_version, manifest_id, manifest_store,
manifest_bucket, manifest_key, manifest_version_id, manifest_sha256,
manifest_size, member_count, total_bytes,
staging_origin_attempt_id, target_prefix
) VALUES (
$1, 'sealed', 1, $2, $3, $4, $5, $6, $7, $8, 0, 0,
$1, 'users/recovery'
)",
)
.bind(authority.attempt_id.as_str())
.bind(manifest_id.as_str())
.bind(&manifest.identity.target_store_id)
.bind(&manifest.identity.bucket)
.bind(&manifest.identity.key)
.bind(&manifest.identity.version_id)
.bind(manifest.content_sha256.as_str())
.bind(i64::try_from(manifest.size).unwrap())
.execute(&self.pool)
.await
.unwrap();
PublicationInput {
attempt_id: authority.attempt_id.clone(),
manifest_id,
schema_version: 1,
manifest,
member_count: 0,
total_bytes: 0,
}
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn activation_and_recovery_share_authority_serialization() {
let Some(harness) = Harness::new("activation-race").await else {
return;
};
let activation_gate = Arc::new(GateFault::new(
remora_attempt::AttemptFaultPoint::ActivationAfterLease,
));
let service = AttemptService::new_with_fault_injector(
harness.pool.clone(),
harness.coordination.clone(),
AttemptConfig { max_attempts: 3 },
activation_gate.clone(),
)
.unwrap();
let activation_job = harness.job().await;
harness
.coordination
.enqueue(activation_job.as_str())
.await
.unwrap();
let activation_claim = service
.claim(&activation_job, &unique_id("worker"), &unique_id("claimop"))
.await
.unwrap();
let remora_attempt::ClaimOutcome::Claimed {
authority: activation_authority,
..
} = activation_claim
else {
panic!("claim rejected")
};
let activating = service.clone();
let activation_input = activation_authority.clone();
let activation = tokio::spawn(async move {
activating
.activate(&activation_input, &unique_id("activateop"))
.await
.unwrap()
});
activation_gate.entered.wait();
let recovering = service.clone();
let recovery =
tokio::spawn(async move { recovering.reconcile_once(&recovery_config()).await.unwrap() });
tokio::task::yield_now().await;
activation_gate.release.wait();
assert!(matches!(
activation.await.unwrap(),
ActivationOutcome::Activated(_)
));
assert_eq!(recovery.await.unwrap().attempts_reaped, 0);
assert_eq!(
service
.authority(&activation_authority.attempt_id)
.await
.unwrap()
.unwrap()
.state,
remora_attempt::AttemptState::Executing
);
service
.publish_failure(
&activation_authority,
&unique_id("failure"),
RuntimeFailureClass::Authorization,
)
.await
.unwrap();
let recovery_gate = Arc::new(GateFault::new(
remora_attempt::AttemptFaultPoint::RecoveryBeforeRevalidation,
));
let service = AttemptService::new_with_fault_injector(
harness.pool.clone(),
harness.coordination.clone(),
AttemptConfig { max_attempts: 3 },
recovery_gate.clone(),
)
.unwrap();
let recovery_job = harness.job().await;
harness
.coordination
.enqueue(recovery_job.as_str())
.await
.unwrap();
let worker = unique_id("worker");
let recovery_claim = service
.claim(&recovery_job, &worker, &unique_id("claimop"))
.await
.unwrap();
let remora_attempt::ClaimOutcome::Claimed {
authority: recovery_authority,
..
} = recovery_claim
else {
panic!("claim rejected")
};
assert_eq!(
service.cooperative_stop(&recovery_authority).await.unwrap(),
CooperativeStopOutcome::StopNotRequired
);
harness
.coordination
.free_reserve(recovery_job.as_str(), Some(worker.as_str()))
.await
.unwrap();
let recovering = service.clone();
let recovery =
tokio::spawn(async move { recovering.reconcile_once(&recovery_config()).await.unwrap() });
recovery_gate.entered.wait();
let activating = service.clone();
let activation_input = recovery_authority.clone();
let activation = tokio::spawn(async move {
activating
.activate(&activation_input, &unique_id("activateop"))
.await
.unwrap()
});
tokio::task::yield_now().await;
recovery_gate.release.wait();
assert_eq!(recovery.await.unwrap().attempts_reaped, 1);
assert_eq!(
activation.await.unwrap(),
ActivationOutcome::PreconditionFailed
);
assert!(
service
.authority(&recovery_authority.attempt_id)
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn batched_reconciliation_rotates_across_eligible_jobs() {
let Some(harness) = Harness::new("fairness").await else {
return;
};
let service = harness.service(3);
let first = harness.job().await;
let second = harness.job().await;
sqlx::query(
"UPDATE jobs SET recovery_checked_at = $1
WHERE job_id <> $2 AND job_id <> $3",
)
.bind(time::OffsetDateTime::now_utc())
.bind(first.as_str())
.bind(second.as_str())
.execute(&harness.pool)
.await
.unwrap();
let config = RecoveryConfig {
batch_size: 1,
..recovery_config()
};
assert_eq!(service.reconcile_once(&config).await.unwrap().enqueued, 1);
assert_eq!(service.reconcile_once(&config).await.unwrap().enqueued, 1);
let mut projected = [
harness.coordination.dequeue().await.unwrap().unwrap(),
harness.coordination.dequeue().await.unwrap().unwrap(),
];
projected.sort();
let mut expected = [first.to_string(), second.to_string()];
expected.sort();
assert_eq!(projected, expected);
}
#[tokio::test]
async fn coordination_partition_rolls_back_and_restart_converges() {
let Some(harness) = Harness::new("partition").await else {
return;
};
let upstream = coordination_upstream();
let proxy = CoordinationProxy::start(upstream.clone(), None).await;
let proxy_url = proxy.url();
let store = CoordinationStore::connect(
&proxy_url,
format!("{}wp17:partition:{}:", coordination_namespace(), nonce()),
StoreConfig::default(),
)
.await
.unwrap();
let service = AttemptService::new(
harness.pool.clone(),
store.clone(),
AttemptConfig { max_attempts: 3 },
)
.unwrap();
let job = harness.job().await;
assert_eq!(
service
.reconcile_once(&recovery_config())
.await
.unwrap()
.enqueued,
1
);
store.drop_job_state(&[job.as_str()]).await.unwrap();
let address = proxy.partition().await;
let interrupted = tokio::time::timeout(
Duration::from_millis(250),
service.reconcile_once(&recovery_config()),
)
.await;
assert!(interrupted.is_err() || interrupted.unwrap().is_err());
assert_eq!(
service
.mutation_projection(&job)
.await
.unwrap()
.unwrap()
.phase,
remora_domain::JobPhase::Queued
);
let resumed_proxy = CoordinationProxy::start(upstream, Some(address)).await;
let resumed_store = CoordinationStore::connect(
&resumed_proxy.url(),
format!("{}wp17:partition:{}:", coordination_namespace(), nonce()),
StoreConfig::default(),
)
.await
.unwrap();
let restarted = AttemptService::new(
harness.pool.clone(),
resumed_store.clone(),
AttemptConfig { max_attempts: 3 },
)
.unwrap();
assert_eq!(
restarted
.reconcile_once(&recovery_config())
.await
.unwrap()
.enqueued,
1
);
assert_eq!(
resumed_store.dequeue().await.unwrap(),
Some(job.to_string())
);
resumed_proxy.partition().await;
}
#[tokio::test]
async fn coordination_wipes_reap_lost_attempts_and_reconstruct_dispatch() {
let Some(harness) = Harness::new("wipe").await else {
return;
};
let service = harness.service(3);
let config = recovery_config();
let queued = harness.job().await;
let first = service.reconcile_once(&config).await.unwrap();
assert_eq!(first.enqueued, 1);
assert!(harness.coordination.dequeue().await.unwrap().is_some());
harness
.coordination
.drop_job_state(&[queued.as_str()])
.await
.unwrap();
let restarted = harness.service(3);
let second = restarted.reconcile_once(&config).await.unwrap();
assert_eq!(second.enqueued, 1);
harness
.coordination
.drop_job_state(&[queued.as_str()])
.await
.unwrap();
assert_eq!(restarted.reconcile_once(&config).await.unwrap().enqueued, 1);
let authority = harness.executing(&service, &queued).await;
service
.report_progress(
&authority,
&unique_id("progress"),
remora_attempt::ProgressRecord {
completed_bytes: 64,
total_bytes: Some(128),
completed_members: 0,
total_members: Some(1),
},
)
.await
.unwrap();
harness
.coordination
.drop_job_state(&[queued.as_str()])
.await
.unwrap();
assert_eq!(
service
.reconcile_once(&config)
.await
.unwrap()
.attempts_reaped,
1
);
let scheduled = service.reconcile_once(&config).await.unwrap();
assert_eq!(scheduled.retries_scheduled, 1);
assert_eq!(scheduled.enqueued, 1);
assert!(
harness
.coordination
.get_progress(queued.as_str())
.await
.unwrap()
.is_none()
);
let worker = unique_id("worker");
let claim = service
.claim(&queued, &worker, &unique_id("claimop"))
.await
.unwrap();
let remora_attempt::ClaimOutcome::Claimed { authority, .. } = claim else {
panic!("retry claim rejected")
};
harness
.coordination
.free_reserve(queued.as_str(), Some(worker.as_str()))
.await
.unwrap();
assert_eq!(
service
.reconcile_once(&config)
.await
.unwrap()
.attempts_reaped,
1
);
assert_eq!(
sqlx::query_scalar::<_, String>("SELECT state FROM attempts WHERE attempt_id = $1")
.bind(authority.attempt_id.as_str())
.fetch_one(&harness.pool)
.await
.unwrap(),
"reaped"
);
}
#[tokio::test]
async fn cancellation_is_terminal_and_cooperative_stop_revokes_execution() {
let Some(harness) = Harness::new("cancel").await else {
return;
};
let service = harness.service(3);
let config = recovery_config();
let queued = harness.job().await;
assert_eq!(
service.request_cancel(&queued).await.unwrap(),
CancelOutcome::Requested
);
assert_eq!(
service.request_cancel(&queued).await.unwrap(),
CancelOutcome::AlreadyTerminal
);
assert_eq!(
service
.mutation_projection(&queued)
.await
.unwrap()
.unwrap()
.phase,
remora_domain::JobPhase::Cancelled
);
assert_eq!(
service
.reconcile_once(&config)
.await
.unwrap()
.terminal_coordination_cleaned,
1
);
let running = harness.job().await;
let authority = harness.executing(&service, &running).await;
assert_eq!(
service.request_cancel(&running).await.unwrap(),
CancelOutcome::Requested
);
let report = service.reconcile_once(&config).await.unwrap();
assert_eq!(report.cancellations_published, 1);
assert_eq!(report.cancellations_finalized, 1);
assert_eq!(report.terminal_coordination_cleaned, 0);
assert_eq!(harness.coordination.global_used().await.unwrap(), 1);
assert_eq!(
service.cooperative_stop(&authority).await.unwrap(),
CooperativeStopOutcome::Stopped
);
assert_eq!(
sqlx::query_scalar::<_, String>("SELECT state FROM attempts WHERE attempt_id = $1")
.bind(authority.attempt_id.as_str())
.fetch_one(&harness.pool)
.await
.unwrap(),
"stopped"
);
assert_eq!(
service
.reconcile_once(&config)
.await
.unwrap()
.terminal_coordination_cleaned,
1
);
assert_eq!(harness.coordination.global_used().await.unwrap(), 0);
assert_eq!(
service
.publish_failure(
&authority,
&unique_id("latefail"),
RuntimeFailureClass::Internal,
)
.await
.unwrap(),
remora_attempt::AttemptMutationOutcome::Rejected {
reason: remora_attempt::MutationRejection::AuthorityLost,
disposition: remora_durable::MutationDisposition::First,
}
);
}
#[tokio::test]
async fn worker_loss_reaps_live_authority_without_treating_coordination_as_durable() {
let Some(harness) = Harness::new("worker-down").await else {
return;
};
let service = harness.service(1);
let job = harness.job().await;
let authority = harness.executing(&service, &job).await;
assert_eq!(
service
.mark_worker_down(&authority.worker_incarnation)
.await
.unwrap(),
1
);
let report = service.reconcile_once(&recovery_config()).await.unwrap();
assert_eq!(report.attempts_reaped, 1);
assert_eq!(
service
.mutation_projection(&job)
.await
.unwrap()
.unwrap()
.phase,
remora_domain::JobPhase::Failed
);
assert_eq!(
service.cooperative_stop(&authority).await.unwrap(),
CooperativeStopOutcome::Stopped
);
}
#[tokio::test]
async fn cleanup_uses_exact_durable_identities_retries_and_excludes_the_winner() {
let Some(harness) = Harness::new("cleanup").await else {
return;
};
let service = harness.service(3);
let loser_job = harness.job().await;
let loser = harness.executing(&service, &loser_job).await;
let loser_key = attempt_object_key_in(
"users/recovery",
&loser.attempt_id,
&unique_id("object"),
None,
);
let loser_object = harness
.target
.put_create_only(
&CreateObjectRequest::parse(loser_key.clone()).unwrap(),
b"loser-stage".as_slice(),
)
.await
.unwrap();
service
.register_staging_object(
&loser.attempt_id,
&StagingObject {
target_store: loser_object.identity.target_store_id.clone(),
bucket: loser_object.identity.bucket.clone(),
key: loser_object.identity.key.clone(),
version_id: loser_object.identity.version_id.clone(),
},
)
.await
.unwrap();
assert!(
service
.register_staging_object(
&loser.attempt_id,
&StagingObject {
target_store: "foreign-store".into(),
bucket: loser_object.identity.bucket.clone(),
key: loser_object.identity.key.clone(),
version_id: loser_object.identity.version_id.clone(),
},
)
.await
.is_err()
);
let multipart_key = attempt_object_key_in(
"users/recovery",
&loser.attempt_id,
&unique_id("multipart"),
None,
);
let multipart = harness
.target
.begin_multipart(&CreateObjectRequest::parse(multipart_key.clone()).unwrap())
.await
.unwrap();
let multipart_identity = multipart.identity().clone();
service
.register_staging_multipart(
&loser.attempt_id,
&StagingMultipart {
target_store: multipart_identity.target_store_id.clone(),
bucket: multipart_identity.bucket.clone(),
key: multipart_identity.key.clone(),
upload_id: multipart_identity.upload_id.clone(),
},
)
.await
.unwrap();
service
.publish_failure(
&loser,
&unique_id("failure"),
RuntimeFailureClass::Authorization,
)
.await
.unwrap();
let winner_job = harness.job().await;
let winner = harness.executing(&service, &winner_job).await;
let winner_key =
attempt_manifest_key_in("users/recovery", &winner.attempt_id, &unique_id("manifest"));
let winner_object = harness
.target
.put_create_only(
&CreateObjectRequest::parse(winner_key).unwrap(),
b"winner-manifest".as_slice(),
)
.await
.unwrap();
service
.register_staging_object(
&winner.attempt_id,
&StagingObject {
target_store: winner_object.identity.target_store_id.clone(),
bucket: winner_object.identity.bucket.clone(),
key: winner_object.identity.key.clone(),
version_id: winner_object.identity.version_id.clone(),
},
)
.await
.unwrap();
let publication = harness.seal_result(&winner, winner_object.clone()).await;
service
.publish_success(&winner, &unique_id("success"), publication)
.await
.unwrap();
let config = recovery_config();
let first = service.claim_cleanup(&config).await.unwrap().unwrap();
let failing_target = TargetStore::connect(
TargetStoreConfig::new_exclusive(
harness.target_config.target_store_id.clone(),
"http://127.0.0.1:9",
harness.target_config.bucket.clone(),
harness.target_config.region.clone(),
harness.target_config.access_key_id.clone(),
harness.target_config.secret_access_key.clone(),
"",
)
.unwrap(),
)
.unwrap();
assert!(execute_cleanup(&failing_target, &first).await.is_err());
assert_eq!(
service
.retry_cleanup(&first, "target_unavailable")
.await
.unwrap(),
CleanupCompletion::RetryScheduled
);
let short_claims = RecoveryConfig {
cleanup_claim_ttl: Duration::from_millis(1),
..config.clone()
};
let abandoned = service.claim_cleanup(&short_claims).await.unwrap().unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let restarted = harness.service(3);
let reclaimed = restarted.claim_cleanup(&config).await.unwrap().unwrap();
assert_eq!(reclaimed.cleanup_id, abandoned.cleanup_id);
assert_ne!(reclaimed.claim_token, abandoned.claim_token);
assert_eq!(
service.complete_cleanup(&abandoned).await.unwrap(),
CleanupCompletion::ClaimLost
);
execute_cleanup(&harness.target, &reclaimed).await.unwrap();
assert_eq!(
restarted.complete_cleanup(&reclaimed).await.unwrap(),
CleanupCompletion::Completed
);
let mut completed = 1;
while let Some(claim) = restarted.claim_cleanup(&config).await.unwrap() {
assert_eq!(claim.attempt_id, loser.attempt_id);
execute_cleanup(&harness.target, &claim).await.unwrap();
assert_eq!(
restarted.complete_cleanup(&claim).await.unwrap(),
CleanupCompletion::Completed
);
assert_eq!(
restarted.complete_cleanup(&claim).await.unwrap(),
CleanupCompletion::ClaimLost
);
completed += 1;
}
assert_eq!(completed, 2);
assert!(
harness
.target
.head_exact(&loser_object.identity)
.await
.is_err()
);
assert!(
harness
.target
.head_exact(&winner_object.identity)
.await
.is_ok()
);
assert!(
service
.register_staging_object(
&loser.attempt_id,
&StagingObject {
target_store: harness.target_config.target_store_id.clone(),
bucket: harness.target_config.bucket.clone(),
key: attempt_object_key_in(
"users/recovery",
&winner.attempt_id,
&unique_id("wrong"),
None,
),
version_id: None,
},
)
.await
.is_err()
);
}
#[tokio::test]
async fn cleanup_reconciles_accepted_writes_from_durable_key_intents() {
let Some(harness) = Harness::new("write-intent").await else {
return;
};
let service = harness.service(1);
let config = recovery_config();
let multipart_job = harness.job().await;
let multipart_attempt = harness.executing(&service, &multipart_job).await;
let multipart_key = attempt_object_key_in(
"users/recovery",
&multipart_attempt.attempt_id,
&unique_id("multipart"),
None,
);
service
.register_staging_write_intent_operation(
&multipart_attempt,
&unique_id("intent"),
StagingWriteIntent {
target_store: harness.target_config.target_store_id.clone(),
bucket: harness.target_config.bucket.clone(),
key: multipart_key.clone(),
},
)
.await
.unwrap();
let accepted_multipart = harness
.target
.begin_multipart(&CreateObjectRequest::parse(multipart_key.clone()).unwrap())
.await
.unwrap();
let accepted_upload_id = accepted_multipart.identity().upload_id.clone();
service
.publish_failure(
&multipart_attempt,
&unique_id("failure"),
RuntimeFailureClass::Authorization,
)
.await
.unwrap();
let intent_claim = service.claim_cleanup(&config).await.unwrap().unwrap();
assert!(matches!(
intent_claim.identity,
CleanupIdentity::WriteIntent(_)
));
let discovered = harness
.target
.discover_staging_write(&multipart_key)
.await
.unwrap();
assert!(discovered.objects.is_empty());
assert_eq!(discovered.multiparts.len(), 1);
assert_eq!(discovered.multiparts[0].upload_id, accepted_upload_id);
assert_eq!(
service
.reconcile_write_intent(
&intent_claim,
Vec::new(),
discovered
.multiparts
.into_iter()
.map(|identity| StagingMultipart {
target_store: identity.target_store_id,
bucket: identity.bucket,
key: identity.key,
upload_id: identity.upload_id,
})
.collect(),
)
.await
.unwrap(),
CleanupCompletion::Completed
);
let multipart_claim = service.claim_cleanup(&config).await.unwrap().unwrap();
assert!(matches!(
multipart_claim.identity,
CleanupIdentity::Multipart(_)
));
execute_cleanup(&harness.target, &multipart_claim)
.await
.unwrap();
service.complete_cleanup(&multipart_claim).await.unwrap();
assert!(
harness
.target
.discover_staging_write(&multipart_key)
.await
.unwrap()
.multiparts
.is_empty()
);
let object_job = harness.job().await;
let object_attempt = harness.executing(&service, &object_job).await;
let object_key = attempt_object_key_in(
"users/recovery",
&object_attempt.attempt_id,
&unique_id("object"),
None,
);
service
.register_staging_write_intent_operation(
&object_attempt,
&unique_id("intent"),
StagingWriteIntent {
target_store: harness.target_config.target_store_id.clone(),
bucket: harness.target_config.bucket.clone(),
key: object_key.clone(),
},
)
.await
.unwrap();
let accepted_object = harness
.target
.put_create_only(
&CreateObjectRequest::parse(object_key.clone()).unwrap(),
b"accepted-before-ledger".as_slice(),
)
.await
.unwrap();
service
.publish_failure(
&object_attempt,
&unique_id("failure"),
RuntimeFailureClass::Authorization,
)
.await
.unwrap();
let intent_claim = service.claim_cleanup(&config).await.unwrap().unwrap();
let discovered = harness
.target
.discover_staging_write(&object_key)
.await
.unwrap();
let [discovered_object] = discovered.objects.as_slice() else {
panic!("expected one accepted object");
};
assert_eq!(
discovered_object.target_store_id,
accepted_object.identity.target_store_id
);
assert_eq!(discovered_object.bucket, accepted_object.identity.bucket);
assert_eq!(discovered_object.key, accepted_object.identity.key);
if let (Some(discovered_version), Some(accepted_version)) = (
discovered_object.version_id.as_deref(),
accepted_object.identity.version_id.as_deref(),
) {
assert_eq!(discovered_version, accepted_version);
}
assert!(discovered.multiparts.is_empty());
assert_eq!(
service
.reconcile_write_intent(
&intent_claim,
discovered
.objects
.into_iter()
.map(|identity| StagingObject {
target_store: identity.target_store_id,
bucket: identity.bucket,
key: identity.key,
version_id: identity.version_id,
})
.collect(),
Vec::new(),
)
.await
.unwrap(),
CleanupCompletion::Completed
);
let object_claim = service.claim_cleanup(&config).await.unwrap().unwrap();
assert!(matches!(object_claim.identity, CleanupIdentity::Object(_)));
execute_cleanup(&harness.target, &object_claim)
.await
.unwrap();
service.complete_cleanup(&object_claim).await.unwrap();
assert!(
harness
.target
.head_exact(&accepted_object.identity)
.await
.is_err()
);
}
async fn execute_cleanup(
target: &TargetStore,
claim: &remora_attempt::CleanupClaim,
) -> Result<(), remora_target::TargetError> {
match &claim.identity {
CleanupIdentity::WriteIntent(_) => Err(remora_target::TargetError::internal(
"write intents must be reconciled before exact cleanup",
)),
CleanupIdentity::Object(object) => {
target
.delete_exact(&ObjectIdentity {
target_store_id: object.target_store.clone(),
bucket: object.bucket.clone(),
key: object.key.clone(),
version_id: object.version_id.clone(),
})
.await
}
CleanupIdentity::Multipart(upload) => {
let (session, _) = target
.resume_multipart(
MultipartIdentity {
target_store_id: upload.target_store.clone(),
bucket: upload.bucket.clone(),
key: upload.key.clone(),
upload_id: upload.upload_id.clone(),
},
None,
)
.await?;
target.abort_multipart(session).await
}
}
}
fn recovery_config() -> RecoveryConfig {
RecoveryConfig {
retry_delay: Duration::ZERO,
cleanup_claim_ttl: Duration::from_secs(10),
batch_size: 128,
}
}
fn required() -> bool {
std::env::var("REMORA_RECOVERY_REQUIRE").is_ok()
}
fn required_env(name: &str) -> Option<String> {
match std::env::var(name) {
Ok(value) if !value.is_empty() => Some(value),
Ok(_) | Err(_) if required() => panic!("{name} is required"),
Ok(_) | Err(_) => None,
}
}
fn unique_id(prefix: &str) -> OpaqueId {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
OpaqueId::parse(format!("{prefix}{nanos:x}")).unwrap()
}
fn nonce() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos()
}
fn coordination_namespace() -> String {
std::env::var("REMORA_COORDINATION_NAMESPACE").unwrap_or_else(|_| "remora:test:".into())
}
fn coordination_upstream() -> String {
std::env::var("REMORA_COORDINATION_URL")
.unwrap()
.trim_start_matches("redis://")
.split('/')
.next()
.unwrap()
.to_owned()
}