use std::time::Duration;
use tokio::time::Instant;
use super::RequestControlView;
use crate::contract::TransportContractViolation;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DeferredExpiryMargins {
recovery: Duration,
write: Duration,
}
impl DeferredExpiryMargins {
pub(super) const fn validate(self) -> Result<(), TransportContractViolation> {
if self.recovery.is_zero() {
return Err(TransportContractViolation::DeferredExpiryZeroRecoveryMargin);
}
if self.write.is_zero() {
return Err(TransportContractViolation::DeferredExpiryZeroWriteMargin);
}
Ok(())
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[must_use]
pub enum DeferredExpiryOutcome {
Attached,
AlreadyAttached,
OwnerBudgetInsufficient,
ProtocolAlreadyExpired,
OwnerAlreadyExpired,
}
impl DeferredExpiryMargins {
#[must_use]
pub const fn new(recovery: Duration, write: Duration) -> Self {
Self { recovery, write }
}
#[must_use]
pub const fn recovery(self) -> Duration {
self.recovery
}
#[must_use]
pub const fn write(self) -> Duration {
self.write
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum DeferredExpiryKind {
LongPollTimeout,
OwnerDeadline,
}
impl DeferredExpiryKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::LongPollTimeout => "long_poll_timeout",
Self::OwnerDeadline => "owner_deadline",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DeferredExpiry {
protocol_at: Instant,
resume_cutoff: Option<Instant>,
write_cutoff: Option<Instant>,
}
impl DeferredExpiry {
pub(super) fn try_from_control(
control: &RequestControlView,
protocol_at: Instant,
margins: DeferredExpiryMargins,
) -> Result<Self, ExpiryRejection> {
let now = Instant::now();
if protocol_at <= now {
return Err(ExpiryRejection::ProtocolAlreadyExpired);
}
let Some(owner_at) = control.deadline().map(|deadline| deadline.instant()) else {
return Ok(Self {
protocol_at,
resume_cutoff: None,
write_cutoff: None,
});
};
if owner_at <= now {
return Err(ExpiryRejection::OwnerAlreadyExpired);
}
let write_cutoff = owner_at
.checked_sub(margins.write)
.ok_or(ExpiryRejection::OwnerBudgetInsufficient)?;
let resume_cutoff = write_cutoff
.checked_sub(margins.recovery)
.ok_or(ExpiryRejection::OwnerBudgetInsufficient)?;
if write_cutoff <= now || resume_cutoff <= now {
return Err(ExpiryRejection::OwnerBudgetInsufficient);
}
Ok(Self {
protocol_at,
resume_cutoff: Some(resume_cutoff),
write_cutoff: Some(write_cutoff),
})
}
#[must_use]
pub const fn protocol_at(self) -> Instant {
self.protocol_at
}
#[must_use]
pub const fn resume_cutoff(self) -> Option<Instant> {
self.resume_cutoff
}
#[must_use]
pub const fn write_cutoff(self) -> Option<Instant> {
self.write_cutoff
}
#[must_use]
pub fn kind(self) -> DeferredExpiryKind {
match self.resume_cutoff {
Some(owner_at) if owner_at <= self.protocol_at => DeferredExpiryKind::OwnerDeadline,
Some(_) | None => DeferredExpiryKind::LongPollTimeout,
}
}
#[must_use]
pub fn next_at(self) -> Instant {
match self.resume_cutoff {
Some(owner_at) if owner_at <= self.protocol_at => owner_at,
Some(_) | None => self.protocol_at,
}
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(super) enum ExpiryRejection {
OwnerBudgetInsufficient,
ProtocolAlreadyExpired,
OwnerAlreadyExpired,
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use rocketmq_runtime::RuntimeConfig;
use rocketmq_runtime::RuntimeOwner;
use super::*;
use crate::deadline::RequestDeadline;
use crate::dispatch::RequestMeta;
use crate::session_view::EmbeddedSessionRecord;
fn control(deadline: Option<RequestDeadline>) -> (RuntimeOwner, RequestControlView) {
let runtime = RuntimeOwner::plan(RuntimeConfig::server_default("deferred-expiry-policy"))
.expect("runtime configuration is valid")
.build()
.expect("expiry test runtime");
let session = Arc::new(EmbeddedSessionRecord::new(9819));
let parent = runtime
.root_context()
.component("deferred-expiry-policy")
.task_group()
.clone();
let view = RequestControlView::from_meta(
&RequestMeta::new(std::time::Instant::now(), deadline),
session.view().state().clone(),
&parent,
);
(runtime, view)
}
#[tokio::test(start_paused = true)]
async fn owner_cutoffs_are_checked_and_protocol_is_capped() {
let now = Instant::now();
let (_runtime, control) = control(Some(RequestDeadline::after(Duration::from_secs(30))));
let expiry = DeferredExpiry::try_from_control(
&control,
now + Duration::from_secs(29),
DeferredExpiryMargins::new(Duration::from_secs(5), Duration::from_secs(3)),
)
.expect("valid owner margins");
assert_eq!(expiry.write_cutoff(), Some(now + Duration::from_secs(27)));
assert_eq!(expiry.resume_cutoff(), Some(now + Duration::from_secs(22)));
assert_eq!(expiry.protocol_at(), now + Duration::from_secs(29));
assert_eq!(expiry.next_at(), now + Duration::from_secs(22));
assert_eq!(expiry.kind(), DeferredExpiryKind::OwnerDeadline);
}
#[tokio::test(start_paused = true)]
async fn notification_protocol_deadline_owner_early_equal_late_matrix_is_owner_safe() {
let now = Instant::now();
let (_runtime, control) = control(Some(RequestDeadline::after(Duration::from_secs(10))));
let margins = DeferredExpiryMargins::new(Duration::from_secs(3), Duration::from_secs(2));
let cases = [
(
now + Duration::from_secs(4),
DeferredExpiryKind::LongPollTimeout,
now + Duration::from_secs(4),
),
(
now + Duration::from_secs(5),
DeferredExpiryKind::OwnerDeadline,
now + Duration::from_secs(5),
),
(
now + Duration::from_secs(6),
DeferredExpiryKind::OwnerDeadline,
now + Duration::from_secs(5),
),
];
for (protocol_at, expected_kind, expected_next) in cases {
let expiry = DeferredExpiry::try_from_control(&control, protocol_at, margins)
.expect("Notification owner/protocol boundary remains live");
assert_eq!(expiry.resume_cutoff(), Some(now + Duration::from_secs(5)));
assert_eq!(expiry.kind(), expected_kind);
assert_eq!(expiry.next_at(), expected_next);
}
tokio::time::advance(Duration::from_secs(5)).await;
assert_eq!(Instant::now(), now + Duration::from_secs(5));
}
#[test]
fn margins_are_non_zero_even_without_an_owner_deadline() {
let (_runtime, control) = control(None);
let protocol_at = Instant::now() + Duration::from_secs(1);
assert_eq!(
DeferredExpiryMargins::new(Duration::ZERO, Duration::from_millis(1)).validate(),
Err(TransportContractViolation::DeferredExpiryZeroRecoveryMargin)
);
assert_eq!(
DeferredExpiryMargins::new(Duration::from_millis(1), Duration::ZERO).validate(),
Err(TransportContractViolation::DeferredExpiryZeroWriteMargin)
);
assert!(DeferredExpiry::try_from_control(
&control,
protocol_at,
DeferredExpiryMargins::new(Duration::from_millis(1), Duration::from_millis(1)),
)
.is_ok());
}
}
#[cfg(test)]
#[path = "../../tests/unit/dispatch/deferred_expiry.rs"]
mod acceptance_tests;