Skip to content

Commit 16b8f12

Browse files
[perf-cleanup] remove redundant tests and update mcast polling
Combine similar [nexus_tests] for mcast, while being more thoughtful on failure handling and polling.
1 parent f7f3c39 commit 16b8f12

File tree

9 files changed

+686
-1817
lines changed

9 files changed

+686
-1817
lines changed

nexus/db-queries/src/db/datastore/multicast/members.rs

Lines changed: 118 additions & 232 deletions
Large diffs are not rendered by default.

nexus/tests/integration_tests/multicast/api.rs

Lines changed: 7 additions & 230 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
//! - SSM (Source-Specific): 232.0.0.0/8, sources required per-member
1717
//! - SSM validation: Every SSM member must specify sources (S,G subscription)
1818
//! - New groups: Validated before creation
19-
//! - Existing groups: Validated on join (by IP, name, or ID)
19+
//! - Existing groups: Validated on join (all paths share `instance_multicast_group_join`)
2020
//! - Empty sources array: Treated same as None (invalid for SSM)
2121
//! - Source IP validation: ASM can have sources; SSM requires them
2222
//! - Pool validation: IP must be in a linked multicast pool
@@ -461,7 +461,13 @@ async fn test_join_by_ip_ssm_with_sources(cptestctx: &ControlPlaneTestContext) {
461461
}
462462

