Fix autonomous ME recovery from Direct fallback

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-10-02 23:51:14 +03:00
parent fca6506449
commit d65efcc479
16 changed files with 1423 additions and 46 deletions
+24 -2
View File
@@ -1,5 +1,10 @@
use super::*;
// Outage regressions use loopback sockets and explicit family-state snapshots.
#[cfg(test)]
mod tests;
/// Reports whether replacing an endpoint could disrupt a bound client.
pub(super) fn has_bound_clients_on_endpoint(
writer_ids: &[u64],
bound_clients_by_writer: &HashMap<u64, usize>,
@@ -9,6 +14,7 @@ pub(super) fn has_bound_clients_on_endpoint(
.any(|writer_id| bound_clients_by_writer.get(writer_id).copied().unwrap_or(0) > 0)
}
/// Restores missing single-endpoint coverage under the bounded outage retry policy.
pub(super) async fn recover_single_endpoint_outage(
pool: &Arc<MePool>,
rng: &Arc<SecureRandom>,
@@ -53,13 +59,21 @@ pub(super) async fn recover_single_endpoint_outage(
pool.stats
.increment_me_single_endpoint_outage_reconnect_attempt_total();
let generation = pool.current_generation();
let bypass_quarantine = pool.single_endpoint_outage_disable_quarantine();
let attempt_ok = if bypass_quarantine {
pool.stats
.increment_me_single_endpoint_quarantine_bypass_total();
match tokio::time::timeout(
pool.reconnect_runtime.me_one_timeout,
pool.connect_one_for_dc(endpoint, key.0, rng.as_ref()),
pool.connect_one_with_generation_contour_for_dc_with_intent(
endpoint,
rng.as_ref(),
generation,
WriterContour::Active,
key.0,
WriterOpenIntent::Coverage,
),
)
.await
{
@@ -88,7 +102,14 @@ pub(super) async fn recover_single_endpoint_outage(
let one_endpoint = [endpoint];
match tokio::time::timeout(
pool.reconnect_runtime.me_one_timeout,
pool.connect_endpoints_round_robin(key.0, &one_endpoint, rng.as_ref()),
pool.connect_endpoints_round_robin_with_generation_contour(
key.0,
&one_endpoint,
rng.as_ref(),
generation,
WriterContour::Active,
WriterOpenIntent::Coverage,
),
)
.await
{
@@ -142,6 +163,7 @@ pub(super) async fn recover_single_endpoint_outage(
);
}
/// Rotates an idle single-endpoint shadow without removing the existing coverage first.
pub(super) async fn maybe_rotate_single_endpoint_shadow(
pool: &Arc<MePool>,
rng: &Arc<SecureRandom>,
@@ -0,0 +1,148 @@
use std::sync::atomic::Ordering;
use super::*;
use crate::transport::middle_proxy::admission_test_support::unregistered_writer;
use crate::transport::middle_proxy::pool_writer_security_tests::make_pool;
async fn single_endpoint_reaches_tcp_with_a_full_cap(bypass_quarantine: bool) {
let pool = make_pool().await;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = listener.local_addr().unwrap();
let donor = SocketAddr::new(endpoint.ip(), 1);
pool.update_proxy_maps(
HashMap::from([
(2, vec![(endpoint.ip(), endpoint.port())]),
(3, vec![(donor.ip(), donor.port())]),
]),
None,
)
.await;
pool.floor_runtime
.me_adaptive_floor_cpu_cores_override
.store(1, Ordering::Relaxed);
pool.floor_runtime
.me_adaptive_floor_max_active_writers_per_core
.store(1, Ordering::Relaxed);
pool.floor_runtime
.me_adaptive_floor_max_active_writers_global
.store(1, Ordering::Relaxed);
pool.single_endpoint_runtime
.me_single_endpoint_outage_disable_quarantine
.store(bypass_quarantine, Ordering::Relaxed);
let (writer, _receiver) = unregistered_writer(
&pool,
1,
3,
donor,
pool.current_generation(),
WriterContour::Active,
);
pool.writers.write().await.push(writer);
if bypass_quarantine {
pool.endpoint_quarantine
.lock()
.await
.insert(endpoint, Instant::now() + Duration::from_secs(60));
}
let rng = Arc::new(SecureRandom::new());
let key = (2, IpFamily::V4);
let mut backoff = HashMap::new();
let mut next_attempt = HashMap::new();
let semaphore = Arc::new(Semaphore::new(1));
let required = pool.required_writers_for_dc(1);
// TCP acceptance proves capacity admission; closing the peer deliberately rejects the handshake.
tokio::time::timeout(Duration::from_secs(1), async {
tokio::join!(
recover_single_endpoint_outage(
&pool,
&rng,
key,
endpoint,
required,
&mut backoff,
&mut next_attempt,
&semaphore,
),
async {
let (stream, _) = listener.accept().await.unwrap();
drop(stream);
},
);
})
.await
.expect("missing-DC recovery must reach TCP even when another DC fills the cap");
assert_eq!(
pool.stats
.get_me_single_endpoint_outage_reconnect_attempt_total(),
1
);
assert_eq!(
pool.stats
.get_me_single_endpoint_outage_reconnect_success_total(),
0
);
assert_eq!(
pool.stats.get_me_single_endpoint_quarantine_bypass_total(),
u64::from(bypass_quarantine)
);
assert!(next_attempt.contains_key(&key));
assert!(backoff.contains_key(&key));
assert_eq!(semaphore.available_permits(), 1);
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
0
);
}
#[tokio::test]
async fn single_endpoint_quarantine_bypass_recovers_coverage_at_cap() {
single_endpoint_reaches_tcp_with_a_full_cap(true).await;
}
#[tokio::test]
async fn single_endpoint_round_robin_recovers_coverage_at_cap() {
single_endpoint_reaches_tcp_with_a_full_cap(false).await;
}
#[tokio::test]
async fn restored_coverage_preserves_family_suppression_hysteresis() {
let pool = make_pool().await;
let now = MePool::now_epoch_secs();
let until = now + 3600;
pool.set_family_runtime_state(
IpFamily::V4,
MeFamilyRuntimeState::Suppressed,
now,
until,
5,
0,
);
super::super::update_family_runtime_state(&pool, IpFamily::V4, false);
assert_eq!(
pool.family_runtime_state(IpFamily::V4),
MeFamilyRuntimeState::Suppressed
);
assert_eq!(pool.family_suppressed_until_epoch_secs(IpFamily::V4), until);
assert_eq!(pool.family_recover_success_streak(IpFamily::V4), 0);
pool.set_family_runtime_state(
IpFamily::V4,
MeFamilyRuntimeState::Suppressed,
now,
now - 1,
5,
0,
);
super::super::update_family_runtime_state(&pool, IpFamily::V4, false);
assert_eq!(
pool.family_runtime_state(IpFamily::V4),
MeFamilyRuntimeState::Recovering
);
assert_eq!(pool.family_recover_success_streak(IpFamily::V4), 1);
super::super::update_family_runtime_state(&pool, IpFamily::V4, false);
assert_eq!(
pool.family_runtime_state(IpFamily::V4),
MeFamilyRuntimeState::Healthy
);
assert_eq!(pool.family_fail_streak(IpFamily::V4), 0);
}
+5
View File
@@ -49,6 +49,11 @@ mod send;
mod send_adversarial_tests;
mod wire;
/// Network-free writer fixtures shared by admission and recovery regressions.
#[cfg(test)]
#[path = "tests/admission_test_support.rs"]
pub(crate) mod admission_test_support;
use bytes::Bytes;
use tokio::sync::OwnedSemaphorePermit;
@@ -61,13 +61,13 @@ impl Drop for WriterOpenReservation<'_> {
}
impl MePool {
/// Computes the authoritative active-writer floor across enabled families and DCs.
/// Computes reservation coverage across configured families, including suppressed ones.
pub(in crate::transport::middle_proxy) async fn active_coverage_required_total(&self) -> usize {
let now_epoch_secs = Self::now_epoch_secs();
let mut required_total = 0usize;
let endpoint_snapshot = self.endpoint_snapshot.load_full();
if self.family_enabled_for_drain_coverage(IpFamily::V4, now_epoch_secs) {
// Suppression must not remove the capacity needed to recover active coverage.
if self.decision.ipv4_me {
for addrs in endpoint_snapshot.map_v4.values() {
let mut endpoints = HashSet::<SocketAddr>::new();
for (ip, port) in addrs.iter().copied() {
@@ -79,7 +79,7 @@ impl MePool {
}
}
if self.family_enabled_for_drain_coverage(IpFamily::V6, now_epoch_secs) {
if self.decision.ipv6_me {
for addrs in endpoint_snapshot.map_v6.values() {
let mut endpoints = HashSet::<SocketAddr>::new();
for (ip, port) in addrs.iter().copied() {
@@ -33,6 +33,10 @@ mod publication;
mod replacement;
mod runtime;
// Recovery regressions exercise the serialized production publication boundary.
#[cfg(test)]
mod recovery_tests;
struct PreparedWriter<'a> {
writer: MeWriter,
tx: mpsc::Sender<WriterCommand>,
@@ -84,7 +84,17 @@ impl MePool {
crate::network::IpFamily::V6
};
let now_epoch_secs = Self::now_epoch_secs();
if !self.family_enabled_for_drain_coverage(family, now_epoch_secs) {
let configured = match family {
crate::network::IpFamily::V4 => self.decision.ipv4_me,
crate::network::IpFamily::V6 => self.decision.ipv6_me,
};
// Recovery coverage must remain publishable during suppression; the serialized
// per-group floor check below still bounds it and generation authority is unchanged.
let recovery_coverage =
contour == WriterContour::Active && intent == WriterOpenIntent::Coverage;
if !configured
|| (self.is_family_temporarily_suppressed(family, now_epoch_secs) && !recovery_coverage)
{
return Err(ProxyError::Proxy(
"ME writer family lost publication authority".into(),
));
@@ -0,0 +1,466 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use super::*;
use crate::network::{IpFamily, probe::NetworkDecision};
use crate::transport::middle_proxy::admission_test_support::unregistered_writer;
use crate::transport::middle_proxy::pool::MeFamilyRuntimeState;
use crate::transport::middle_proxy::pool_writer_security_tests::{
make_pool, make_pool_with_decision,
};
fn cap_at_one(pool: &MePool) {
pool.floor_runtime
.me_adaptive_floor_cpu_cores_override
.store(1, Ordering::Relaxed);
pool.floor_runtime
.me_adaptive_floor_max_active_writers_per_core
.store(1, Ordering::Relaxed);
pool.floor_runtime
.me_adaptive_floor_max_active_writers_global
.store(1, Ordering::Relaxed);
}
fn suppress(pool: &MePool, family: IpFamily) {
let now = MePool::now_epoch_secs();
pool.set_family_runtime_state(
family,
MeFamilyRuntimeState::Suppressed,
now,
now + 3600,
5,
0,
);
}
fn prepared<'a>(
pool: &'a Arc<MePool>,
writer: MeWriter,
intent: WriterOpenIntent,
reservation: WriterOpenReservation<'a>,
task_started: Arc<AtomicBool>,
) -> PreparedWriter<'a> {
let cancel = writer.cancel.clone();
PreparedWriter {
tx: writer.tx.clone(),
byte_budget: writer.byte_budget.clone(),
writer,
task_registration: pool.lifecycle.try_register().unwrap(),
writer_task: Box::pin(async move {
task_started.store(true, Ordering::Release);
cancel.cancelled().await;
}),
intent,
_open_reservation: reservation,
}
}
#[tokio::test]
async fn suppressed_active_coverage_publishes_only_the_missing_floor() {
let pool = make_pool().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), addr.port())])]), None)
.await;
cap_at_one(&pool);
suppress(&pool, IpFamily::V4);
let required = pool.required_writers_for_dc(1);
let mut receivers = Vec::new();
for id in 1..=required as u64 {
let reservation = pool
.reserve_writer_open(WriterContour::Active, WriterOpenIntent::Coverage, 2, addr)
.await
.expect("missing coverage must have a reservation despite suppression");
let (writer, receiver) = unregistered_writer(
&pool,
id,
2,
addr,
pool.current_generation(),
WriterContour::Active,
);
receivers.push(receiver);
pool.publish_connected_writer(prepared(
&pool,
writer,
WriterOpenIntent::Coverage,
reservation,
Arc::new(AtomicBool::new(false)),
))
.await
.expect("authenticated coverage must publish without waiting for suppression expiry");
assert_eq!(pool.conn_count.load(Ordering::Relaxed), id as usize);
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
0
);
}
assert!(
pool.reserve_writer_open(WriterContour::Active, WriterOpenIntent::Coverage, 2, addr)
.await
.is_none()
);
let (extra, _receiver) = unregistered_writer(
&pool,
99,
2,
addr,
pool.current_generation(),
WriterContour::Active,
);
assert!(
pool.authorize_writer_publication_capacity(
&extra,
WriterContour::Active,
WriterOpenIntent::Coverage,
&pool.writers.read().await,
)
.is_err()
);
assert_eq!(
pool.family_runtime_state(IpFamily::V4),
MeFamilyRuntimeState::Suppressed
);
assert!(pool.shutdown_until(Duration::from_secs(1)).await);
}
#[tokio::test]
async fn concurrent_suppressed_coverage_publications_cannot_overfill_a_group() {
let pool = make_pool().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), addr.port())])]), None)
.await;
cap_at_one(&pool);
suppress(&pool, IpFamily::V4);
let required = pool.required_writers_for_dc(1);
let mut receivers = Vec::new();
let mut publications = Vec::new();
for id in 1..=(required + 1) as u64 {
let reservation = pool
.reserve_writer_open(WriterContour::Active, WriterOpenIntent::Coverage, 2, addr)
.await
.unwrap();
let (writer, receiver) = unregistered_writer(
&pool,
id,
2,
addr,
pool.current_generation(),
WriterContour::Active,
);
receivers.push(receiver);
publications.push(pool.publish_connected_writer(prepared(
&pool,
writer,
WriterOpenIntent::Coverage,
reservation,
Arc::new(AtomicBool::new(false)),
)));
}
let guard = pool.writers.write().await;
let results = futures::future::join_all(publications);
tokio::pin!(results);
tokio::select! {
biased;
results = &mut results => panic!("publication bypassed the writer barrier: {results:?}"),
_ = tokio::task::yield_now() => {}
}
drop(guard);
let results = results.await;
assert_eq!(
results.iter().filter(|result| result.is_ok()).count(),
required
);
assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
assert_eq!(pool.writers.read().await.len(), required);
assert_eq!(pool.conn_count.load(Ordering::Relaxed), required);
assert_eq!(
pool.registry.writer_idle_since_snapshot().await.len(),
required
);
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
0
);
assert!(pool.shutdown_until(Duration::from_secs(1)).await);
}
#[tokio::test]
async fn committed_warm_writer_uses_resolved_active_coverage_authority() {
let pool = make_pool().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), addr.port())])]), None)
.await;
suppress(&pool, IpFamily::V4);
let reservation = pool
.reserve_writer_open(WriterContour::Warm, WriterOpenIntent::Coverage, 2, addr)
.await
.unwrap();
let (writer, _receiver) = unregistered_writer(
&pool,
1,
2,
addr,
pool.current_generation(),
WriterContour::Warm,
);
pool.publish_connected_writer(prepared(
&pool,
writer,
WriterOpenIntent::Coverage,
reservation,
Arc::new(AtomicBool::new(false)),
))
.await
.expect("a committed generation must resolve warm coverage to its active role");
assert_eq!(
pool.writers.read().await[0].contour.load(Ordering::Acquire),
WriterContour::Active.as_u8()
);
assert_eq!(pool.writer_connect_warm_reserved.load(Ordering::Acquire), 0);
assert!(pool.shutdown_until(Duration::from_secs(1)).await);
}
#[tokio::test]
async fn suppressed_families_keep_their_full_coverage_reservation_budget() {
let pool = make_pool_with_decision(NetworkDecision {
ipv4_me: true,
ipv6_me: true,
..NetworkDecision::default()
})
.await;
let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
let v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 443);
pool.update_proxy_maps(
HashMap::from([(2, vec![(v4.ip(), v4.port())])]),
Some(HashMap::from([(2, vec![(v6.ip(), v6.port())])])),
)
.await;
cap_at_one(&pool);
suppress(&pool, IpFamily::V4);
suppress(&pool, IpFamily::V6);
let required = pool.required_writers_for_dc(1);
assert_eq!(pool.active_coverage_required_total().await, required * 4);
let mut receivers = Vec::new();
let mut writers = pool.writers.write().await;
for (index, (dc, addr)) in [(2, v4), (-2, v4), (-2, v6)].into_iter().enumerate() {
for offset in 0..required {
let (writer, receiver) = unregistered_writer(
&pool,
(index * required + offset + 1) as u64,
dc,
addr,
pool.current_generation(),
WriterContour::Active,
);
writers.push(writer);
receivers.push(receiver);
}
}
drop(writers);
let reservation = pool
.reserve_writer_open(WriterContour::Active, WriterOpenIntent::Coverage, 2, v6)
.await
.expect("other covered groups must not consume the suppressed family's floor budget");
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
1
);
drop(reservation);
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
0
);
}
#[tokio::test]
async fn coverage_reservations_remain_bounded_and_release_on_drop() {
let pool = make_pool().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), addr.port())])]), None)
.await;
cap_at_one(&pool);
suppress(&pool, IpFamily::V4);
let limit = pool.required_writers_for_dc(1) * 2
+ pool
.reconnect_runtime
.me_reconnect_max_concurrent_per_dc
.max(1) as usize;
let mut reservations = Vec::new();
for _ in 0..limit {
reservations.push(
pool.reserve_writer_open(WriterContour::Active, WriterOpenIntent::Coverage, 2, addr)
.await
.expect("the configured coverage budget must remain available"),
);
}
assert!(
pool.reserve_writer_open(WriterContour::Active, WriterOpenIntent::Coverage, 2, addr,)
.await
.is_none()
);
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
limit
);
drop(reservations);
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
0
);
}
#[tokio::test]
async fn suppression_exception_does_not_admit_growth_replacement_or_pending_warm() {
let pool = make_pool().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), addr.port())])]), None)
.await;
suppress(&pool, IpFamily::V4);
for (contour, intent) in [
(WriterContour::Active, WriterOpenIntent::Normal),
(WriterContour::Active, WriterOpenIntent::Replacement),
(WriterContour::Warm, WriterOpenIntent::Coverage),
(WriterContour::Warm, WriterOpenIntent::Normal),
] {
let (writer, _receiver) =
unregistered_writer(&pool, 1, 2, addr, pool.current_generation(), contour);
assert!(
pool.authorize_writer_publication_capacity(&writer, contour, intent, &[])
.is_err()
);
}
}
#[tokio::test]
async fn coverage_exception_never_enables_a_disabled_family() {
let pool = make_pool().await;
let v4 = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
let v6 = SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 443);
pool.update_proxy_maps(
HashMap::from([(2, vec![(v4.ip(), v4.port())])]),
Some(HashMap::from([(2, vec![(v6.ip(), v6.port())])])),
)
.await;
suppress(&pool, IpFamily::V6);
assert_eq!(
pool.active_coverage_required_total().await,
pool.required_writers_for_dc(1) * 2
);
let (writer, _receiver) = unregistered_writer(
&pool,
1,
2,
v6,
pool.current_generation(),
WriterContour::Active,
);
assert!(
pool.authorize_writer_publication_capacity(
&writer,
WriterContour::Active,
WriterOpenIntent::Coverage,
&[],
)
.is_err()
);
}
#[tokio::test]
async fn suppressed_coverage_cannot_publish_a_stale_generation() {
let pool = make_pool().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), addr.port())])]), None)
.await;
suppress(&pool, IpFamily::V4);
let reservation = pool
.reserve_writer_open(WriterContour::Active, WriterOpenIntent::Coverage, 2, addr)
.await
.unwrap();
let (writer, _receiver) = unregistered_writer(
&pool,
1,
2,
addr,
pool.current_generation().saturating_sub(1),
WriterContour::Active,
);
let task_started = Arc::new(AtomicBool::new(false));
let error = pool
.publish_connected_writer(prepared(
&pool,
writer,
WriterOpenIntent::Coverage,
reservation,
task_started.clone(),
))
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("generation lost publication authority")
);
assert!(pool.writers.read().await.is_empty());
assert!(pool.registry.writer_idle_since_snapshot().await.is_empty());
assert_eq!(pool.conn_count.load(Ordering::Relaxed), 0);
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
0
);
assert!(!task_started.load(Ordering::Acquire));
assert!(pool.shutdown_until(Duration::from_secs(1)).await);
}
#[tokio::test]
async fn suppressed_coverage_rechecks_endpoints_after_publication_waits() {
let pool = make_pool().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), addr.port())])]), None)
.await;
suppress(&pool, IpFamily::V4);
let reservation = pool
.reserve_writer_open(WriterContour::Active, WriterOpenIntent::Coverage, 2, addr)
.await
.unwrap();
let (writer, _receiver) = unregistered_writer(
&pool,
1,
2,
addr,
pool.current_generation(),
WriterContour::Active,
);
let task_started = Arc::new(AtomicBool::new(false));
let publication = pool.publish_connected_writer(prepared(
&pool,
writer,
WriterOpenIntent::Coverage,
reservation,
task_started.clone(),
));
tokio::pin!(publication);
let guard = pool.writers.write().await;
tokio::select! {
biased;
result = &mut publication => panic!("publication bypassed the writer barrier: {result:?}"),
_ = tokio::task::yield_now() => {}
}
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), 444)])]), None)
.await;
drop(guard);
let error = publication.await.unwrap_err();
assert!(
error
.to_string()
.contains("target changed before publication")
);
assert!(pool.writers.read().await.is_empty());
assert!(pool.registry.writer_idle_since_snapshot().await.is_empty());
assert_eq!(pool.conn_count.load(Ordering::Relaxed), 0);
assert_eq!(
pool.writer_connect_active_reserved.load(Ordering::Acquire),
0
);
assert!(!task_started.load(Ordering::Acquire));
assert!(pool.shutdown_until(Duration::from_secs(1)).await);
}
@@ -0,0 +1,98 @@
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
use std::time::Instant;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use super::codec::WriterCommand;
use super::pool::{MePool, MeWriter, WriterContour};
use super::pool_writer_security_tests::make_pool;
/// Owns idle writer visibility without spawning ME transports or client sessions.
pub(crate) struct IdlePoolFixture {
pool: Arc<MePool>,
draining: Vec<Arc<AtomicBool>>,
_receivers: Vec<mpsc::Receiver<WriterCommand>>,
}
impl IdlePoolFixture {
/// Builds both signed DC groups with a controllable readiness quorum.
pub(crate) async fn new(ready: bool) -> Self {
let pool = make_pool().await;
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 443);
pool.update_proxy_maps(HashMap::from([(2, vec![(addr.ip(), addr.port())])]), None)
.await;
let mut draining = Vec::new();
let mut receivers = Vec::new();
{
let mut writers = pool.writers.write().await;
for (id, dc) in [(1, 2), (2, -2)] {
let (writer, receiver) = unregistered_writer(
&pool,
id,
dc,
addr,
pool.current_generation(),
WriterContour::Active,
);
writer.draining.store(!ready, Ordering::Release);
draining.push(writer.draining.clone());
receivers.push(receiver);
writers.push(writer);
}
}
Self {
pool,
draining,
_receivers: receivers,
}
}
/// Returns the pool sampled by the production admission gate.
pub(crate) fn pool(&self) -> Arc<MePool> {
self.pool.clone()
}
/// Changes the fixture quorum without notifying the admission gate.
pub(crate) fn set_ready(&self, ready: bool) {
for draining in &self.draining {
draining.store(!ready, Ordering::Release);
}
}
}
/// Builds a writer whose command receiver remains owned by the test.
pub(super) fn unregistered_writer(
pool: &Arc<MePool>,
id: u64,
dc: i32,
addr: SocketAddr,
generation: u64,
contour: WriterContour,
) -> (MeWriter, mpsc::Receiver<WriterCommand>) {
let (tx, rx) = mpsc::channel(8);
(
MeWriter {
id,
addr,
source_ip: addr.ip(),
writer_dc: dc,
generation,
contour: Arc::new(AtomicU8::new(contour.as_u8())),
created_at: Instant::now(),
tx,
byte_budget: pool.new_writer_byte_budget(),
cancel: CancellationToken::new(),
degraded: Arc::new(AtomicBool::new(false)),
rtt_ema_ms_x10: Arc::new(AtomicU32::new(0)),
draining: Arc::new(AtomicBool::new(false)),
draining_started_at_epoch_secs: Arc::new(AtomicU64::new(0)),
drain_deadline_epoch_secs: Arc::new(AtomicU64::new(0)),
allow_drain_fallback: Arc::new(AtomicBool::new(false)),
},
rx,
)
}