Skip to content

Commit d93efd8

Browse files
authored
fix(glue,sagemaker,pipes,sns,scheduler): persist versions/status + honor DLQ + FIFO/EB params (bug-hunt) (#2285)
2 parents 82f33b9 + b17ca57 commit d93efd8

18 files changed

Lines changed: 882 additions & 78 deletions

File tree

crates/fakecloud-core/src/delivery.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ pub struct DeliveryBus {
1919
kinesis_sender: Option<Arc<dyn KinesisDelivery>>,
2020
/// Start Step Functions executions.
2121
stepfunctions_starter: Option<Arc<dyn StepFunctionsDelivery>>,
22+
/// Start SageMaker pipeline executions (Scheduler sagemaker target).
23+
sagemaker_pipeline_starter: Option<Arc<dyn SageMakerPipelineDelivery>>,
2224
/// Write objects to S3 buckets.
2325
s3_writer: Option<Arc<dyn S3Delivery>>,
2426
/// Put records into Firehose delivery streams.
@@ -196,6 +198,14 @@ pub trait StepFunctionsDelivery: Send + Sync {
196198
fn start_execution(&self, state_machine_arn: &str, input: &str);
197199
}
198200

201+
/// Cross-service SageMaker pipeline dispatch used by EventBridge Scheduler's
202+
/// `sagemaker:pipeline` target. The pipeline is identified by ARN
203+
/// (`arn:aws:sagemaker:<region>:<account>:pipeline/<name>`); `parameters` is the
204+
/// target's `PipelineParameterList`.
205+
pub trait SageMakerPipelineDelivery: Send + Sync {
206+
fn start_pipeline_execution(&self, pipeline_arn: &str, parameters: &serde_json::Value);
207+
}
208+
199209
/// Cross-service Kinesis Data Firehose dispatch used by services
200210
/// (CloudWatch Logs subscription filters, EventBridge targets) that
201211
/// route records into a delivery stream without depending on the
@@ -385,6 +395,7 @@ impl DeliveryBus {
385395
lambda_invoker: None,
386396
kinesis_sender: None,
387397
stepfunctions_starter: None,
398+
sagemaker_pipeline_starter: None,
388399
s3_writer: None,
389400
firehose_sender: None,
390401
ses_dispatcher: None,
@@ -679,6 +690,18 @@ impl DeliveryBus {
679690
}
680691
}
681692

693+
pub fn with_sagemaker_pipeline(mut self, starter: Arc<dyn SageMakerPipelineDelivery>) -> Self {
694+
self.sagemaker_pipeline_starter = Some(starter);
695+
self
696+
}
697+
698+
/// Start a SageMaker pipeline execution if a starter is wired.
699+
pub fn start_sagemaker_pipeline(&self, pipeline_arn: &str, parameters: &serde_json::Value) {
700+
if let Some(ref starter) = self.sagemaker_pipeline_starter {
701+
starter.start_pipeline_execution(pipeline_arn, parameters);
702+
}
703+
}
704+
682705
pub fn with_stepfunctions(mut self, starter: Arc<dyn StepFunctionsDelivery>) -> Self {
683706
self.stepfunctions_starter = Some(starter);
684707
self

crates/fakecloud-e2e/tests/glue.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,95 @@ async fn table_lifecycle() {
190190
assert!(err.into_service_error().is_entity_not_found_exception());
191191
}
192192

193+
#[tokio::test]
194+
async fn table_versions_are_archived_on_create_and_update() {
195+
let server = TestServer::start().await;
196+
let glue = server.glue_client().await;
197+
198+
glue.create_database()
199+
.database_input(DatabaseInput::builder().name("versdb").build().unwrap())
200+
.send()
201+
.await
202+
.expect("create db");
203+
204+
glue.create_table()
205+
.database_name("versdb")
206+
.table_input(table_input("t"))
207+
.send()
208+
.await
209+
.expect("create table");
210+
211+
// A create archives version 1.
212+
let v1 = glue
213+
.get_table_versions()
214+
.database_name("versdb")
215+
.table_name("t")
216+
.send()
217+
.await
218+
.expect("get versions after create");
219+
assert_eq!(v1.table_versions().len(), 1);
220+
assert_eq!(v1.table_versions()[0].version_id(), Some("1"));
221+
222+
// An update archives a second version with the mutated table.
223+
glue.update_table()
224+
.database_name("versdb")
225+
.table_input(
226+
TableInput::builder()
227+
.name("t")
228+
.description("updated description")
229+
.build()
230+
.unwrap(),
231+
)
232+
.send()
233+
.await
234+
.expect("update table");
235+
236+
let v2 = glue
237+
.get_table_versions()
238+
.database_name("versdb")
239+
.table_name("t")
240+
.send()
241+
.await
242+
.expect("get versions after update");
243+
assert_eq!(
244+
v2.table_versions().len(),
245+
2,
246+
"update must archive a new version"
247+
);
248+
249+
// GetTableVersion with no VersionId returns the latest (2), reflecting the
250+
// updated description rather than a synthesized default.
251+
let latest = glue
252+
.get_table_version()
253+
.database_name("versdb")
254+
.table_name("t")
255+
.send()
256+
.await
257+
.expect("get latest version");
258+
let tv = latest.table_version().expect("table version");
259+
assert_eq!(tv.version_id(), Some("2"));
260+
assert_eq!(
261+
tv.table().and_then(|t| t.description()),
262+
Some("updated description")
263+
);
264+
265+
// Deleting the table purges its version archive.
266+
glue.delete_table()
267+
.database_name("versdb")
268+
.name("t")
269+
.send()
270+
.await
271+
.expect("delete");
272+
let after = glue
273+
.get_table_versions()
274+
.database_name("versdb")
275+
.table_name("t")
276+
.send()
277+
.await
278+
.expect("get versions after delete");
279+
assert!(after.table_versions().is_empty());
280+
}
281+
193282
#[tokio::test]
194283
async fn partition_lifecycle() {
195284
let server = TestServer::start().await;

crates/fakecloud-e2e/tests/sagemaker.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,59 @@ async fn sagemaker_pipeline_execution_round_trip() {
193193
);
194194
}
195195

196+
/// Stop*/Start* lifecycle ops must advance the target resource's status so a
197+
/// subsequent Describe reflects the transition instead of the default state.
198+
#[tokio::test]
199+
async fn sagemaker_notebook_instance_stop_start_transitions_status() {
200+
let server = TestServer::start().await;
201+
let client = sagemaker_client(&server).await;
202+
203+
client
204+
.create_notebook_instance()
205+
.notebook_instance_name("nb-1")
206+
.instance_type(aws_sdk_sagemaker::types::InstanceType::MlT2Medium)
207+
.role_arn("arn:aws:iam::000000000000:role/SageMakerRole")
208+
.send()
209+
.await
210+
.expect("create_notebook_instance");
211+
212+
client
213+
.stop_notebook_instance()
214+
.notebook_instance_name("nb-1")
215+
.send()
216+
.await
217+
.expect("stop_notebook_instance");
218+
let stopped = client
219+
.describe_notebook_instance()
220+
.notebook_instance_name("nb-1")
221+
.send()
222+
.await
223+
.expect("describe after stop");
224+
assert_eq!(
225+
stopped.notebook_instance_status(),
226+
Some(&aws_sdk_sagemaker::types::NotebookInstanceStatus::Stopped),
227+
"StopNotebookInstance must move status to Stopped"
228+
);
229+
230+
client
231+
.start_notebook_instance()
232+
.notebook_instance_name("nb-1")
233+
.send()
234+
.await
235+
.expect("start_notebook_instance");
236+
let started = client
237+
.describe_notebook_instance()
238+
.notebook_instance_name("nb-1")
239+
.send()
240+
.await
241+
.expect("describe after start");
242+
assert_eq!(
243+
started.notebook_instance_status(),
244+
Some(&aws_sdk_sagemaker::types::NotebookInstanceStatus::InService),
245+
"StartNotebookInstance must move status to InService"
246+
);
247+
}
248+
196249
/// `AddAssociation` is the only creation path for a lineage edge; the edge must
197250
/// then be listed and deletable.
198251
#[tokio::test]

crates/fakecloud-e2e/tests/sns.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,114 @@ async fn sns_publish_delivers_to_sqs_subscriber() {
264264
assert_eq!(envelope["TopicArn"], topic_arn);
265265
}
266266

267+
/// An SQS subscriber whose target queue is gone must route the notification to
268+
/// the subscription's RedrivePolicy DLQ instead of silently dropping it — the
269+
/// DLQ was previously honored for HTTP subscribers only.
270+
#[tokio::test]
271+
async fn sns_sqs_subscriber_failure_routes_to_dlq() {
272+
let server = TestServer::start().await;
273+
let sns = server.sns_client().await;
274+
let sqs = server.sqs_client().await;
275+
276+
async fn queue_arn(sqs: &aws_sdk_sqs::Client, url: &str) -> String {
277+
sqs.get_queue_attributes()
278+
.queue_url(url)
279+
.attribute_names(QueueAttributeName::QueueArn)
280+
.send()
281+
.await
282+
.unwrap()
283+
.attributes()
284+
.unwrap()
285+
.get(&QueueAttributeName::QueueArn)
286+
.unwrap()
287+
.to_string()
288+
}
289+
290+
// Main queue (will be deleted) + DLQ.
291+
let main_url = sqs
292+
.create_queue()
293+
.queue_name("dlq-main")
294+
.send()
295+
.await
296+
.unwrap()
297+
.queue_url()
298+
.unwrap()
299+
.to_string();
300+
let main_arn = queue_arn(&sqs, &main_url).await;
301+
let dlq_url = sqs
302+
.create_queue()
303+
.queue_name("dlq-target")
304+
.send()
305+
.await
306+
.unwrap()
307+
.queue_url()
308+
.unwrap()
309+
.to_string();
310+
let dlq_arn = queue_arn(&sqs, &dlq_url).await;
311+
312+
let topic_arn = sns
313+
.create_topic()
314+
.name("dlq-topic")
315+
.send()
316+
.await
317+
.unwrap()
318+
.topic_arn()
319+
.unwrap()
320+
.to_string();
321+
let sub_arn = sns
322+
.subscribe()
323+
.topic_arn(&topic_arn)
324+
.protocol("sqs")
325+
.endpoint(&main_arn)
326+
.return_subscription_arn(true)
327+
.send()
328+
.await
329+
.unwrap()
330+
.subscription_arn()
331+
.unwrap()
332+
.to_string();
333+
sns.set_subscription_attributes()
334+
.subscription_arn(&sub_arn)
335+
.attribute_name("RedrivePolicy")
336+
.attribute_value(format!(r#"{{"deadLetterTargetArn":"{dlq_arn}"}}"#))
337+
.send()
338+
.await
339+
.unwrap();
340+
341+
// Delete the subscribed queue so delivery fails.
342+
sqs.delete_queue()
343+
.queue_url(&main_url)
344+
.send()
345+
.await
346+
.unwrap();
347+
348+
sns.publish()
349+
.topic_arn(&topic_arn)
350+
.message("to-dlq")
351+
.send()
352+
.await
353+
.unwrap();
354+
355+
// The notification must land in the DLQ.
356+
let msgs = sqs
357+
.receive_message()
358+
.queue_url(&dlq_url)
359+
.max_number_of_messages(10)
360+
.wait_time_seconds(1)
361+
.send()
362+
.await
363+
.unwrap();
364+
assert_eq!(
365+
msgs.messages().len(),
366+
1,
367+
"failed delivery must route to DLQ"
368+
);
369+
assert!(
370+
msgs.messages()[0].body().unwrap().contains("to-dlq"),
371+
"DLQ envelope should wrap the original message"
372+
);
373+
}
374+
267375
/// Subscribing Lambda/email/sms protocols should succeed (stub delivery).
268376
#[tokio::test]
269377
async fn sns_subscribe_lambda_email_sms() {

crates/fakecloud-glue/src/service.rs

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,26 +1101,27 @@ impl GlueService {
11011101
if db.tables.contains_key(&name) {
11021102
return Err(already_exists(format!("Table {name} already exists")));
11031103
}
1104-
db.tables.insert(
1105-
name.clone(),
1106-
Table {
1107-
name,
1108-
database_name: db_name,
1109-
description: input["Description"].as_str().map(|s| s.to_string()),
1110-
owner: input["Owner"].as_str().map(|s| s.to_string()),
1111-
create_time: now,
1112-
update_time: now,
1113-
last_access_time: None,
1114-
retention: input["Retention"].as_i64().unwrap_or(0),
1115-
storage_descriptor: parse_storage_descriptor(&input["StorageDescriptor"]),
1116-
partition_keys: parse_columns(&input["PartitionKeys"]),
1117-
view_original_text: input["ViewOriginalText"].as_str().map(|s| s.to_string()),
1118-
view_expanded_text: input["ViewExpandedText"].as_str().map(|s| s.to_string()),
1119-
table_type: input["TableType"].as_str().map(|s| s.to_string()),
1120-
parameters: parse_string_map(&input["Parameters"]),
1121-
partitions: BTreeMap::new(),
1122-
},
1123-
);
1104+
let table = Table {
1105+
name: name.clone(),
1106+
database_name: db_name.clone(),
1107+
description: input["Description"].as_str().map(|s| s.to_string()),
1108+
owner: input["Owner"].as_str().map(|s| s.to_string()),
1109+
create_time: now,
1110+
update_time: now,
1111+
last_access_time: None,
1112+
retention: input["Retention"].as_i64().unwrap_or(0),
1113+
storage_descriptor: parse_storage_descriptor(&input["StorageDescriptor"]),
1114+
partition_keys: parse_columns(&input["PartitionKeys"]),
1115+
view_original_text: input["ViewOriginalText"].as_str().map(|s| s.to_string()),
1116+
view_expanded_text: input["ViewExpandedText"].as_str().map(|s| s.to_string()),
1117+
table_type: input["TableType"].as_str().map(|s| s.to_string()),
1118+
parameters: parse_string_map(&input["Parameters"]),
1119+
partitions: BTreeMap::new(),
1120+
};
1121+
let tv_json = table_json(&table);
1122+
db.tables.insert(name.clone(), table);
1123+
// Archive the initial version so GetTableVersion(s) return real data.
1124+
state.archive_table_version(&db_name, &name, tv_json);
11241125
Ok(AwsResponse::ok_json(json!({})))
11251126
}
11261127

@@ -1224,6 +1225,10 @@ impl GlueService {
12241225
if let Some(r) = input["Retention"].as_i64() {
12251226
t.retention = r;
12261227
}
1228+
// Snapshot the mutated table as a new archived version (Glue bumps the
1229+
// VersionId on every UpdateTable).
1230+
let tv_json = table_json(t);
1231+
state.archive_table_version(db_name, name, tv_json);
12271232
Ok(AwsResponse::ok_json(json!({})))
12281233
}
12291234

@@ -1242,6 +1247,7 @@ impl GlueService {
12421247
if db.tables.remove(name).is_none() {
12431248
return Err(entity_not_found(format!("Table {name} not found")));
12441249
}
1250+
state.purge_table_versions(db_name, name);
12451251
Ok(AwsResponse::ok_json(json!({})))
12461252
}
12471253

0 commit comments

Comments
 (0)