463463
/// Test SSM join-by-IP without sources should fail.
464+
///
464465
/// SSM addresses (232.0.0.0/8) require source IPs for implicit creation.
466+
///
467+
/// This is the canonical test for SSM source validation. The validation
468+
/// code path is shared regardless of how you join (by IP, name, or ID) -
469+
/// all routes converge on the same `instance_multicast_group_join` logic
470+
/// that checks `is_ssm_address()` and rejects joins without sources.
465471
#[nexus_test]
466472
async fn test_join_by_ip_ssm_without_sources_fails(
467473
cptestctx: &ControlPlaneTestContext,
@@ -516,158 +522,6 @@ async fn test_join_by_ip_ssm_without_sources_fails(
516522
cleanup_instances(cptestctx, client, project_name, &[instance_name]).await;
517523
}
518524

519-
/// Test joining an existing SSM group by ID without sources should fail.
520-
///
521-
/// This tests the SSM validation for join-by-ID path: if an SSM group exists
522-
/// (created by first instance with sources), a second instance cannot join
523-
/// by group ID without providing sources.
524-
#[nexus_test]
525-
async fn test_join_existing_ssm_group_by_id_without_sources_fails(
526-
cptestctx: &ControlPlaneTestContext,
527-
) {
528-
let client = &cptestctx.external_client;
529-
let project_name = "ssm-id-fail-project";
530-
531-
// Setup: SSM pool
532-
let (_, _, _ssm_pool) = ops::join3(
533-
create_project(client, project_name),
534-
create_default_ip_pool(client),
535-
create_multicast_ip_pool_with_range(
536-
client,
537-
"ssm-id-fail-pool",
538-
(232, 40, 0, 1),
539-
(232, 40, 0, 255),
540-
),
541-
)
542-
.await;
543-
544-
create_instance(client, project_name, "ssm-id-inst-1").await;
545-
create_instance(client, project_name, "ssm-id-inst-2").await;
546-
547-
// First instance creates SSM group with sources
548-
let ssm_ip = "232.40.0.100";
549-
let source_ip: IpAddr = "10.40.0.1".parse().unwrap();
550-
let join_url_1 = format!(
551-
"/v1/instances/ssm-id-inst-1/multicast-groups/{ssm_ip}?project={project_name}"
552-
);
553-
554-
let join_body_1 =
555-
InstanceMulticastGroupJoin { source_ips: Some(vec![source_ip]) };
556-
let member_1: MulticastGroupMember =
557-
put_upsert(client, &join_url_1, &join_body_1).await;
558-
559-
let group_id = member_1.multicast_group_id;
560-
561-
// Second instance tries to join by group ID WITHOUT sources - should fail
562-
let join_url_by_id = format!(
563-
"/v1/instances/ssm-id-inst-2/multicast-groups/{group_id}?project={project_name}"
564-
);
565-
566-
let error = NexusRequest::new(
567-
RequestBuilder::new(client, Method::PUT, &join_url_by_id)
568-
.body(Some(&InstanceMulticastGroupJoin {
569-
source_ips: None, // No sources!
570-
}))
571-
.expect_status(Some(StatusCode::BAD_REQUEST)),
572-
)
573-
.authn_as(AuthnMode::PrivilegedUser)
574-
.execute()
575-
.await
576-
.expect("Join by ID without sources should fail for SSM group");
577-
578-
let error_body: dropshot::HttpErrorResponseBody =
579-
error.parsed_body().unwrap();
580-
assert!(
581-
error_body.message.contains("SSM")
582-
|| error_body.message.contains("source"),
583-
"Error should mention SSM or source IPs: {}",
584-
error_body.message
585-
);
586-
587-
let expected_group_name = format!("mcast-{}", ssm_ip.replace('.', "-"));
588-
cleanup_instances(
589-
cptestctx,
590-
client,
591-
project_name,
592-
&["ssm-id-inst-1", "ssm-id-inst-2"],
593-
)
594-
.await;
595-
wait_for_group_deleted(client, &expected_group_name).await;
596-
}
597-
598-
/// Test joining an existing SSM group by NAME without sources should fail.
599-
#[nexus_test]
600-
async fn test_join_existing_ssm_group_by_name_without_sources_fails(
601-
cptestctx: &ControlPlaneTestContext,
602-
) {
603-
let client = &cptestctx.external_client;
604-
let project_name = "ssm-name-fail-project";
605-
606-
// Setup: SSM pool
607-
let (_, _, _ssm_pool) = ops::join3(
608-
create_project(client, project_name),
609-
create_default_ip_pool(client),
610-
create_multicast_ip_pool_with_range(
611-
client,
612-
"ssm-name-fail-pool",
613-
(232, 45, 0, 1),
614-
(232, 45, 0, 100),
615-
),
616-
)
617-
.await;
618-
619-
create_instance(client, project_name, "ssm-name-inst-1").await;
620-
create_instance(client, project_name, "ssm-name-inst-2").await;
621-
622-
// First instance creates SSM group with sources
623-
let ssm_ip = "232.45.0.50";
624-
let join_url = format!(
625-
"/v1/instances/ssm-name-inst-1/multicast-groups/{ssm_ip}?project={project_name}"
626-
);
627-
let join_body = InstanceMulticastGroupJoin {
628-
source_ips: Some(vec!["10.0.0.1".parse().unwrap()]),
629-
};
630-
631-
put_upsert::<_, MulticastGroupMember>(client, &join_url, &join_body).await;
632-
633-
// Get the group's auto-generated name
634-
let expected_group_name = format!("mcast-{}", ssm_ip.replace('.', "-"));
635-
636-
// Second instance tries to join by NAME without sources - should fail
637-
let join_by_name_url = format!(
638-
"/v1/instances/ssm-name-inst-2/multicast-groups/{expected_group_name}?project={project_name}"
639-
);
640-
let join_body_no_sources = InstanceMulticastGroupJoin { source_ips: None };
641-
642-
let error = NexusRequest::new(
643-
RequestBuilder::new(client, Method::PUT, &join_by_name_url)
644-
.body(Some(&join_body_no_sources))
645-
.expect_status(Some(StatusCode::BAD_REQUEST)),
646-
)
647-
.authn_as(AuthnMode::PrivilegedUser)
648-
.execute()
649-
.await
650-
.expect("Join by name without sources should fail for SSM group");
651-
652-
let error_body: dropshot::HttpErrorResponseBody =
653-
error.parsed_body().unwrap();
654-
assert!(
655-
error_body.message.contains("SSM")
656-
|| error_body.message.contains("source"),
657-
"Error should mention SSM or source IPs: {}",
658-
error_body.message
659-
);
660-
661-
cleanup_instances(
662-
cptestctx,
663-
client,
664-
project_name,
665-
&["ssm-name-inst-1", "ssm-name-inst-2"],
666-
)
667-
.await;
668-
wait_for_group_deleted(client, &expected_group_name).await;
669-
}
670-
671525
/// Test that SSM join-by-IP with empty sources array fails.
672526
///
673527
/// `source_ips: Some(vec![])` (empty array) is treated the same as
@@ -725,83 +579,6 @@ async fn test_ssm_with_empty_sources_array_fails(
725579
cleanup_instances(cptestctx, client, project_name, &[instance_name]).await;
726580
}
727581

728-
/// Test joining an existing SSM group by IP without sources fails.
729-
///
730-
/// When an SSM group already exists (created by first instance with sources),
731-
/// a second instance joining by IP should still fail without sources since
732-
/// the group is SSM.
733-
#[nexus_test]
734-
async fn test_join_existing_ssm_group_by_ip_without_sources_fails(
735-
cptestctx: &ControlPlaneTestContext,
736-
) {
737-
let client = &cptestctx.external_client;
738-
let project_name = "ssm-ip-existing-fail-project";
739-
740-
// Setup: SSM pool
741-
let (_, _, _ssm_pool) = ops::join3(
742-
create_project(client, project_name),
743-
create_default_ip_pool(client),
744-
create_multicast_ip_pool_with_range(
745-
client,
746-
"ssm-ip-existing-fail-pool",
747-
(232, 47, 0, 1),
748-
(232, 47, 0, 100),
749-
),
750-
)
751-
.await;
752-
753-
create_instance(client, project_name, "ssm-ip-inst-1").await;
754-
create_instance(client, project_name, "ssm-ip-inst-2").await;
755-
756-
// First instance creates SSM group with sources
757-
let ssm_ip = "232.47.0.50";
758-
let join_url = format!(
759-
"/v1/instances/ssm-ip-inst-1/multicast-groups/{ssm_ip}?project={project_name}"
760-
);
761-
let join_body = InstanceMulticastGroupJoin {
762-
source_ips: Some(vec!["10.0.0.1".parse().unwrap()]),
763-
};
764-
765-
put_upsert::<_, MulticastGroupMember>(client, &join_url, &join_body).await;
766-
767-
let expected_group_name = format!("mcast-{}", ssm_ip.replace('.', "-"));
768-
769-
// Second instance tries to join by IP without sources - should fail
770-
// Even though the group exists, SSM still requires sources
771-
let join_url_2 = format!(
772-
"/v1/instances/ssm-ip-inst-2/multicast-groups/{ssm_ip}?project={project_name}"
773-
);
774-
let join_body_no_sources = InstanceMulticastGroupJoin { source_ips: None };
775-
776-
let error = NexusRequest::new(
777-
RequestBuilder::new(client, Method::PUT, &join_url_2)
778-
.body(Some(&join_body_no_sources))
779-
.expect_status(Some(StatusCode::BAD_REQUEST)),
780-
)
781-
.authn_as(AuthnMode::PrivilegedUser)
782-
.execute()
783-
.await
784-
.expect("Join existing SSM group by IP without sources should fail");
785-
786-
let error_body: dropshot::HttpErrorResponseBody =
787-
error.parsed_body().unwrap();
788-
assert!(
789-
error_body.message.contains("SSM")
790-
|| error_body.message.contains("source"),
791-
"Error should mention SSM or source IPs: {}",
792-
error_body.message
793-
);
794-
795-
cleanup_instances(
796-
cptestctx,
797-
client,
798-
project_name,
799-
&["ssm-ip-inst-1", "ssm-ip-inst-2"],
800-
)
801-
.await;
802-
wait_for_group_deleted(client, &expected_group_name).await;
803-
}
804-
805582
/// Test join-by-IP with IP not in any pool should fail.
806583
#[nexus_test]
807584
async fn test_join_by_ip_not_in_pool_fails(

nexus/tests/integration_tests/multicast/cache_invalidation.rs

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
//! - Cache TTL refresh: Verifies caches are refreshed when TTL expires
1212
//! - Backplane cache expiry: Tests that stale backplane mappings are cleaned up
1313
14+
use std::time::Duration;
15+
1416
use gateway_client::types::{PowerState, RotState, SpState};
1517
use nexus_db_queries::context::OpContext;
1618
use nexus_test_utils::resource_helpers::{
@@ -57,7 +59,7 @@ async fn test_sled_move_updates_multicast_port_mapping(
5759
)
5860
.await;
5961

60-
// Create instance (no multicast groups at creation - implicit model)
62+
// Create instance (no multicast groups at creation, implicit model)
6163
let instance = instance_for_multicast_groups(
6264
cptestctx,
6365
PROJECT_NAME,
@@ -279,20 +281,25 @@ async fn test_cache_ttl_driven_refresh() {
279281
const GROUP_NAME: &str = "ttl-test-group";
280282
const INSTANCE_NAME: &str = "ttl-test-instance";
281283

284+
// Test cache TTL values
285+
const SLED_CACHE_TTL: Duration = Duration::from_millis(500);
286+
const BACKPLANE_CACHE_TTL: Duration = Duration::from_millis(250);
287+
// Buffer to ensure TTL has definitely expired
288+
const TTL_EXPIRY_BUFFER: Duration = Duration::from_millis(100);
289+
282290
// Start test server with custom config
283291
let cptestctx = nexus_test_utils::ControlPlaneBuilder::new(
284292
"test_cache_ttl_driven_refresh",
285293
)
286294
.customize_nexus_config(&|config| {
287-
// Set short cache TTLs for testing (2 seconds for sled cache)
295+
// Set short cache TTLs for testing
288296
config.pkg.background_tasks.multicast_reconciler.sled_cache_ttl_secs =
289-
chrono::TimeDelta::seconds(2).to_std().unwrap();
297+
SLED_CACHE_TTL;
290298
config
291299
.pkg
292300
.background_tasks
293301
.multicast_reconciler
294-
.backplane_cache_ttl_secs =
295-
chrono::TimeDelta::seconds(1).to_std().unwrap();
302+
.backplane_cache_ttl_secs = BACKPLANE_CACHE_TTL;
296303

297304
// Ensure multicast is enabled
298305
config.pkg.multicast.enabled = true;
@@ -318,7 +325,7 @@ async fn test_cache_ttl_driven_refresh() {
318325
)
319326
.await;
320327

321-
// Create instance (no multicast groups at creation - implicit model)
328+
// Create instance (no multicast groups at creation, implicit model)
322329
let instance = instance_for_multicast_groups(
323330
&cptestctx,
324331
PROJECT_NAME,
@@ -435,9 +442,8 @@ async fn test_cache_ttl_driven_refresh() {
435442
.await
436443
.expect("Should insert new inventory collection");
437444

438-
// Wait for cache TTL to expire (sled_cache_ttl = 1 second)
439-
// Sleep for 1.5 seconds to ensure TTL has expired
440-
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
445+
// Wait for cache TTL to expire
446+
tokio::time::sleep(SLED_CACHE_TTL + TTL_EXPIRY_BUFFER).await;
441447

442448
wait_for_condition_with_reconciler(
443449
&cptestctx.lockstep_client,
@@ -458,7 +464,7 @@ async fn test_cache_ttl_driven_refresh() {
458464
}
459465
},
460466
&POLL_INTERVAL,
461-
&MULTICAST_OPERATION_TIMEOUT,
467+
&MEDIUM_TIMEOUT, // DPD update requires reconciler processing
462468
)
463469
.await
464470
.expect("DPD should update with new rear port after TTL expiry");
@@ -483,23 +489,26 @@ async fn test_backplane_cache_ttl_expiry() {
483489
const GROUP_NAME: &str = "backplane-ttl-group";
484490
const INSTANCE_NAME: &str = "backplane-ttl-instance";
485491

492+
// Cache TTL values: backplane shorter than sled to test them independently
493+
const BACKPLANE_CACHE_TTL: Duration = Duration::from_millis(250);
494+
const SLED_CACHE_TTL: Duration = Duration::from_secs(2);
495+
// Buffer to ensure TTL has definitely expired (20% margin)
496+
const TTL_EXPIRY_BUFFER: Duration = Duration::from_millis(50);
497+
486498
let cptestctx = nexus_test_utils::ControlPlaneBuilder::new(
487499
"test_backplane_cache_ttl_expiry",
488500
)
489501
.customize_nexus_config(&|config| {
490-
// Set backplane cache TTL to 1 second (shorter than sled cache to test
491-
// independently)
502+
// Set backplane cache TTL shorter than sled cache to test independently
492503
config
493504
.pkg
494505
.background_tasks
495506
.multicast_reconciler
496-
.backplane_cache_ttl_secs =
497-
chrono::TimeDelta::seconds(1).to_std().unwrap();
507+
.backplane_cache_ttl_secs = BACKPLANE_CACHE_TTL;
498508

499-
// Keep sled cache TTL longer to ensure we're testing backplane cache
500-
// expiry
509+
// Keep sled cache TTL longer to ensure we're testing backplane cache expiry
501510
config.pkg.background_tasks.multicast_reconciler.sled_cache_ttl_secs =
502-
chrono::TimeDelta::seconds(10).to_std().unwrap();
511+
SLED_CACHE_TTL;
503512

504513
// Ensure multicast is enabled
505514
config.pkg.multicast.enabled = true;
@@ -519,7 +528,7 @@ async fn test_backplane_cache_ttl_expiry() {
519528
)
520529
.await;
521530

522-
// Create instance (no multicast groups at creation - implicit model)
531+
// Create instance (no multicast groups at creation, implicit model)
523532
let instance = instance_for_multicast_groups(
524533
&cptestctx,
525534
PROJECT_NAME,
@@ -550,9 +559,8 @@ async fn test_backplane_cache_ttl_expiry() {
550559
.await
551560
.expect("Should verify initial port mapping");
552561

553-
// Wait for backplane cache TTL to expire (500ms) but not sled cache (5 seconds)
554-
// Sleep for 1 second to ensure backplane TTL has expired
555-
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
562+
// Wait for backplane cache TTL to expire but not sled cache
563+
tokio::time::sleep(BACKPLANE_CACHE_TTL + TTL_EXPIRY_BUFFER).await;
556564

557565
// Force cache access by triggering reconciler
558566
// This will cause the reconciler to check backplane cache, find it expired,

0 commit comments

Comments
 (0)