Skip to content

Commit 69a48fe

Browse files
authored
fix(autoscaling,ec2,kinesis): persist CFN ASG + scheduler/pipes deliveries and guard EC2 restart reconcile (bug-hunt) (#2316)
2 parents dcb9b84 + 05886de commit 69a48fe

7 files changed

Lines changed: 475 additions & 43 deletions

File tree

crates/fakecloud-autoscaling/src/cfn_provision.rs

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,21 @@
1212
1313
use std::sync::Arc;
1414

15+
use fakecloud_persistence::SnapshotHook;
16+
1517
use crate::AutoScalingService;
1618
use crate::SharedAutoScalingState;
1719

20+
/// Snapshot hooks the detached CFN capacity reconcile fires after it mutates
21+
/// shared state, so the launched ASG instances (`autoscaling`) and their
22+
/// backing EC2 records (`ec2`) survive a restart. `None` fields make the
23+
/// corresponding persist a no-op (memory mode / unit tests).
24+
#[derive(Clone, Default)]
25+
pub struct CfnReconcilePersistHooks {
26+
pub autoscaling: Option<SnapshotHook>,
27+
pub ec2: Option<SnapshotHook>,
28+
}
29+
1830
/// Reconcile a CFN-provisioned Auto Scaling Group to its desired capacity by
1931
/// launching REAL EC2 instances. No-op if the group is gone (e.g. the stack was
2032
/// deleted before reconciliation ran). Intended to be `tokio::spawn`ed by the
@@ -29,9 +41,21 @@ pub async fn cfn_reconcile_capacity(
2941
group_name: String,
3042
account_id: String,
3143
region: String,
44+
persist: CfnReconcilePersistHooks,
3245
) {
33-
let svc = AutoScalingService::new(asg_state).with_ec2(ec2_state, ec2_runtime);
46+
// The EC2 hook is set on the service so `apply_capacity` persists the REAL
47+
// EC2 instances it launches; the autoscaling hook is fired below to persist
48+
// the group's `instances` list. Both are required because this reconcile
49+
// runs DETACHED after `CreateStack`/`UpdateStack` has already serialized the
50+
// group with `instances=[]`; without them the launched instances (and their
51+
// EC2 records) vanish on restart (bug-hunt restart-dataloss).
52+
let svc = AutoScalingService::new(asg_state)
53+
.with_ec2(ec2_state, ec2_runtime)
54+
.with_ec2_snapshot_hook(persist.ec2);
3455
svc.reconcile_group(&account_id, &group_name, &region).await;
56+
if let Some(hook) = persist.autoscaling {
57+
hook().await;
58+
}
3559
}
3660

3761
/// Terminate the REAL EC2 instances launched for a CFN-provisioned Auto Scaling
@@ -54,3 +78,146 @@ pub async fn cfn_terminate_instances(
5478
svc.cfn_terminate_instances(&account_id, &region, &instance_ids)
5579
.await;
5680
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use super::*;
85+
use crate::state::{AutoScalingAccounts, AutoScalingGroup, AutoScalingSnapshot};
86+
use fakecloud_persistence::SnapshotStore;
87+
use parking_lot::{Mutex, RwLock};
88+
89+
/// Shared cell holding the bytes a capturing snapshot store last saved.
90+
type Captured = Arc<Mutex<Option<Vec<u8>>>>;
91+
92+
// A `SnapshotStore` that captures the last-saved bytes in memory so a test
93+
// can assert what the persist hook serialized (no tempfile needed).
94+
struct CapturingStore(Captured);
95+
96+
impl SnapshotStore for CapturingStore {
97+
fn load(&self) -> std::io::Result<Option<Vec<u8>>> {
98+
Ok(self.0.lock().clone())
99+
}
100+
fn save(&self, bytes: &[u8]) -> std::io::Result<()> {
101+
*self.0.lock() = Some(bytes.to_vec());
102+
Ok(())
103+
}
104+
}
105+
106+
fn capturing() -> (Captured, Arc<dyn SnapshotStore>) {
107+
let cell: Captured = Arc::new(Mutex::new(None));
108+
(cell.clone(), Arc::new(CapturingStore(cell)))
109+
}
110+
111+
fn group_desired(name: &str, desired: i64) -> AutoScalingGroup {
112+
AutoScalingGroup {
113+
name: name.to_string(),
114+
arn: format!(
115+
"arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:x:autoScalingGroupName/{name}"
116+
),
117+
launch_configuration_name: None,
118+
launch_template: None,
119+
min_size: 0,
120+
max_size: desired,
121+
desired_capacity: desired,
122+
default_cooldown: 300,
123+
availability_zones: vec!["us-east-1a".to_string()],
124+
vpc_zone_identifier: None,
125+
health_check_type: "EC2".to_string(),
126+
health_check_grace_period: 0,
127+
target_group_arns: Vec::new(),
128+
load_balancer_names: Vec::new(),
129+
new_instances_protected_from_scale_in: false,
130+
created_time: chrono::Utc::now(),
131+
instances: Vec::new(),
132+
tags: Vec::new(),
133+
status: None,
134+
service_linked_role_arn: String::new(),
135+
}
136+
}
137+
138+
// bug-hunt restart-dataloss #1/#2: a CFN-provisioned ASG is inserted with
139+
// `instances=[]` and reconciled to capacity by a DETACHED task. That task
140+
// must persist BOTH the launched ASG instances (autoscaling snapshot) and
141+
// their backing EC2 records (EC2 snapshot); otherwise the group reloads
142+
// empty and the EC2 containers leak with no owning state on restart.
143+
#[tokio::test]
144+
async fn cfn_reconcile_persists_asg_and_ec2_instances() {
145+
let account = "123456789012";
146+
let asg_state: SharedAutoScalingState = Arc::new(RwLock::new(AutoScalingAccounts::new()));
147+
asg_state
148+
.write()
149+
.get_or_create(account)
150+
.groups
151+
.insert("g".to_string(), group_desired("g", 2));
152+
153+
let ec2_state: fakecloud_ec2::SharedEc2State = Arc::new(RwLock::new(
154+
fakecloud_core::multi_account::MultiAccountState::new(account, "us-east-1", ""),
155+
));
156+
157+
// Persist hooks over capturing stores, built exactly as the server wires
158+
// the autoscaling + EC2 snapshot hooks.
159+
let (asg_cell, asg_store) = capturing();
160+
let asg_hook = AutoScalingService::new(asg_state.clone())
161+
.with_snapshot_store(asg_store)
162+
.snapshot_hook();
163+
assert!(asg_hook.is_some(), "autoscaling hook must be built");
164+
165+
let (ec2_cell, ec2_store) = capturing();
166+
let ec2_hook = fakecloud_ec2::Ec2Service::with_state(ec2_state.clone())
167+
.with_snapshot_store(ec2_store)
168+
.snapshot_hook();
169+
assert!(ec2_hook.is_some(), "ec2 hook must be built");
170+
171+
// No EC2 runtime -> metadata-only instances (CI path), still real EC2
172+
// records in `ec2_state`.
173+
cfn_reconcile_capacity(
174+
asg_state.clone(),
175+
ec2_state.clone(),
176+
None,
177+
"g".to_string(),
178+
account.to_string(),
179+
"us-east-1".to_string(),
180+
CfnReconcilePersistHooks {
181+
autoscaling: asg_hook,
182+
ec2: ec2_hook,
183+
},
184+
)
185+
.await;
186+
187+
// In-memory: the group reached desired capacity.
188+
assert_eq!(
189+
asg_state.read().accounts[account].groups["g"]
190+
.instances
191+
.len(),
192+
2,
193+
"reconcile must launch 2 instances"
194+
);
195+
196+
// #1: the autoscaling snapshot the detached reconcile fired contains the
197+
// launched instances (not the empty list CreateStack serialized).
198+
let asg_bytes = asg_cell
199+
.lock()
200+
.clone()
201+
.expect("autoscaling snapshot written");
202+
let asg_snap: AutoScalingSnapshot = serde_json::from_slice(&asg_bytes).unwrap();
203+
let persisted = asg_snap.accounts.expect("multi-account snapshot");
204+
assert_eq!(
205+
persisted.accounts[account].groups["g"].instances.len(),
206+
2,
207+
"launched ASG instances must survive restart"
208+
);
209+
210+
// #2: the EC2 snapshot the reconcile fired contains the backing records,
211+
// so EC2 boot-recovery has a row to re-drive (no container leak).
212+
let ec2_bytes = ec2_cell.lock().clone().expect("ec2 snapshot written");
213+
let ec2_snap: fakecloud_ec2::Ec2Snapshot = serde_json::from_slice(&ec2_bytes).unwrap();
214+
let ec2_accounts = ec2_snap.accounts.expect("ec2 multi-account snapshot");
215+
let in_memory = ec2_state.read().default_ref().instances.len();
216+
assert_eq!(in_memory, 2, "reconcile must create 2 EC2 records");
217+
assert_eq!(
218+
ec2_accounts.default_ref().instances.len(),
219+
in_memory,
220+
"ASG-launched EC2 records must be persisted (bug #2)"
221+
);
222+
}
223+
}

crates/fakecloud-autoscaling/src/service.rs

Lines changed: 65 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ pub struct AutoScalingService {
4545
/// instances (unit tests).
4646
ec2_state: Option<fakecloud_ec2::SharedEc2State>,
4747
ec2_runtime: Option<Arc<fakecloud_ec2::Ec2Runtime>>,
48+
/// EC2 whole-state snapshot hook. Reconciling desired capacity launches
49+
/// (and terminates) REAL EC2 instances through a bare `Ec2Service` built
50+
/// without a snapshot store, so those EC2 records lived only in memory and
51+
/// leaked their containers on restart (EC2 boot-recovery had no persisted
52+
/// `pending`/`running` row to re-drive). Firing this hook after an EC2
53+
/// mutation writes the EC2 state through, the same way a direct
54+
/// `RunInstances` API call persists (bug-hunt restart-dataloss).
55+
ec2_snapshot_hook: Option<SnapshotHook>,
4856
}
4957

5058
impl AutoScalingService {
@@ -55,6 +63,7 @@ impl AutoScalingService {
5563
snapshot_lock: Arc::new(AsyncMutex::new(())),
5664
ec2_state: None,
5765
ec2_runtime: None,
66+
ec2_snapshot_hook: None,
5867
}
5968
}
6069

@@ -63,6 +72,16 @@ impl AutoScalingService {
6372
self
6473
}
6574

75+
/// Attach the EC2 snapshot hook so ASG-launched EC2 instances are persisted
76+
/// after capacity reconciliation. Without it, ASG-launched instances live
77+
/// only in memory and their backing containers leak on restart (there is no
78+
/// persisted EC2 row for boot-recovery to re-drive). `None` (memory mode /
79+
/// unit tests) makes the persist a no-op.
80+
pub fn with_ec2_snapshot_hook(mut self, hook: Option<SnapshotHook>) -> Self {
81+
self.ec2_snapshot_hook = hook;
82+
self
83+
}
84+
6685
/// Attach the EC2 backend so desired-capacity reconciliation launches real
6786
/// container-backed instances via `RunInstances`.
6887
pub fn with_ec2(
@@ -995,12 +1014,18 @@ impl AutoScalingService {
9951014

9961015
let mut launched: Vec<AsgInstance> = Vec::new();
9971016
let mut terminate_ids: Vec<String> = Vec::new();
1017+
// Whether this reconcile mutated REAL EC2 state (launched or terminated
1018+
// container-backed instances), so it must be persisted through the EC2
1019+
// snapshot hook below. No-op when there is no EC2 backend (the ids are
1020+
// synthesized metadata) or no hook wired.
1021+
let mut ec2_touched = false;
9981022

9991023
if current_ids.len() < target {
10001024
let need = target - current_ids.len();
10011025
let mut ids = self
10021026
.run_ec2_instances(&image_id, &instance_type, subnet.as_deref(), need, req)
10031027
.await;
1028+
ec2_touched = self.ec2_state.is_some();
10041029
// No EC2 backend wired (unit tests) or a partial launch: synthesize
10051030
// the remainder so the group still reports its desired capacity.
10061031
while ids.len() < need {
@@ -1024,34 +1049,51 @@ impl AutoScalingService {
10241049
let remove = current_ids.len() - target;
10251050
terminate_ids = current_ids.iter().rev().take(remove).cloned().collect();
10261051
self.terminate_ec2_instances(&terminate_ids, req).await;
1052+
ec2_touched = self.ec2_state.is_some();
10271053
}
10281054

1029-
let mut accounts = self.state.write();
1030-
let st = accounts.get_or_create(account);
1031-
let descs: Vec<String> = {
1032-
let Some(g) = st.groups.get_mut(name) else {
1033-
return;
1034-
};
1035-
let lcn = g.launch_configuration_name.clone();
1036-
let prot = g.new_instances_protected_from_scale_in;
1037-
let mut descs = Vec::new();
1038-
for mut ni in launched {
1039-
ni.launch_configuration_name = lcn.clone();
1040-
ni.protected_from_scale_in = prot;
1041-
descs.push(format!("Launching a new EC2 instance: {}", ni.instance_id));
1042-
g.instances.push(ni);
1055+
// Persist the REAL EC2 records this reconcile launched/terminated BEFORE
1056+
// touching the ASG state below. The ASG-launched instances are driven
1057+
// through a bare `Ec2Service` built without a snapshot store, so without
1058+
// firing the EC2 snapshot hook here they live only in memory and leak
1059+
// their containers on restart (EC2 boot-recovery has no persisted row to
1060+
// re-drive). Done before the ASG write so a concurrent group delete (the
1061+
// early `return` below) cannot skip persisting them. No-op when there is
1062+
// no EC2 backend or no hook wired (bug-hunt restart-dataloss).
1063+
if ec2_touched {
1064+
if let Some(hook) = &self.ec2_snapshot_hook {
1065+
hook().await;
10431066
}
1044-
if !terminate_ids.is_empty() {
1045-
g.instances
1046-
.retain(|i| !terminate_ids.contains(&i.instance_id));
1047-
for id in &terminate_ids {
1048-
descs.push(format!("Terminating EC2 instance: {id}"));
1067+
}
1068+
1069+
{
1070+
let mut accounts = self.state.write();
1071+
let st = accounts.get_or_create(account);
1072+
let descs: Vec<String> = {
1073+
let Some(g) = st.groups.get_mut(name) else {
1074+
return;
1075+
};
1076+
let lcn = g.launch_configuration_name.clone();
1077+
let prot = g.new_instances_protected_from_scale_in;
1078+
let mut descs = Vec::new();
1079+
for mut ni in launched {
1080+
ni.launch_configuration_name = lcn.clone();
1081+
ni.protected_from_scale_in = prot;
1082+
descs.push(format!("Launching a new EC2 instance: {}", ni.instance_id));
1083+
g.instances.push(ni);
10491084
}
1085+
if !terminate_ids.is_empty() {
1086+
g.instances
1087+
.retain(|i| !terminate_ids.contains(&i.instance_id));
1088+
for id in &terminate_ids {
1089+
descs.push(format!("Terminating EC2 instance: {id}"));
1090+
}
1091+
}
1092+
descs
1093+
};
1094+
for d in descs {
1095+
st.activities.insert(0, activity(name, &d));
10501096
}
1051-
descs
1052-
};
1053-
for d in descs {
1054-
st.activities.insert(0, activity(name, &d));
10551097
}
10561098
}
10571099
}

crates/fakecloud-cloudformation/src/extras.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1075,7 +1075,8 @@ impl CloudFormationService {
10751075
// custom-resource Lambda invokes (0.2).
10761076
{
10771077
let handles =
1078-
crate::service::ContainerBackingHandles::from_provisioner(&provisioner);
1078+
crate::service::ContainerBackingHandles::from_provisioner(&provisioner)
1079+
.with_snapshot_hooks(&self.snapshot_hooks);
10791080
handles.spawn_container_intents(std::mem::take(
10801081
&mut *provisioner.pending_container_spawns.lock(),
10811082
));

0 commit comments

Comments
 (0)