Skip to content

Commit b347c33

Browse files
authored
fix(ecr,sqs,elasticache): data-durability + portability gaps (bug-hunt 4.2/4.3/4.4) (#2290)
2 parents ae46d7e + 367b47c commit b347c33

5 files changed

Lines changed: 110 additions & 8 deletions

File tree

crates/fakecloud-ecr/src/oci.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,12 @@ pub(crate) fn create_upload_spool(upload_id: &str) -> std::io::Result<PathBuf> {
5252
/// `UploadLayerPart` route, where the SDK sends a base64-encoded chunk.
5353
pub(crate) fn append_bytes_sync(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
5454
use std::io::Write;
55-
let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
55+
// `create(true)`: a spool lost to a restart (temp-dir, non-durable) is
56+
// recreated rather than 500-ing the chunk.
57+
let mut f = std::fs::OpenOptions::new()
58+
.append(true)
59+
.create(true)
60+
.open(path)?;
5661
f.write_all(bytes)?;
5762
f.sync_data()?;
5863
Ok(())
@@ -69,6 +74,7 @@ pub(crate) async fn append_stream(
6974
use tokio::io::AsyncWriteExt;
7075
let mut file = tokio::fs::OpenOptions::new()
7176
.append(true)
77+
.create(true)
7278
.open(path)
7379
.await
7480
.map_err(|e| {

crates/fakecloud-ecr/src/state.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,14 @@ pub struct EcrState {
4545
pub account_settings: BTreeMap<String, String>,
4646
/// Layer upload state machine keyed by `uploadId`. Each entry is
4747
/// tied to a specific repository.
48-
#[serde(default)]
48+
///
49+
/// NOT persisted: the per-upload spool file lives under the system temp
50+
/// dir (`spool_path`) and never survives a restart, so persisting the
51+
/// upload metadata alone would leave orphan entries whose next
52+
/// `UploadLayerPart` opens a missing spool and 500s. Dropping in-flight
53+
/// uploads on restart keeps the map consistent with the (gone) spools; a
54+
/// stale client simply re-initiates, matching ECR's finite upload lifetime.
55+
#[serde(skip)]
4956
pub layer_uploads: BTreeMap<String, LayerUpload>,
5057
/// Pull-time update exclusions keyed by IAM principal ARN. These
5158
/// are registry-level per the Smithy model.
@@ -411,3 +418,39 @@ pub struct ReplicationDestination {
411418
pub region: String,
412419
pub registry_id: String,
413420
}
421+
422+
#[cfg(test)]
423+
mod tests {
424+
use super::*;
425+
426+
#[test]
427+
fn layer_uploads_are_not_persisted_across_snapshot() {
428+
// In-progress uploads spool to the system temp dir, which does not
429+
// survive a restart. Persisting the upload metadata alone would leave
430+
// an orphan entry whose next `UploadLayerPart` opens a missing spool
431+
// and 500s (bug-hunt 4.2), so `layer_uploads` is `#[serde(skip)]`.
432+
let mut state = EcrState::new("123456789012", "us-east-1");
433+
state.layer_uploads.insert(
434+
"upload-1".to_string(),
435+
LayerUpload {
436+
upload_id: "upload-1".to_string(),
437+
repository_name: "repo".to_string(),
438+
created_at: Utc::now(),
439+
spool_path: "/tmp/fakecloud-ecr-upload-upload-1".to_string(),
440+
last_byte_received: 42,
441+
},
442+
);
443+
444+
let json = serde_json::to_string(&state).unwrap();
445+
assert!(
446+
!json.contains("upload-1"),
447+
"in-progress uploads must not be serialized"
448+
);
449+
450+
let restored: EcrState = serde_json::from_str(&json).unwrap();
451+
assert!(
452+
restored.layer_uploads.is_empty(),
453+
"in-progress uploads must not survive a restart"
454+
);
455+
}
456+
}

crates/fakecloud-elasticache/src/service/snapshots.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@
33
44
use super::*;
55

6+
/// Per-snapshot RDB dump path. Roots under the platform temp dir rather than a
7+
/// hardcoded `/tmp` (absent on Windows, sometimes non-writable in containers).
8+
/// The PID keeps concurrent servers from colliding on the same file.
9+
fn snapshot_rdb_temp_path(account_id: &str, snapshot_name: &str) -> String {
10+
std::env::temp_dir()
11+
.join(format!(
12+
"fakecloud-ec-{}-{}-{}.rdb",
13+
account_id,
14+
snapshot_name,
15+
std::process::id()
16+
))
17+
.to_string_lossy()
18+
.into_owned()
19+
}
20+
621
impl ElastiCacheService {
722
pub(super) fn create_serverless_cache_snapshot(
823
&self,
@@ -289,12 +304,7 @@ impl ElastiCacheService {
289304
};
290305

291306
if let Some(ref runtime) = self.runtime {
292-
let tmp_path = format!(
293-
"/tmp/fakecloud-ec-{}-{}-{}.rdb",
294-
request.account_id,
295-
snapshot_name,
296-
std::process::id()
297-
);
307+
let tmp_path = snapshot_rdb_temp_path(&request.account_id, &snapshot_name);
298308
match runtime.dump_rdb(&group_id, &tmp_path).await {
299309
Ok(()) => {
300310
snapshot.rdb_path = Some(tmp_path);
@@ -556,3 +566,23 @@ impl ElastiCacheService {
556566
))
557567
}
558568
}
569+
570+
#[cfg(test)]
571+
mod snapshot_path_tests {
572+
use super::snapshot_rdb_temp_path;
573+
574+
#[test]
575+
fn rdb_temp_path_roots_under_platform_temp_dir() {
576+
// Portability (4.4): the RDB dump must live under the platform temp
577+
// dir, not a hardcoded `/tmp` that is absent on Windows / some
578+
// containers.
579+
let path = snapshot_rdb_temp_path("123456789012", "nightly");
580+
let temp = std::env::temp_dir();
581+
assert!(
582+
std::path::Path::new(&path).starts_with(&temp),
583+
"{path} must root under {}",
584+
temp.display()
585+
);
586+
assert!(path.contains("nightly") && path.ends_with(".rdb"));
587+
}
588+
}

crates/fakecloud-sqs/src/service/messages.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,17 @@ impl SqsService {
812812
"redrive target queue not found; returning message to source queue"
813813
);
814814
if let Some(source) = state.queues.values_mut().find(|q| q.arn == queue_arn) {
815+
// Reset the receive history before returning the message.
816+
// It re-enters the source with `receive_count` still past
817+
// `maxReceiveCount` (that is what triggered the redrive) and
818+
// `visible_at = None` (immediately visible), so without this
819+
// the very next ReceiveMessage would re-redrive it, hit the
820+
// same unresolvable target, and spin in a hot loop. Resetting
821+
// gives the consumer a fresh `maxReceiveCount` attempts —
822+
// the message is reprocessed rather than tight-looping, and
823+
// is still never destroyed.
824+
msg.receive_count = 0;
825+
msg.first_received_at = None;
815826
source.messages.push_back(msg);
816827
}
817828
}

crates/fakecloud-sqs/src/service_tests.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,6 +1394,18 @@ fn redrive_with_unresolvable_dlq_arn_keeps_message_on_source_queue() {
13941394
.unwrap(),
13951395
);
13961396
assert_eq!(attrs["Attributes"]["ApproximateNumberOfMessages"], "1");
1397+
1398+
// Loop-prevention (4.3): the message was returned to source with its
1399+
// receive history reset, so a fresh receive delivers it again for normal
1400+
// reprocessing. Before the fix its receive_count stayed past
1401+
// maxReceiveCount with visible_at=None, so the next receive re-redrove it
1402+
// into the same unresolvable target and spun in a hot loop (0 delivered
1403+
// forever).
1404+
assert_eq!(
1405+
receive_msgs(&svc, &src_url, 1).len(),
1406+
1,
1407+
"returned message must be re-receivable, not hot-looping through redrive"
1408+
);
13971409
}
13981410

13991411
// ── Error branch tests ──

0 commit comments

Comments
 (0)