blob: 82e12426c707295fefd674828562f96f45a1526d [file]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! The wire failure channels and the one send exit for host-built frames.
//!
//! Every frame the dispatch host builds, success or rejection, leaves through
//! [`send_host_frame`], so the send-failure log has one shape. Which channel a
//! failure rides is a wire contract with the SDK:
//!
//! | channel | carrier | when |
//! |---|---|---|
//! | [`FrameChannel::TypedDeny`] | Reply, nonzero status + empty body, or a result-framed rejection body | rejections that must unblock the SDK's lockstep request slot: checksum, authz, pre-consensus rewrite, unknown or unsupported non-replicated code, unbound non-PING read, transient replay hints |
//! | [`FrameChannel::Eviction`] | session-terminal Eviction frame with a typed reason | the client must register again: `NoSession`, `MalformedLogin`, heartbeat and login evictions. The reason rides the channel label, since one `context` covers four of them |
//! | [`FrameChannel::ResyncSentinel`] | status-0 poll reply, body carries `RESYNC_REQUIRED_PARTITION_SENTINEL` | a fenced consumer-group poll: the consumer must re-sync its assignment; HTTP mirrors it as `resync_required_polled_messages` in `crate::http::wire` |
//! | [`FrameChannel::EmptyFrame`] | status 0 with the 16-byte empty poll | fallback for an unexpected owner reply or a poll encoding failure; this does not prove the partition is empty. Missing owner replies and explicit owner rejections use `TypedDeny` |
//! | [`FrameChannel::Reply`] | status-0 success frame | host-built success replies: login/register, ping, logout, non-replicated read bodies, committed metadata replies |
//! | silent drop | no frame | one deliberate case, a transient consensus submit failure: the SDK read-timeout replays the same request id, and a synthesized failure could contradict a write that commits moments later. A header `RequestHeader::validate` rejected also drops, but that one is a GAP, not a contract - the fields decode, so a deny could be echoed under the transport id, and the client instead waits out its read timeout |
//! | HTTP status | HTTP status code | the HTTP spine maps the same rejections in `crate::http::error`; it never rides these frames |
//!
//! The last two send nothing, so [`FrameChannel`] has no variant for them.
//!
//! Scope: the table covers `crate::dispatch` only. Two neighbours answer on
//! their own paths by design - the partitions engine builds and sends
//! produce/poll replies, and the shard crate builds client-shaped denies of
//! its own (`IggyShard::deny_partition_request_transient` and
//! `stage_transient_deny`, both `TypedDeny`-shaped, the latter shedding the
//! frame outright when its lifecycle queue is full).
use crate::reply_frame::{build_deny_reply, current_metadata_commit};
use crate::responses::NonReplicatedResponse;
use crate::rewrite::RewriteStage;
use crate::shell::{ShellBus, ShellShard};
use bytes::Bytes;
use consensus::{
EvictionContext, MetadataHandle, build_eviction_message,
build_incompatible_protocol_eviction_message, build_result_rejection_reply,
};
use iggy_binary_protocol::{EvictionReason, PrepareHeader, RoutedRequestHeader};
use iggy_common::IggyError;
use journal::superblock::SuperblockStore;
use journal::{Journal, JournalHandle};
use message_bus::BusMessage;
use server_common::Message;
use std::rc::Rc;
use tracing::warn;
/// Labels the channel a host-built frame rides, for the send-failure log.
/// The taxonomy, including the two channels that never construct a frame,
/// is on the module doc.
#[derive(Clone, Copy, Debug)]
pub(in crate::dispatch) enum FrameChannel {
TypedDeny,
/// The reason travels with the channel: five call sites share the
/// `"login_rejection"` context across four distinct reasons, so
/// `context` alone cannot tell a `MalformedLogin` send failure from an
/// `InvalidCredentials` one.
Eviction(EvictionReason),
ResyncSentinel,
EmptyFrame,
Reply,
}
impl std::fmt::Display for FrameChannel {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TypedDeny => formatter.write_str("typed_deny"),
Self::Eviction(reason) => write!(formatter, "eviction({reason:?})"),
Self::ResyncSentinel => formatter.write_str("resync_sentinel"),
Self::EmptyFrame => formatter.write_str("empty_frame"),
Self::Reply => formatter.write_str("reply"),
}
}
}
/// The one send exit for host-built client frames. Best-effort: a failed
/// send means the connection is gone (or its queue is full), and there is
/// nothing left to reply on, so the error is logged and dropped. `frame` is
/// any bus message: a contiguous frozen frame or the vectored poll reply.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn send_host_frame<B: ShellBus>(
bus: &B,
transport_client_id: u128,
frame: impl Into<BusMessage>,
channel: FrameChannel,
context: &'static str,
) {
if let Err(send_error) = bus.send_to_client(transport_client_id, frame).await {
warn!(
transport_client_id,
error = %send_error,
channel = %channel,
context,
"failed to send host frame to client"
);
}
}
/// Reply to a request rejected before it reached its plane with the request's
/// own frame: empty body + nonzero `status`. The nonzero status is the whole
/// point: the SDK peeks it and surfaces the typed error, whereas a status-0
/// frame reads as a committed ack for work that never happened. Silence is no
/// better, the connection decodes replies in lockstep and would wedge on every
/// later request.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn send_deny_reply<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RoutedRequestHeader,
status: u32,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = build_deny_reply(request_header, transport_client_id, 0, commit, status);
send_host_frame(
&shard.bus,
transport_client_id,
reply.into_generic().into_frozen(),
FrameChannel::TypedDeny,
"request_denial",
)
.await;
}
/// Deny a request from an unbound transport without disclosing the metadata
/// commit frontier. The status is the only field a pre-authenticated caller
/// needs, while the live commit would expose cluster write activity.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn send_unbound_deny_reply<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RoutedRequestHeader,
status: u32,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let reply = build_deny_reply(request_header, transport_client_id, 0, 0, status);
send_host_frame(
&shard.bus,
transport_client_id,
reply.into_generic().into_frozen(),
FrameChannel::TypedDeny,
"unbound_request_denial",
)
.await;
}
/// Reply to a denied non-replicated read with the request's reply frame: empty
/// body + nonzero `status`. The SDK peeks the status before body decode and
/// surfaces the typed error, so a poll denial never reaches the empty-poll
/// "0 messages" body path.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn send_non_replicated_deny<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: &Message<RoutedRequestHeader>,
transport_client_id: u128,
status: u32,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = build_deny_reply(
request.header(),
request.header().client,
request.header().session,
commit,
status,
);
send_host_frame(
&shard.bus,
transport_client_id,
reply.into_generic().into_frozen(),
FrameChannel::TypedDeny,
"non_replicated_denial",
)
.await;
}
/// Reject a request before it reaches consensus: warn, then send the typed
/// deny reply. A silent drop would wedge every later request on the
/// connection until the socket read timeout. `stage` names the chain step
/// for both log lines, so the set stays enumerable.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn send_pre_consensus_deny<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RoutedRequestHeader,
error: &IggyError,
stage: RewriteStage,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let context = stage.as_str();
warn!(
transport_client_id,
error = %error,
operation = ?request_header.operation,
context,
"denying request pre-consensus"
);
let commit = current_metadata_commit(shard);
let reply = build_deny_reply(
request_header,
transport_client_id,
0,
commit,
error.as_code(),
);
send_host_frame(
&shard.bus,
transport_client_id,
reply.into_generic().into_frozen(),
FrameChannel::TypedDeny,
context,
)
.await;
}
/// Result-framed rejection Reply: status 0, body `[count=1][index][code]`.
/// The SDK decodes the nonzero result code, so a transient code makes it
/// replay the same request at once instead of waiting out its read timeout.
/// Replying empty instead would surface as a hard `InvalidFormat` decode
/// failure and break the replay.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn send_result_rejection<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
request_header: &RoutedRequestHeader,
error: &IggyError,
context: &'static str,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = build_result_rejection_reply(request_header, commit, error.as_code());
send_host_frame(
&shard.bus,
transport_client_id,
reply.into_generic().into_frozen(),
FrameChannel::TypedDeny,
context,
)
.await;
}
/// Best-effort session-terminal `Eviction` frame: the client's session is
/// gone (or was never granted), so it must register again. Every frame
/// transport decodes `Command::Eviction` and maps the typed reason
/// (`NoSession` -> `Unauthenticated`, ...), so clients fail fast with the
/// real cause instead of a body-decode failure or a timeout. Consensus
/// context (cluster/view/replica) is stamped on the metadata shard and
/// zeroed elsewhere; the SDK only reads the reason, plus the protocol
/// window on `IncompatibleProtocol`.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn send_eviction<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
transport_client_id: u128,
vsr_client_id: u128,
reason: EvictionReason,
context: &'static str,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let ctx = shard.plane.metadata().consensus.as_ref().map_or(
EvictionContext {
cluster: 0,
view: 0,
replica: 0,
},
EvictionContext::from_consensus,
);
let eviction = match reason {
EvictionReason::IncompatibleProtocol => {
build_incompatible_protocol_eviction_message(ctx, vsr_client_id)
}
_ => build_eviction_message(ctx, vsr_client_id, reason),
};
send_host_frame(
&shard.bus,
transport_client_id,
eviction.into_generic().into_frozen(),
FrameChannel::Eviction(reason),
context,
)
.await;
}
/// Send a non-replicated reply body to a client, stamping the current
/// metadata commit. Shared by the non-replicated read arms; `channel`
/// labels the body shape (a real answer, a fail-fast empty poll, or the
/// re-sync sentinel) for the send-failure log.
#[allow(clippy::future_not_send)]
pub(in crate::dispatch) async fn send_non_replicated_bytes<B, MJ, S, SB>(
shard: &Rc<ShellShard<B, MJ, S, SB>>,
request: &Message<RoutedRequestHeader>,
transport_client_id: u128,
bytes: Bytes,
channel: FrameChannel,
context: &'static str,
) where
B: ShellBus,
MJ: JournalHandle + 'static,
MJ::Target: Journal<Entry = Message<PrepareHeader>, Header = PrepareHeader>,
S: 'static,
SB: SuperblockStore + 'static,
{
let commit = current_metadata_commit(shard);
let reply = NonReplicatedResponse::Bytes(bytes).into_reply(
request.header(),
request.header().client,
request.header().session,
commit,
);
send_host_frame(
&shard.bus,
transport_client_id,
reply.into_generic().into_frozen(),
channel,
context,
)
.await;
}
// Shape tests for the host frame exits: each pins the contract a client
// decodes (status, body shape, identity, commit stamp), never the raw bytes.
#[cfg(test)]
mod tests {
use super::*;
use crate::dispatch::handle_client_request;
use crate::dispatch::test_support::{
FIRST_BOOT, SpyBus, TestShard, request_message, test_shard,
};
use crate::session_manager::SessionManager;
use configs::server::ServerConfig;
use consensus::{LocalPipeline, VsrConsensus};
use iggy_binary_protocol::codes::PING_CODE;
use iggy_binary_protocol::consensus::REJECTION_SECTION_LEN;
use iggy_binary_protocol::{
Command, ConsensusHeader, EvictionHeader, IGGY_PROTOCOL_VERSION, IGGY_PROTOCOL_VERSION_MIN,
Operation, ReplyHeader, result_code,
};
use iggy_common::RESYNC_REQUIRED_PARTITION_SENTINEL;
use std::cell::RefCell;
use std::mem::size_of;
use std::sync::Arc;
const TRANSPORT: u128 = 42;
const VSR_CLIENT: u128 = 7;
const SESSION: u64 = 3;
const REQUEST: u64 = 5;
/// Nonzero, so a stamped frontier is distinguishable from a withheld one.
const COMMIT: u64 = 11;
const MAX_TOKENS_PER_USER: u32 = 1;
/// `[partition_id:4][current_offset:8][count:4]`.
const EMPTY_POLL_BODY_LEN: usize = 16;
/// Send-failure log label only; no exit reads it.
const CONTEXT: &str = "shape_test";
/// Consensus context the eviction shard stamps, spelled out so the
/// assertion does not read it back from the object under test. Replica is
/// 2 rather than 1 so all three differ: equal values would hide a builder
/// that stamps the cluster into the replica field, or the zeroed fallback.
const EVICTION_CLUSTER: u128 = 1;
const EVICTION_VIEW: u32 = 0;
const EVICTION_REPLICA: u8 = 2;
/// A shard whose metadata commit frontier sits at `COMMIT`.
fn shard_at_commit() -> (SpyBus, Rc<TestShard>) {
let bus = SpyBus::default();
let shard = Rc::new(test_shard(&bus, 0, 1, FIRST_BOOT));
metadata_consensus(&shard).advance_commit_max(COMMIT);
(bus, shard)
}
fn metadata_consensus(shard: &TestShard) -> &VsrConsensus<SpyBus, LocalPipeline> {
shard
.plane
.metadata()
.consensus
.as_ref()
.expect("test shard carries metadata consensus")
}
/// The one frame on the bus, decoded as `H` and passed through its own
/// header validation. Every host frame targets the transport connection
/// and declares its own length.
fn sole_frame_to_transport<H: ConsensusHeader>(bus: &SpyBus) -> (H, Vec<u8>) {
let replies = bus.client_replies.borrow();
assert_eq!(replies.len(), 1, "expected exactly one client-bound frame");
let (target, frame) = &replies[0];
assert_eq!(
*target, TRANSPORT,
"the frame must go to the transport connection"
);
let header_bytes = frame
.get(..size_of::<H>())
.expect("frame holds a full header");
let header = bytemuck::checked::try_pod_read_unaligned::<H>(header_bytes)
.expect("client frame decodes into the expected header");
header
.validate()
.expect("host frame passes its own header validation");
assert_eq!(
header.size() as usize,
frame.len(),
"size must cover the whole frame"
);
(header, frame[size_of::<H>()..].to_vec())
}
fn poll_request() -> Message<RoutedRequestHeader> {
request_message(Operation::NonReplicated, VSR_CLIENT, SESSION, REQUEST, &[])
}
/// The echo every request-correlated Reply carries.
fn assert_echoes_request(header: &ReplyHeader, request: &RoutedRequestHeader) {
assert_eq!(header.command, Command::Reply, "must ride a Reply frame");
assert_eq!(header.request, request.request, "must echo the request id");
assert_eq!(
header.operation, request.operation,
"must echo the request operation"
);
}
/// The typed deny shape: the request's own Reply, nonzero status, no body.
fn assert_typed_deny(
header: &ReplyHeader,
body: &[u8],
request: &RoutedRequestHeader,
status: u32,
) {
assert_echoes_request(header, request);
assert_eq!(
header.status, status,
"the status carries the typed error code"
);
assert_ne!(
header.status, 0,
"a deny must never read as a committed ack"
);
assert!(body.is_empty(), "a deny reply carries no body");
}
/// The empty `PolledMessages` body the poll arm sends when a partition
/// cannot answer; with the re-sync sentinel as `partition_id` it tells a
/// fenced consumer to re-sync its assignment.
fn empty_poll_body(partition_id: u32) -> Bytes {
let mut body = Vec::with_capacity(EMPTY_POLL_BODY_LEN);
body.extend_from_slice(&partition_id.to_le_bytes());
body.extend_from_slice(&0u64.to_le_bytes());
body.extend_from_slice(&0u32.to_le_bytes());
Bytes::from(body)
}
/// Send one eviction and decode it. The bus is cleared first so each
/// reason is inspected on its own.
async fn evict(
bus: &SpyBus,
shard: &Rc<TestShard>,
vsr_client: u128,
reason: EvictionReason,
) -> EvictionHeader {
bus.client_replies.borrow_mut().clear();
send_eviction(shard, TRANSPORT, vsr_client, reason, CONTEXT).await;
let (header, _) = sole_frame_to_transport::<EvictionHeader>(bus);
// Literals, not `metadata_consensus(shard)`: reading the context back
// from the source the builder read it from cannot catch a mis-mapped
// field. `test_shard(.., 1, 3, ..)` seeds cluster 1, view 0, replica 1.
assert_eq!(
(header.cluster, header.view, header.replica),
(EVICTION_CLUSTER, EVICTION_VIEW, EVICTION_REPLICA),
"the metadata shard stamps its live consensus context"
);
header
}
#[compio::test]
async fn typed_deny_must_echo_the_request_with_nonzero_status_and_no_body() {
let (bus, shard) = shard_at_commit();
let request = poll_request();
let status = IggyError::Unauthenticated.as_code();
send_deny_reply(&shard, TRANSPORT, request.header(), status).await;
let (header, body) = sole_frame_to_transport::<ReplyHeader>(&bus);
assert_typed_deny(&header, &body, request.header(), status);
assert_eq!(
header.client, TRANSPORT,
"a pre-plane deny answers under the transport id"
);
assert_eq!(header.op, 0, "a pre-plane deny has no session to stamp");
assert_eq!(
header.commit, COMMIT,
"a bound deny stamps the live metadata commit"
);
}
#[compio::test]
async fn unbound_deny_must_not_disclose_the_commit_frontier() {
let (bus, shard) = shard_at_commit();
let request = poll_request();
let status = IggyError::Unauthenticated.as_code();
send_unbound_deny_reply(&shard, TRANSPORT, request.header(), status).await;
let (header, body) = sole_frame_to_transport::<ReplyHeader>(&bus);
assert_typed_deny(&header, &body, request.header(), status);
assert_eq!(
header.client, TRANSPORT,
"an unbound deny answers under the transport id"
);
assert_eq!(header.op, 0, "an unbound deny has no session to stamp");
assert_eq!(
header.commit, 0,
"an unbound transport must not learn the commit frontier"
);
}
#[compio::test]
async fn non_replicated_deny_must_answer_under_the_request_session() {
let (bus, shard) = shard_at_commit();
let request = poll_request();
let status = IggyError::Unauthenticated.as_code();
send_non_replicated_deny(&shard, &request, TRANSPORT, status).await;
let (header, body) = sole_frame_to_transport::<ReplyHeader>(&bus);
assert_typed_deny(&header, &body, request.header(), status);
assert_eq!(
header.client, VSR_CLIENT,
"a read deny answers under the request's client id"
);
assert_eq!(
header.op, SESSION,
"a read deny stamps the request's session"
);
assert_eq!(
header.commit, COMMIT,
"a read deny stamps the live metadata commit"
);
}
#[compio::test]
async fn pre_consensus_deny_must_carry_the_error_code_as_status() {
let (bus, shard) = shard_at_commit();
let request = poll_request();
let error = IggyError::InvalidCredentials;
send_pre_consensus_deny(
&shard,
TRANSPORT,
request.header(),
&error,
RewriteStage::UserPassword,
)
.await;
let (header, body) = sole_frame_to_transport::<ReplyHeader>(&bus);
assert_typed_deny(&header, &body, request.header(), error.as_code());
assert_eq!(
header.client, TRANSPORT,
"a pre-consensus deny answers under the transport id"
);
assert_eq!(header.op, 0, "a pre-consensus deny has no session to stamp");
assert_eq!(
header.commit, COMMIT,
"a pre-consensus deny stamps the live metadata commit"
);
}
#[compio::test]
async fn result_rejection_must_ride_status_zero_with_the_code_in_the_result_section() {
let (bus, shard) = shard_at_commit();
let request = poll_request();
let error = IggyError::TransientNotAccepted;
send_result_rejection(&shard, TRANSPORT, request.header(), &error, CONTEXT).await;
let (header, body) = sole_frame_to_transport::<ReplyHeader>(&bus);
assert_echoes_request(&header, request.header());
assert_eq!(header.status, 0, "a result-framed rejection keeps status 0");
assert_eq!(
body.len(),
REJECTION_SECTION_LEN,
"the body is exactly one rejection section"
);
assert_eq!(
result_code(&body),
Some(error.as_code()),
"the result section carries the code"
);
assert_eq!(
header.client, VSR_CLIENT,
"a rejection answers under the request's client id"
);
assert_eq!(
header.op, COMMIT,
"op is position-typed at the commit like every reply"
);
assert_eq!(
header.commit, COMMIT,
"a rejection stamps the live metadata commit"
);
}
#[compio::test]
async fn eviction_must_carry_the_typed_reason_and_the_live_consensus_context() {
let bus = SpyBus::default();
let shard = Rc::new(test_shard(&bus, EVICTION_REPLICA, 3, FIRST_BOOT));
let no_session = evict(&bus, &shard, TRANSPORT, EvictionReason::NoSession).await;
assert_eq!(
no_session.reason,
EvictionReason::NoSession,
"the reason rides typed"
);
assert_eq!(
no_session.client, TRANSPORT,
"a client without a session is addressed by its transport id"
);
let malformed_login = evict(&bus, &shard, VSR_CLIENT, EvictionReason::MalformedLogin).await;
assert_eq!(
malformed_login.reason,
EvictionReason::MalformedLogin,
"the reason rides typed"
);
assert_eq!(
malformed_login.client, VSR_CLIENT,
"a login eviction is addressed to the request's client id"
);
let incompatible = evict(
&bus,
&shard,
VSR_CLIENT,
EvictionReason::IncompatibleProtocol,
)
.await;
assert_eq!(
incompatible.reason,
EvictionReason::IncompatibleProtocol,
"the reason rides typed"
);
assert_eq!(
incompatible.client, VSR_CLIENT,
"a login eviction is addressed to the request's client id"
);
// On a `.0` release both bounds coincide, so a swapped max/min pair
// passes here; `validate` still holds `1 <= min <= max`.
assert_eq!(
(
incompatible.server_protocol_version,
incompatible.server_protocol_version_min
),
(IGGY_PROTOCOL_VERSION, IGGY_PROTOCOL_VERSION_MIN),
"IncompatibleProtocol carries the accepted protocol window"
);
}
/// The HTTP mirror (`resync_required_polled_messages` in
/// `crate::http::wire`) is a JSON DTO, not a wire frame, so the shared
/// contract asserted here is the sentinel constant itself; the DTO's own
/// test pins its `partition_id` to the same constant.
#[compio::test]
async fn resync_sentinel_must_ride_a_status_zero_poll_body_led_by_the_sentinel() {
let (bus, shard) = shard_at_commit();
let request = poll_request();
send_non_replicated_bytes(
&shard,
&request,
TRANSPORT,
empty_poll_body(RESYNC_REQUIRED_PARTITION_SENTINEL),
FrameChannel::ResyncSentinel,
CONTEXT,
)
.await;
let (header, body) = sole_frame_to_transport::<ReplyHeader>(&bus);
assert_echoes_request(&header, request.header());
assert_eq!(
header.status, 0,
"the sentinel rides a success frame, not a deny"
);
assert_eq!(
body.len(),
EMPTY_POLL_BODY_LEN,
"the body is the empty poll the SDK decoder requires"
);
assert_eq!(
body[..4],
RESYNC_REQUIRED_PARTITION_SENTINEL.to_le_bytes(),
"the body leads with the re-sync sentinel partition id"
);
assert_eq!(
header.client, VSR_CLIENT,
"a read reply answers under the request's client id"
);
assert_eq!(
header.op, SESSION,
"a read reply stamps the request's session"
);
assert_eq!(
header.commit, COMMIT,
"a read reply stamps the live metadata commit"
);
}
/// The PING reply is the one host-built success Reply the funnel serves
/// without a consensus round.
#[compio::test]
async fn ping_must_reply_status_zero_and_empty_under_the_request_session() {
let (bus, shard) = shard_at_commit();
let sessions = Rc::new(RefCell::new(SessionManager::new()));
let server_config = Arc::new(ServerConfig::default());
let request = poll_request().transmute_header(|header, ping: &mut RoutedRequestHeader| {
*ping = header;
ping.reserved[..4].copy_from_slice(&PING_CODE.to_le_bytes());
});
let request_header = *request.header();
handle_client_request(
&shard,
&sessions,
&server_config,
MAX_TOKENS_PER_USER,
TRANSPORT,
request.into_generic(),
)
.await;
let (header, body) = sole_frame_to_transport::<ReplyHeader>(&bus);
assert_echoes_request(&header, &request_header);
assert_eq!(header.status, 0, "a served ping is a success reply");
assert!(body.is_empty(), "the ping reply carries no body");
assert_eq!(
header.client, VSR_CLIENT,
"the ping answers under the request's client id"
);
assert_eq!(header.op, SESSION, "the ping stamps the request's session");
assert_eq!(
header.commit, COMMIT,
"the ping stamps the live metadata commit"
);
}
}