This is an automated email from the ASF dual-hosted git repository. spetz pushed a commit to branch vsr_raw_request_rust_python in repository https://gitbox.apache.org/repos/asf/iggy.git
commit 094d8fbc85942478fd05845266173c93b3dd6451 Author: spetz <[email protected]> AuthorDate: Mon Jul 27 22:09:56 2026 +0200 feat(sdk): support VSR raw requests in Rust and Python --- .../actions/python-maturin/pre-merge/action.yml | 48 +++- .github/config/components.yml | 2 +- Cargo.lock | 6 +- Cargo.toml | 6 +- bdd/python/uv.lock | 2 +- core/binary_protocol/Cargo.toml | 2 +- core/common/Cargo.toml | 2 +- core/common/src/lib.rs | 1 + core/common/src/traits/binary_transport.rs | 13 +- core/common/src/types/binary_request_kind.rs | 80 +++++++ core/common/src/types/mod.rs | 1 + core/integration/tests/sdk/raw.rs | 185 ++++++++++++--- core/sdk/Cargo.toml | 2 +- core/sdk/src/binary/mod.rs | 23 ++ core/sdk/src/clients/client.rs | 79 +++++-- core/sdk/src/prelude.rs | 12 +- core/sdk/src/quic/quic_client.rs | 166 ++++++++----- core/sdk/src/tcp/tcp_client.rs | 178 +++++++++----- core/sdk/src/vsr.rs | 263 ++++++++++++++++++--- core/sdk/src/websocket/websocket_client.rs | 165 ++++++++----- examples/python/uv.lock | 2 +- foreign/python/Cargo.toml | 7 +- foreign/python/README.md | 30 +++ foreign/python/apache_iggy.pyi | 40 ++++ foreign/python/pyproject.toml | 3 +- foreign/python/src/binary_request_kind.rs | 52 ++++ foreign/python/src/client.rs | 39 ++- foreign/python/src/lib.rs | 3 + foreign/python/tests/test_raw_command.py | 62 ++++- foreign/python/tests/test_raw_command_vsr.py | 70 ++++++ foreign/python/uv.lock | 2 +- 31 files changed, 1262 insertions(+), 284 deletions(-) diff --git a/.github/actions/python-maturin/pre-merge/action.yml b/.github/actions/python-maturin/pre-merge/action.yml index 81d3bd50b..e93c4fc76 100644 --- a/.github/actions/python-maturin/pre-merge/action.yml +++ b/.github/actions/python-maturin/pre-merge/action.yml @@ -20,7 +20,7 @@ description: Python pre-merge testing with maturin github iggy actions inputs: task: - description: "Task to run (lint, test, build)" + description: "Task to run (lint, test, test-vsr, build)" required: true runs: @@ -117,12 +117,31 @@ runs: ls -la dist/ shell: bash + - name: Build VSR Python extension and server + if: inputs.task == 'test-vsr' + run: | + cd foreign/python + uv run --no-sync maturin develop --features vsr + cd ../.. + cargo build --locked --bin iggy-server-ng --features vsr + shell: bash + - name: Start Iggy server if: inputs.task == 'test' id: iggy uses: ./.github/actions/utils/server-start continue-on-error: true + - name: Start VSR Iggy server + if: inputs.task == 'test-vsr' + id: iggy-vsr + uses: ./.github/actions/utils/server-start + with: + mode: bin + bin: ${{ github.workspace }}/target/debug/iggy-server-ng + log-file: ${{ runner.temp }}/iggy-server-vsr.log + pid-file: ${{ runner.temp }}/iggy-server-vsr.pid + - name: Run Python integration tests if: inputs.task == 'test' && steps.iggy.outcome == 'success' run: | @@ -144,6 +163,20 @@ runs: exit ${TEST_EXIT_CODE:-0} shell: bash + - name: Run Python VSR raw request tests + if: inputs.task == 'test-vsr' + run: | + cd foreign/python + mkdir -p ../../reports + IGGY_TEST_PROTOCOL=vsr \ + IGGY_SERVER_HOST=127.0.0.1 \ + IGGY_SERVER_TCP_PORT=8090 \ + uv run --no-sync pytest tests/test_raw_command_vsr.py -v \ + --junitxml=../../reports/python-vsr-junit.xml \ + --tb=short \ + --capture=no + shell: bash + - name: Run Python unit tests only (fallback) if: inputs.task == 'test' && steps.iggy.outcome != 'success' run: | @@ -168,6 +201,13 @@ runs: pid-file: ${{ steps.iggy.outputs.pid_file }} log-file: ${{ steps.iggy.outputs.log_file }} + - name: Stop VSR Iggy server + if: always() && inputs.task == 'test-vsr' + uses: ./.github/actions/utils/server-stop + with: + pid-file: ${{ steps.iggy-vsr.outputs.pid_file }} + log-file: ${{ steps.iggy-vsr.outputs.log_file }} + - name: Generate coverage report if: inputs.task == 'test' run: | @@ -189,13 +229,15 @@ runs: shell: bash - name: Upload test artifacts - if: always() && inputs.task == 'test' + if: always() && (inputs.task == 'test' || inputs.task == 'test-vsr') uses: actions/upload-artifact@v7 with: - name: python-test-results-${{ github.run_id }}-${{ github.run_attempt }} + name: python-${{ inputs.task }}-results-${{ github.run_id }}-${{ github.run_attempt }} path: | reports/python-junit.xml + reports/python-vsr-junit.xml reports/python-coverage.lcov foreign/python/dist/*.whl + ${{ runner.temp }}/iggy-server-vsr.log retention-days: 7 if-no-files-found: ignore diff --git a/.github/config/components.yml b/.github/config/components.yml index 2d30025cb..631025aca 100644 --- a/.github/config/components.yml +++ b/.github/config/components.yml @@ -232,7 +232,7 @@ components: - "ci-infrastructure" # CI changes trigger full regression paths: - "foreign/python/**" - tasks: ["lint", "test", "build"] + tasks: ["lint", "test", "test-vsr", "build"] sdk-php: depends_on: diff --git a/Cargo.lock b/Cargo.lock index 7591cdbf3..50939ed39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6611,7 +6611,7 @@ checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" [[package]] name = "iggy" -version = "0.10.3-edge.2" +version = "0.10.3-edge.3" dependencies = [ "async-broadcast", "async-dropper", @@ -6821,7 +6821,7 @@ dependencies = [ [[package]] name = "iggy_binary_protocol" -version = "0.10.3-edge.2" +version = "0.10.3-edge.3" dependencies = [ "aligned-vec", "bytemuck", @@ -6833,7 +6833,7 @@ dependencies = [ [[package]] name = "iggy_common" -version = "0.10.3-edge.2" +version = "0.10.3-edge.3" dependencies = [ "aes-gcm", "async-broadcast", diff --git a/Cargo.toml b/Cargo.toml index b03900ab1..153461028 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -196,10 +196,10 @@ hyper-util = { version = "0.1.20", features = ["server-auto", "service"] } iceberg = "0.9.1" iceberg-catalog-rest = "0.9.1" iceberg-storage-opendal = "0.9.1" -iggy = { path = "core/sdk", version = "0.10.3-edge.2" } +iggy = { path = "core/sdk", version = "0.10.3-edge.3" } iggy-cli = { path = "core/cli", version = "0.13.1-edge.1" } -iggy_binary_protocol = { path = "core/binary_protocol", version = "0.10.3-edge.2" } -iggy_common = { path = "core/common", version = "0.10.3-edge.2" } +iggy_binary_protocol = { path = "core/binary_protocol", version = "0.10.3-edge.3" } +iggy_common = { path = "core/common", version = "0.10.3-edge.3" } iggy_connector_sdk = { path = "core/connectors/sdk", version = "0.3.1-edge.1" } indexmap = "2.14.0" integration = { path = "core/integration" } diff --git a/bdd/python/uv.lock b/bdd/python/uv.lock index ed686c9bd..37cc01eb7 100644 --- a/bdd/python/uv.lock +++ b/bdd/python/uv.lock @@ -8,7 +8,7 @@ exclude-newer-span = "P7D" [[package]] name = "apache-iggy" -version = "0.8.1.dev3" +version = "0.8.1.dev4" source = { directory = "../../foreign/python" } [package.metadata] diff --git a/core/binary_protocol/Cargo.toml b/core/binary_protocol/Cargo.toml index 1155dcdd0..ce36c19b1 100644 --- a/core/binary_protocol/Cargo.toml +++ b/core/binary_protocol/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "iggy_binary_protocol" -version = "0.10.3-edge.2" +version = "0.10.3-edge.3" description = "Wire protocol types and codec for the Iggy binary protocol. Shared between server and SDK." edition = "2024" rust-version.workspace = true diff --git a/core/common/Cargo.toml b/core/common/Cargo.toml index d5c80224d..3934e5bbd 100644 --- a/core/common/Cargo.toml +++ b/core/common/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "iggy_common" -version = "0.10.3-edge.2" +version = "0.10.3-edge.3" description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second." edition = "2024" rust-version.workspace = true diff --git a/core/common/src/lib.rs b/core/common/src/lib.rs index 12f139d2a..e4964a3b2 100644 --- a/core/common/src/lib.rs +++ b/core/common/src/lib.rs @@ -72,6 +72,7 @@ pub use traits::topic_client::TopicClient; pub use traits::user_client::UserClient; pub use traits::validatable::Validatable; pub use types::args::*; +pub use types::binary_request_kind::BinaryRequestKind; pub use types::client::client_info::*; pub use types::client_state::ClientState; pub use types::cluster::*; diff --git a/core/common/src/traits/binary_transport.rs b/core/common/src/traits/binary_transport.rs index 3dacff9ad..80f1b7182 100644 --- a/core/common/src/traits/binary_transport.rs +++ b/core/common/src/traits/binary_transport.rs @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -use crate::{ClientState, DiagnosticEvent, IggyDuration, IggyError}; +use crate::{BinaryRequestKind, ClientState, DiagnosticEvent, IggyDuration, IggyError}; use async_trait::async_trait; use bytes::Bytes; #[cfg(feature = "vsr")] @@ -28,7 +28,18 @@ pub trait BinaryTransport { /// Sets the state of the client. async fn set_state(&self, state: ClientState); async fn publish_event(&self, event: DiagnosticEvent); + /// Send a standard command. The protocol tables classify `code`, so the + /// caller declares no execution model; every typed SDK method lands here. async fn send_raw_with_response(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError>; + /// Send a raw command whose `code` the SDK need not know, with the caller + /// declaring how it executes. A code the tables do know keeps its own + /// class and a conflicting `kind` is rejected. + async fn send_raw_with_kind( + &self, + kind: BinaryRequestKind, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError>; fn get_heartbeat_interval(&self) -> IggyDuration; /// Per-transport consumer-group + partitioning cache used to resolve diff --git a/core/common/src/types/binary_request_kind.rs b/core/common/src/types/binary_request_kind.rs new file mode 100644 index 000000000..cfd1e49d2 --- /dev/null +++ b/core/common/src/types/binary_request_kind.rs @@ -0,0 +1,80 @@ +// 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. + +use strum::{Display, EnumString}; + +/// How a raw binary request executes on the server. +/// +/// A vendor command code is unknown to the SDK, so the caller has to say whether +/// it runs outside consensus or is replicated through it. The declaration is +/// never authoritative: a code the protocol tables already know keeps its own +/// class, and the server independently rejects a declaration it disagrees with. +/// +/// Classic framing carries `[length][code][payload]` with no operation field, so +/// the kind is inert there and both variants encode identical bytes. It only +/// selects a wire path under VSR. +/// +/// The string form is the cross-SDK spelling the shared BDD scenarios and the +/// PHP binding use. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Display, EnumString)] +#[strum(serialize_all = "snake_case")] +pub enum BinaryRequestKind { + /// Runs on the receiving node only, outside consensus. Reads, pings, and + /// vendor commands that own no replicated state. + NonReplicated, + /// Replicated through consensus before it takes effect. + /// + /// Only the standard replicated commands are supported today. A vendor code + /// declared `Replicated` yields [`crate::IggyError::FeatureUnavailable`]: + /// the protocol has no deterministic handler registry, replicated state + /// ownership, or snapshot contract for one yet. + Replicated, +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::BinaryRequestKind; + + #[test] + fn given_display_output_when_parsed_should_round_trip() { + for kind in [ + BinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated, + ] { + assert_eq!( + BinaryRequestKind::from_str(&kind.to_string()).unwrap(), + kind + ); + } + } + + #[test] + fn given_cross_sdk_names_when_parsed_should_match_the_other_bindings() { + assert_eq!( + BinaryRequestKind::NonReplicated.to_string(), + "non_replicated" + ); + assert_eq!(BinaryRequestKind::Replicated.to_string(), "replicated"); + } + + #[test] + fn given_unknown_text_when_parsed_should_fail() { + assert!(BinaryRequestKind::from_str("auto").is_err()); + } +} diff --git a/core/common/src/types/mod.rs b/core/common/src/types/mod.rs index f1a2f37af..a8e2edfab 100644 --- a/core/common/src/types/mod.rs +++ b/core/common/src/types/mod.rs @@ -16,6 +16,7 @@ // under the License. pub(crate) mod args; +pub(crate) mod binary_request_kind; pub(crate) mod client; pub(crate) mod client_state; pub(crate) mod cluster; diff --git a/core/integration/tests/sdk/raw.rs b/core/integration/tests/sdk/raw.rs index cffeec954..89accc9a6 100644 --- a/core/integration/tests/sdk/raw.rs +++ b/core/integration/tests/sdk/raw.rs @@ -22,6 +22,15 @@ use iggy_binary_protocol::codes::{GET_STATS_CODE, LOGIN_USER_CODE, PING_CODE}; use iggy_binary_protocol::requests::system::{GetStatsRequest, PingRequest}; use integration::iggy_harness; +/// A code no server registers a handler for, well past every range the protocol +/// assigns, so it stays a vendor code as the command table grows. +const VENDOR_CODE: u32 = 60_001; + +const KINDS: [BinaryRequestKind; 2] = [ + BinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated, +]; + #[cfg(not(feature = "vsr"))] #[iggy_harness(test_client_transport = [Tcp, Quic, Http, WebSocket])] async fn given_authenticated_client_when_sending_raw_request_should_round_trip( @@ -63,39 +72,24 @@ async fn assert_raw_round_trip(client: &IggyClient) { let error = client .send_binary_request(PING_CODE, PingRequest.to_bytes()) .await - .expect_err("binary command must be unavailable on HTTP"); + .expect_err("legacy binary command must be unavailable on HTTP"); assert_eq!(error, IggyError::FeatureUnavailable); - } - _ => { - let response = client - .send_binary_request(PING_CODE, PingRequest.to_bytes()) - .await - .expect("binary ping request should succeed"); - assert!(response.is_empty()); - - let stats = client - .send_binary_request(GET_STATS_CODE, GetStatsRequest.to_bytes()) - .await - .expect("authenticated binary command should return a body"); - assert!(!stats.is_empty()); - - // Rejected before the wire (guards the VSR consensus-session panic - // when a login code is sent raw on a bound client). - let error = client - .send_binary_request(LOGIN_USER_CODE, Bytes::new()) - .await - .expect_err("session-control codes must be rejected by the raw path"); - assert_eq!(error, IggyError::InvalidCommand); - // VSR encoder is closed-world: unknown code rejected at encode time. - #[cfg(feature = "vsr")] - { + for kind in KINDS { let error = client - .send_binary_request(60_000, Bytes::new()) + .send_binary_request_with_kind(kind, PING_CODE, PingRequest.to_bytes()) .await - .expect_err("unknown code must be rejected under VSR"); - assert_eq!(error, IggyError::InvalidCommand); + .expect_err("binary command must be unavailable on HTTP"); + assert_eq!(error, IggyError::FeatureUnavailable); } + } + _ => { + assert_standard_codes_round_trip(client).await; + assert_session_control_codes_are_rejected(client).await; + assert_legacy_vendor_code_stays_closed_world(client).await; + assert_vendor_code_reaches_the_server(client).await; + assert_replicated_declaration_is_honored(client).await; + assert_conflicting_declaration_is_honored(client).await; let error = client .send_http_request(HttpMethod::Get, "/ping", None) @@ -105,3 +99,138 @@ async fn assert_raw_round_trip(client: &IggyClient) { } } } + +async fn assert_standard_codes_round_trip(client: &IggyClient) { + let response = client + .send_binary_request(PING_CODE, PingRequest.to_bytes()) + .await + .expect("legacy binary ping request should succeed"); + assert!(response.is_empty()); + + let stats = client + .send_binary_request(GET_STATS_CODE, GetStatsRequest.to_bytes()) + .await + .expect("legacy authenticated binary command should return a body"); + assert!(!stats.is_empty()); + + let response = client + .send_binary_request_with_kind( + BinaryRequestKind::NonReplicated, + PING_CODE, + PingRequest.to_bytes(), + ) + .await + .expect("binary ping request should succeed"); + assert!(response.is_empty()); + + let stats = client + .send_binary_request_with_kind( + BinaryRequestKind::NonReplicated, + GET_STATS_CODE, + GetStatsRequest.to_bytes(), + ) + .await + .expect("authenticated binary command should return a body"); + assert!(!stats.is_empty()); +} + +/// Rejected before the wire whatever the declaration, which also guards the VSR +/// consensus-session panic a raw login on a bound client used to trigger. +async fn assert_session_control_codes_are_rejected(client: &IggyClient) { + let error = client + .send_binary_request(LOGIN_USER_CODE, Bytes::new()) + .await + .expect_err("legacy session-control codes must be rejected"); + assert_eq!(error, IggyError::InvalidCommand); + + for kind in KINDS { + let error = client + .send_binary_request_with_kind(kind, LOGIN_USER_CODE, Bytes::new()) + .await + .expect_err("session-control codes must be rejected by the raw path"); + assert_eq!(error, IggyError::InvalidCommand); + } +} + +/// The compatibility API keeps its previous VSR closed-world behavior while +/// classic framing still lets the server judge an arbitrary code. +async fn assert_legacy_vendor_code_stays_closed_world(client: &IggyClient) { + let error = client + .send_binary_request(VENDOR_CODE, Bytes::from_static(b"vendor-body")) + .await + .expect_err("no server or VSR table knows the vendor code"); + assert_eq!(error, IggyError::InvalidCommand); + + client + .ping() + .await + .expect("legacy raw rejection must not poison the connection"); +} + +/// A non-replicated vendor code leaves the SDK, so the rejection comes from the +/// server rather than the encoder, and the connection survives it. +async fn assert_vendor_code_reaches_the_server(client: &IggyClient) { + let error = client + .send_binary_request_with_kind( + BinaryRequestKind::NonReplicated, + VENDOR_CODE, + Bytes::from_static(b"vendor-body"), + ) + .await + .expect_err("no server registers a handler for the vendor code"); + assert_eq!(error, IggyError::InvalidCommand); + + client + .ping() + .await + .expect("connection must stay usable after a request-level rejection"); +} + +/// Under VSR a replicated vendor code has no deterministic handler registry, so +/// it fails in the encoder. Classic framing has no operation field, so the same +/// call reaches the server and is rejected there. +async fn assert_replicated_declaration_is_honored(client: &IggyClient) { + let error = client + .send_binary_request_with_kind( + BinaryRequestKind::Replicated, + VENDOR_CODE, + Bytes::from_static(b"vendor-body"), + ) + .await + .expect_err("a replicated vendor code has no handler on either protocol"); + + #[cfg(feature = "vsr")] + assert_eq!(error, IggyError::FeatureUnavailable); + #[cfg(not(feature = "vsr"))] + assert_eq!(error, IggyError::InvalidCommand); + + client + .ping() + .await + .expect("connection must stay usable after a rejected declaration"); +} + +/// A declaration that disagrees with a standard code cannot redirect it. Under +/// VSR the encoder rejects the pair; classic framing ignores the declaration and +/// emits the same bytes it would for the matching one. +async fn assert_conflicting_declaration_is_honored(client: &IggyClient) { + let result = client + .send_binary_request_with_kind( + BinaryRequestKind::Replicated, + GET_STATS_CODE, + GetStatsRequest.to_bytes(), + ) + .await; + + #[cfg(feature = "vsr")] + assert_eq!( + result.expect_err("a read declared replicated must be rejected"), + IggyError::InvalidCommand + ); + #[cfg(not(feature = "vsr"))] + assert!( + !result + .expect("classic framing ignores the declaration") + .is_empty() + ); +} diff --git a/core/sdk/Cargo.toml b/core/sdk/Cargo.toml index d308dc363..5f8d91aa9 100644 --- a/core/sdk/Cargo.toml +++ b/core/sdk/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "iggy" -version = "0.10.3-edge.2" +version = "0.10.3-edge.3" description = "Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second." edition = "2024" rust-version.workspace = true diff --git a/core/sdk/src/binary/mod.rs b/core/sdk/src/binary/mod.rs index d892b1910..5224b404b 100644 --- a/core/sdk/src/binary/mod.rs +++ b/core/sdk/src/binary/mod.rs @@ -15,4 +15,27 @@ // specific language governing permissions and limitations // under the License. +use iggy_binary_protocol::codes::{ + LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE, LOGIN_USER_CODE, + LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, LOGOUT_USER_CODE, +}; +use iggy_common::IggyError; pub use iggy_common::{BinaryClient, BinaryTransport}; + +/// Auth/session codes rejected by the raw binary path. Must go through the +/// typed `login_user` / `logout_user` methods to keep session state correct. +pub(crate) const SESSION_CONTROL_CODES: [u32; 5] = [ + LOGIN_USER_CODE, + LOGOUT_USER_CODE, + LOGIN_REGISTER_CODE, + LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, + LOGIN_REGISTER_WITH_PAT_CODE, +]; + +pub(crate) fn validate_binary_request_code(code: u32) -> Result<(), IggyError> { + if SESSION_CONTROL_CODES.contains(&code) { + Err(IggyError::InvalidCommand) + } else { + Ok(()) + } +} diff --git a/core/sdk/src/clients/client.rs b/core/sdk/src/clients/client.rs index 98beb9830..c2cdb65e0 100644 --- a/core/sdk/src/clients/client.rs +++ b/core/sdk/src/clients/client.rs @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +use crate::binary::validate_binary_request_code; use crate::client_wrappers::client_wrapper::ClientWrapper; use crate::client_wrappers::connection_info::ConnectionInfo; use crate::clients::client_builder::IggyClientBuilder; @@ -30,13 +31,9 @@ use crate::websocket::websocket_client::WebSocketClient; use async_broadcast::Receiver; use async_trait::async_trait; use bytes::Bytes; -use iggy_binary_protocol::codes::{ - LOGIN_REGISTER_CODE, LOGIN_REGISTER_WITH_PAT_CODE, LOGIN_USER_CODE, - LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, LOGOUT_USER_CODE, -}; use iggy_common::Consumer; use iggy_common::locking::{IggyRwLock, IggyRwLockFn}; -use iggy_common::{BinaryTransport, Client, HttpMethod, SystemClient}; +use iggy_common::{BinaryRequestKind, BinaryTransport, Client, HttpMethod, SystemClient}; use iggy_common::{ConnectionStringUtils, DiagnosticEvent, Partitioner, TransportProtocol}; use std::fmt::Debug; use std::sync::Arc; @@ -45,16 +42,6 @@ use tokio::time::sleep; use tracing::log::warn; use tracing::{debug, error, info}; -/// Auth/session codes rejected by the raw binary path. Must go through the -/// typed `login_user` / `logout_user` methods to keep session state correct. -const SESSION_CONTROL_CODES: [u32; 5] = [ - LOGIN_USER_CODE, - LOGOUT_USER_CODE, - LOGIN_REGISTER_CODE, - LOGIN_WITH_PERSONAL_ACCESS_TOKEN_CODE, - LOGIN_REGISTER_WITH_PAT_CODE, -]; - /// The main client struct which implements all the `Client` traits and wraps the underlying low-level client for the specific transport. /// /// It also provides the additional builders for the standalone consumer, consumer group, and producer. @@ -204,13 +191,8 @@ impl IggyClient { /// Login and logout codes are rejected with `InvalidCommand`. Use the /// `login_user` / `logout_user` methods so SDK session state stays correct. /// - /// Custom codes only work on the classic protocol. Under `vsr` the encoder - /// is closed-world: an unknown code yields `InvalidCommand`, a replicated - /// code with no mapping yields `UnknownReplicatedCommand`. pub async fn send_binary_request(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError> { - if SESSION_CONTROL_CODES.contains(&code) { - return Err(IggyError::InvalidCommand); - } + validate_binary_request_code(code)?; match &*self.client.read().await { ClientWrapper::Tcp(client) => client.send_raw_with_response(code, payload).await, ClientWrapper::Quic(client) => client.send_raw_with_response(code, payload).await, @@ -219,6 +201,38 @@ impl IggyClient { } } + /// Send a raw binary command with an explicit replication declaration. + /// + /// A standard command keeps the class defined by the protocol tables, and + /// a conflicting declaration is rejected with `InvalidCommand`. Classic + /// framing carries no operation field, so the declaration does not change + /// the encoded bytes. Under `vsr`, an unknown + /// [`BinaryRequestKind::NonReplicated`] code uses + /// `Operation::NonReplicated`; an unknown + /// [`BinaryRequestKind::Replicated`] code yields `FeatureUnavailable` + /// until the protocol has a replicated extension registry. + /// + /// Login and logout codes are rejected with `InvalidCommand`. Use the + /// typed session methods so SDK state stays synchronized with the server. + pub async fn send_binary_request_with_kind( + &self, + kind: BinaryRequestKind, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { + // Validate before dispatch so HTTP returns InvalidCommand for session + // control instead of its blanket FeatureUnavailable. + validate_binary_request_code(code)?; + match &*self.client.read().await { + ClientWrapper::Tcp(client) => client.send_raw_with_kind(kind, code, payload).await, + ClientWrapper::Quic(client) => client.send_raw_with_kind(kind, code, payload).await, + ClientWrapper::WebSocket(client) => { + client.send_raw_with_kind(kind, code, payload).await + } + ClientWrapper::Http(_) | ClientWrapper::Iggy(_) => Err(IggyError::FeatureUnavailable), + } + } + /// Invoke an arbitrary HTTP endpoint and return the raw response body. HTTP /// transport only; binary transports yield `FeatureUnavailable`. pub async fn send_http_request( @@ -289,6 +303,7 @@ impl Client for IggyClient { #[cfg(test)] mod tests { use super::*; + use crate::binary::SESSION_CONTROL_CODES; #[test] fn should_fail_with_empty_connection_string() { @@ -482,6 +497,11 @@ mod tests { IggyClient::from_connection_string("iggy+http://user:[email protected]:1234").unwrap(); let result = client.send_binary_request(0, Bytes::new()).await; assert!(matches!(result, Err(IggyError::FeatureUnavailable))); + + let result = client + .send_binary_request_with_kind(BinaryRequestKind::NonReplicated, 0, Bytes::new()) + .await; + assert!(matches!(result, Err(IggyError::FeatureUnavailable))); } #[tokio::test] @@ -491,8 +511,23 @@ mod tests { let result = client.send_binary_request(code, Bytes::new()).await; assert!( matches!(result, Err(IggyError::InvalidCommand)), - "code {code} must be rejected before reaching the transport" + "legacy raw code {code} must be rejected before reaching the transport" ); } + + for kind in [ + BinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated, + ] { + for code in SESSION_CONTROL_CODES { + let result = client + .send_binary_request_with_kind(kind, code, Bytes::new()) + .await; + assert!( + matches!(result, Err(IggyError::InvalidCommand)), + "code {code} declared {kind} must be rejected before reaching the transport" + ); + } + } } } diff --git a/core/sdk/src/prelude.rs b/core/sdk/src/prelude.rs index 34f8d1484..1aa6ea93b 100644 --- a/core/sdk/src/prelude.rs +++ b/core/sdk/src/prelude.rs @@ -48,12 +48,12 @@ pub use crate::stream_builder::{IggyStream, IggyStreamConfig}; pub use crate::tcp::tcp_client::TcpClient; pub use crate::websocket::websocket_client::WebSocketClient; pub use iggy_common::{ - Aes256GcmEncryptor, Args, ArgsOptional, AutoLogin, CacheMetrics, CacheMetricsKey, ClientError, - ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole, ClusterNodeStatus, - CompressionAlgorithm, Consumer, ConsumerGroup, ConsumerGroupDetails, ConsumerGroupMember, - ConsumerKind, EncryptorKind, GlobalPermissions, HeaderKey, HeaderKind, HeaderValue, - HttpClientConfig, HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, IdentityInfo, - IggyByteSize, IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage, + Aes256GcmEncryptor, Args, ArgsOptional, AutoLogin, BinaryRequestKind, CacheMetrics, + CacheMetricsKey, ClientError, ClientInfoDetails, ClusterMetadata, ClusterNode, ClusterNodeRole, + ClusterNodeStatus, CompressionAlgorithm, Consumer, ConsumerGroup, ConsumerGroupDetails, + ConsumerGroupMember, ConsumerKind, EncryptorKind, GlobalPermissions, HeaderKey, HeaderKind, + HeaderValue, HttpClientConfig, HttpClientConfigBuilder, HttpMethod, IdKind, Identifier, + IdentityInfo, IggyByteSize, IggyDuration, IggyError, IggyExpiry, IggyIndexView, IggyMessage, IggyMessageHeader, IggyMessageHeaderView, IggyMessageView, IggyMessageViewIterator, IggyTimestamp, MaxTopicSize, Partition, Partitioner, Partitioning, Permissions, PersonalAccessTokenExpiry, PollMessages, PolledMessages, PollingKind, PollingStrategy, diff --git a/core/sdk/src/quic/quic_client.rs b/core/sdk/src/quic/quic_client.rs index 9a731f608..75e4ee2d5 100644 --- a/core/sdk/src/quic/quic_client.rs +++ b/core/sdk/src/quic/quic_client.rs @@ -15,13 +15,16 @@ // specific language governing permissions and limitations // under the License. +use crate::binary::validate_binary_request_code; use crate::leader_aware::{LeaderRedirectionState, check_and_redirect_to_leader}; use crate::prelude::AutoLogin; #[cfg(feature = "vsr")] use crate::session::ConsensusSession; #[cfg(feature = "vsr")] use iggy_common::VsrSessionControl as _; -use iggy_common::{BinaryClient, BinaryTransport, Client, PersonalAccessTokenClient, UserClient}; +use iggy_common::{ + BinaryClient, BinaryRequestKind, BinaryTransport, Client, PersonalAccessTokenClient, UserClient, +}; use crate::prelude::{IggyDuration, IggyError, IggyTimestamp, QuicClientConfig}; use crate::quic::skip_server_verification::SkipServerVerification; @@ -137,59 +140,17 @@ impl BinaryTransport for QuicClient { } async fn send_raw_with_response(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError> { - let result = self.send_raw(code, payload.clone()).await; - if result.is_ok() { - return result; - } - - let error = result.unwrap_err(); - if !matches!( - error, - IggyError::Disconnected - | IggyError::EmptyResponse - | IggyError::Unauthenticated - | IggyError::StaleClient - | IggyError::NotConnected - | IggyError::CannotEstablishConnection - | IggyError::QuicError - ) { - return Err(error); - } - - if !self.config.reconnection.enabled { - return Err(IggyError::Disconnected); - } - - #[cfg(feature = "vsr")] - if matches!(self.config.auto_login, AutoLogin::Disabled) && !is_login_register_code(code) { - // Without auto-login a reconnect cannot re-establish the session, so - // non-login requests are not recovered here - their transient replay - // happens on the live connection inside `send_raw`. Login/register - // is the exception: the server stays deliberately silent on a - // transient register failure and relies on the client replaying via - // a reconnect with a fresh session. - return Err(error); - } + self.send_with_reconnect(None, code, payload).await + } - self.disconnect().await?; - #[cfg(feature = "vsr")] - let skip_auto_login = is_login_register_code(code); - #[cfg(feature = "vsr")] - if skip_auto_login { - *self.skip_auto_login_once.lock().await = true; - } - let server_address = self.current_server_address.lock().await.to_string(); - info!( - "Reconnecting to the server: {}, by client: {}", - server_address, self.config.client_address - ); - let reconnect = self.connect().await; - #[cfg(feature = "vsr")] - if skip_auto_login && reconnect.is_err() { - *self.skip_auto_login_once.lock().await = false; - } - reconnect?; - self.send_raw(code, payload).await + async fn send_raw_with_kind( + &self, + kind: BinaryRequestKind, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { + validate_binary_request_code(code)?; + self.send_with_reconnect(Some(kind), code, payload).await } fn get_heartbeat_interval(&self) -> IggyDuration { @@ -679,7 +640,76 @@ impl QuicClient { Ok(()) } - async fn send_raw(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError> { + /// Single reconnect-and-replay path shared by the typed and the raw entry + /// points, so `kind` survives the retry attempt unchanged. + async fn send_with_reconnect( + &self, + kind: Option<BinaryRequestKind>, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { + let result = self.send_raw(kind, code, payload.clone()).await; + if result.is_ok() { + return result; + } + + let error = result.unwrap_err(); + if !matches!( + error, + IggyError::Disconnected + | IggyError::EmptyResponse + | IggyError::Unauthenticated + | IggyError::StaleClient + | IggyError::NotConnected + | IggyError::CannotEstablishConnection + | IggyError::QuicError + ) { + return Err(error); + } + + if !self.config.reconnection.enabled { + return Err(IggyError::Disconnected); + } + + #[cfg(feature = "vsr")] + if matches!(self.config.auto_login, AutoLogin::Disabled) && !is_login_register_code(code) { + // Without auto-login a reconnect cannot re-establish the session, so + // non-login requests are not recovered here - their transient replay + // happens on the live connection inside `send_raw`. Login/register + // is the exception: the server stays deliberately silent on a + // transient register failure and relies on the client replaying via + // a reconnect with a fresh session. + return Err(error); + } + + self.disconnect().await?; + #[cfg(feature = "vsr")] + let skip_auto_login = is_login_register_code(code); + #[cfg(feature = "vsr")] + if skip_auto_login { + *self.skip_auto_login_once.lock().await = true; + } + let server_address = self.current_server_address.lock().await.to_string(); + info!( + "Reconnecting to the server: {}, by client: {}", + server_address, self.config.client_address + ); + let reconnect = self.connect().await; + #[cfg(feature = "vsr")] + if skip_auto_login && reconnect.is_err() { + *self.skip_auto_login_once.lock().await = false; + } + reconnect?; + self.send_raw(kind, code, payload).await + } + + #[cfg_attr(not(feature = "vsr"), expect(unused_variables))] + async fn send_raw( + &self, + kind: Option<BinaryRequestKind>, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { match self.get_state().await { ClientState::Shutdown => { trace!("Cannot send data. Client is shutdown."); @@ -720,7 +750,12 @@ impl QuicClient { let mut consensus_session = consensus_session .lock() .expect("consensus session mutex poisoned"); - crate::vsr::encode_request_header(&mut consensus_session, code, &payload)? + crate::vsr::encode_request_header( + &mut consensus_session, + kind, + code, + &payload, + )? }; trace!("Sending a QUIC VSR request of size {request_size} with code: {code}"); // Same-connection transient resend, gated on the EXPLICIT @@ -925,6 +960,27 @@ mod tests { assert!(quic_client.is_err()); } + #[tokio::test] + async fn raw_binary_transport_rejects_session_control_before_io() { + let client = + QuicClient::from_connection_string("iggy+quic://iggy:[email protected]:8080").unwrap(); + + for kind in [ + BinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated, + ] { + let error = client + .send_raw_with_kind( + kind, + iggy_binary_protocol::codes::LOGIN_USER_CODE, + Bytes::new(), + ) + .await + .unwrap_err(); + assert!(matches!(error, IggyError::InvalidCommand)); + } + } + #[tokio::test] async fn should_fail_without_username() { let connection_string_prefix = "iggy+"; diff --git a/core/sdk/src/tcp/tcp_client.rs b/core/sdk/src/tcp/tcp_client.rs index f827b9f55..34da743e7 100644 --- a/core/sdk/src/tcp/tcp_client.rs +++ b/core/sdk/src/tcp/tcp_client.rs @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +use crate::binary::validate_binary_request_code; use crate::leader_aware::{LeaderRedirectionState, check_and_redirect_to_leader}; use crate::prelude::Client; use crate::prelude::TcpClientConfig; @@ -38,7 +39,9 @@ use iggy_common::{ AutoLogin, ClientState, ConnectionString, ConnectionStringUtils, Credentials, DiagnosticEvent, IggyDuration, IggyError, IggyTimestamp, TcpConnectionStringOptions, TransportProtocol, }; -use iggy_common::{BinaryClient, BinaryTransport, PersonalAccessTokenClient, UserClient}; +use iggy_common::{ + BinaryClient, BinaryRequestKind, BinaryTransport, PersonalAccessTokenClient, UserClient, +}; use rustls::pki_types::{CertificateDer, ServerName, pem::PemObject}; use secrecy::ExposeSecret; use std::net::SocketAddr; @@ -148,64 +151,17 @@ impl BinaryTransport for TcpClient { } async fn send_raw_with_response(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError> { - let result = self.send_raw(code, payload.clone()).await; - if result.is_ok() { - return result; - } - - let error = result.unwrap_err(); - if !matches!( - error, - IggyError::Disconnected - | IggyError::EmptyResponse - | IggyError::Unauthenticated - | IggyError::StaleClient - | IggyError::NotConnected - | IggyError::CannotEstablishConnection - | IggyError::TcpError - ) { - return Err(error); - } - - if !self.config.reconnection.enabled { - return Err(IggyError::Disconnected); - } - - #[cfg(feature = "vsr")] - if matches!(self.config.auto_login, AutoLogin::Disabled) && !is_login_register_code(code) { - // Without auto-login a reconnect cannot re-establish the session, - // so non-login requests fail fast. Login/register itself is the - // exception: the server stays deliberately silent on transient - // register failures (server-ng `surface_login_failure`) and - // relies on the client timing out and replaying the request. - return Err(error); - } - - self.disconnect().await?; - - #[cfg(feature = "vsr")] - let skip_auto_login = is_login_register_code(code); - #[cfg(feature = "vsr")] - if skip_auto_login { - *self.skip_auto_login_once.lock().await = true; - } - - { - let client_address = self.get_client_address_value().await; - let server_address = self.current_server_address.lock().await.clone(); - info!( - "Reconnecting to the server: {} by client: {client_address}...", - server_address - ); - } + self.send_with_reconnect(None, code, payload).await + } - let reconnect = self.connect().await; - #[cfg(feature = "vsr")] - if skip_auto_login && reconnect.is_err() { - *self.skip_auto_login_once.lock().await = false; - } - reconnect?; - self.send_raw(code, payload).await + async fn send_raw_with_kind( + &self, + kind: BinaryRequestKind, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { + validate_binary_request_code(code)?; + self.send_with_reconnect(Some(kind), code, payload).await } fn get_heartbeat_interval(&self) -> IggyDuration { @@ -708,7 +664,81 @@ impl TcpClient { Ok(()) } - async fn send_raw(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError> { + /// Single reconnect-and-replay path shared by the typed and the raw entry + /// points, so `kind` survives the retry attempt unchanged. + async fn send_with_reconnect( + &self, + kind: Option<BinaryRequestKind>, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { + let result = self.send_raw(kind, code, payload.clone()).await; + if result.is_ok() { + return result; + } + + let error = result.unwrap_err(); + if !matches!( + error, + IggyError::Disconnected + | IggyError::EmptyResponse + | IggyError::Unauthenticated + | IggyError::StaleClient + | IggyError::NotConnected + | IggyError::CannotEstablishConnection + | IggyError::TcpError + ) { + return Err(error); + } + + if !self.config.reconnection.enabled { + return Err(IggyError::Disconnected); + } + + #[cfg(feature = "vsr")] + if matches!(self.config.auto_login, AutoLogin::Disabled) && !is_login_register_code(code) { + // Without auto-login a reconnect cannot re-establish the session, + // so non-login requests fail fast. Login/register itself is the + // exception: the server stays deliberately silent on transient + // register failures (server-ng `surface_login_failure`) and + // relies on the client timing out and replaying the request. + return Err(error); + } + + self.disconnect().await?; + + #[cfg(feature = "vsr")] + let skip_auto_login = is_login_register_code(code); + #[cfg(feature = "vsr")] + if skip_auto_login { + *self.skip_auto_login_once.lock().await = true; + } + + { + let client_address = self.get_client_address_value().await; + let server_address = self.current_server_address.lock().await.clone(); + info!( + "Reconnecting to the server: {} by client: {client_address}...", + server_address + ); + } + + let reconnect = self.connect().await; + #[cfg(feature = "vsr")] + if skip_auto_login && reconnect.is_err() { + *self.skip_auto_login_once.lock().await = false; + } + reconnect?; + self.send_raw(kind, code, payload).await + } + + #[cfg_attr(not(feature = "vsr"), expect(unused_variables))] + async fn send_raw( + &self, + kind: Option<BinaryRequestKind>, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { match self.get_state().await { ClientState::Shutdown => { trace!("Cannot send data. Client is shutdown."); @@ -744,6 +774,7 @@ impl TcpClient { }; let (header, result) = self .send_raw_vsr_attempt( + kind, code, payload.clone(), preencoded, @@ -858,6 +889,7 @@ impl TcpClient { #[cfg(feature = "vsr")] async fn send_raw_vsr_attempt( &self, + kind: Option<BinaryRequestKind>, code: u32, payload: Bytes, preencoded: Option<iggy_binary_protocol::consensus::RequestHeader>, @@ -892,7 +924,12 @@ impl TcpClient { let mut consensus_session = consensus_session .lock() .expect("consensus session mutex poisoned"); - crate::vsr::encode_request_header(&mut consensus_session, code, &payload) + crate::vsr::encode_request_header( + &mut consensus_session, + kind, + code, + &payload, + ) }; match encoded { Ok((header, request_size)) => { @@ -1065,6 +1102,27 @@ mod tests { assert!(tcp_client.is_err()); } + #[tokio::test] + async fn raw_binary_transport_rejects_session_control_before_io() { + let client = + TcpClient::from_connection_string("iggy+tcp://iggy:[email protected]:8090").unwrap(); + + for kind in [ + BinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated, + ] { + let error = client + .send_raw_with_kind( + kind, + iggy_binary_protocol::codes::LOGIN_USER_CODE, + Bytes::new(), + ) + .await + .unwrap_err(); + assert!(matches!(error, IggyError::InvalidCommand)); + } + } + #[test] fn should_fail_without_username() { let connection_string_prefix = "iggy+"; diff --git a/core/sdk/src/vsr.rs b/core/sdk/src/vsr.rs index d1e9ab859..0aae763df 100644 --- a/core/sdk/src/vsr.rs +++ b/core/sdk/src/vsr.rs @@ -38,7 +38,7 @@ use iggy_binary_protocol::requests::consumer_offsets::{ use iggy_binary_protocol::requests::messages::SendMessagesHeader; use iggy_binary_protocol::requests::segments::DeleteSegmentsRequest; use iggy_binary_protocol::{WireIdentifier, WirePartitioning}; -use iggy_common::{IggyError, eviction_reason_to_error}; +use iggy_common::{BinaryRequestKind, IggyError, eviction_reason_to_error}; const NON_REPLICATED_CODE_RANGE: std::ops::Range<usize> = 0..4; @@ -54,18 +54,23 @@ const NON_REPLICATED_CODE_RANGE: std::ops::Range<usize> = 0..4; // independent of (client_id, request_id). pub(crate) fn encode_contiguous_request( session: &mut ConsensusSession, + kind: Option<BinaryRequestKind>, code: u32, payload: &Bytes, ) -> Result<Bytes, IggyError> { - let (header, total_size) = encode_request_header(session, code, payload)?; + let (header, total_size) = encode_request_header(session, kind, code, payload)?; let mut request = BytesMut::with_capacity(total_size); request.put_slice(bytemuck::bytes_of(&header)); request.put_slice(payload); Ok(request.freeze()) } +/// `kind` is the caller's replication declaration for a raw request. `None` +/// marks a standard typed command, which the protocol tables classify on their +/// own. pub(crate) fn encode_request_header( session: &mut ConsensusSession, + kind: Option<BinaryRequestKind>, code: u32, payload: &Bytes, ) -> Result<(RequestHeader, usize), IggyError> { @@ -79,7 +84,7 @@ pub(crate) fn encode_request_header( (Operation::Register, session.begin_register(), 0) } _ => { - let operation = operation_for_code(code)?; + let operation = operation_for_code(code, kind)?; // NonReplicated ops (ping, reads) bypass server-side dedup -- // `ClientTable` only tracks request_ids for replicated ops. If // they consumed the monotonic counter, the next replicated @@ -144,19 +149,57 @@ pub(crate) fn encode_request_header( Ok((header, total_size)) } -fn operation_for_code(code: u32) -> Result<Operation, IggyError> { +/// Pick the header operation for `code`, honoring the caller's declaration +/// only where the protocol tables have nothing to say. +/// +/// Standard codes resolve first so a caller cannot redirect a shipped command +/// into the other execution model, then an unknown code falls back to the +/// declaration. Without a declaration an unknown code is a bug in a typed SDK +/// method rather than a vendor extension, so it stays `InvalidCommand`. +fn operation_for_code(code: u32, kind: Option<BinaryRequestKind>) -> Result<Operation, IggyError> { if code == LOGOUT_USER_CODE { - return Ok(Operation::Logout); + return accept_declaration(Operation::Logout, kind); } if let Some(operation) = Operation::from_command_code(code) { - return Ok(operation); + return accept_declaration(operation, kind); } match iggy_binary_protocol::dispatch::lookup_command(code) { - Some(meta) if !meta.is_replicated() => Ok(Operation::NonReplicated), + Some(meta) if !meta.is_replicated() => accept_declaration(Operation::NonReplicated, kind), Some(_) => Err(IggyError::UnknownReplicatedCommand(code)), - None => Err(IggyError::InvalidCommand), + None => match kind { + Some(BinaryRequestKind::NonReplicated) => Ok(Operation::NonReplicated), + // A replicated vendor command needs a deterministic server-side + // handler registry, replicated state ownership, and a snapshot + // contract. None of that exists, and a half-supported frame would + // be worse than no frame. + Some(BinaryRequestKind::Replicated) => Err(IggyError::FeatureUnavailable), + None => Err(IggyError::InvalidCommand), + }, + } +} + +/// Reject a declaration that disagrees with the class `operation` already +/// carries. Partition operations decode their namespace from a payload the SDK +/// must understand, so they are replicated but never reachable from a raw +/// request: `namespace_for_request` refuses an unrecognised code. +fn accept_declaration( + operation: Operation, + kind: Option<BinaryRequestKind>, +) -> Result<Operation, IggyError> { + let Some(declared) = kind else { + return Ok(operation); + }; + let standard = if operation == Operation::NonReplicated { + BinaryRequestKind::NonReplicated + } else { + BinaryRequestKind::Replicated + }; + if declared == standard { + Ok(operation) + } else { + Err(IggyError::InvalidCommand) } } @@ -460,6 +503,7 @@ mod tests { use iggy_binary_protocol::codes::{ CREATE_STREAM_CODE, GET_STREAM_CODE, LOGOUT_USER_CODE, PING_CODE, }; + use iggy_binary_protocol::dispatch::COMMAND_TABLE; use iggy_binary_protocol::requests::messages::SendMessagesHeader; use iggy_binary_protocol::requests::streams::CreateStreamRequest; use iggy_binary_protocol::requests::users::LoginRegisterRequest; @@ -467,10 +511,29 @@ mod tests { use iggy_binary_protocol::{ClientVersionInfo, WireEncode, WireName}; use secrecy::SecretString; + /// Outside every range the protocol assigns, so it stays unknown as the + /// command table grows. + const VENDOR_CODE: u32 = 60_001; + fn decode_request_header(bytes: &Bytes) -> RequestHeader { *bytemuck::checked::try_from_bytes::<RequestHeader>(&bytes[..HEADER_SIZE]).unwrap() } + fn read_non_replicated_code(header: &RequestHeader) -> u32 { + u32::from_le_bytes( + header.reserved[NON_REPLICATED_CODE_RANGE] + .try_into() + .unwrap(), + ) + } + + fn bound_session() -> ConsensusSession { + let mut session = ConsensusSession::with_client_id(42); + let _ = session.register_request_id(); + session.bind(99); + session + } + #[test] fn register_request_uses_zero_request_and_session() { let mut session = ConsensusSession::with_client_id(7); @@ -486,7 +549,7 @@ mod tests { }; let bytes = - encode_contiguous_request(&mut session, LOGIN_REGISTER_CODE, &request.to_bytes()) + encode_contiguous_request(&mut session, None, LOGIN_REGISTER_CODE, &request.to_bytes()) .unwrap(); let header = decode_request_header(&bytes); @@ -513,13 +576,14 @@ mod tests { }; let mut session = ConsensusSession::with_client_id(7); - encode_contiguous_request(&mut session, LOGIN_REGISTER_CODE, &request.to_bytes()).unwrap(); + encode_contiguous_request(&mut session, None, LOGIN_REGISTER_CODE, &request.to_bytes()) + .unwrap(); session.bind(42); // A second login on the same bound session must encode a fresh Register // (request 0, session 0), not panic in the one-shot register guard. let bytes = - encode_contiguous_request(&mut session, LOGIN_REGISTER_CODE, &request.to_bytes()) + encode_contiguous_request(&mut session, None, LOGIN_REGISTER_CODE, &request.to_bytes()) .unwrap(); let header = decode_request_header(&bytes); assert_eq!(header.operation, Operation::Register); @@ -618,8 +682,10 @@ mod tests { } .to_bytes(); - let first = encode_contiguous_request(&mut session, CREATE_STREAM_CODE, &payload).unwrap(); - let second = encode_contiguous_request(&mut session, CREATE_STREAM_CODE, &payload).unwrap(); + let first = + encode_contiguous_request(&mut session, None, CREATE_STREAM_CODE, &payload).unwrap(); + let second = + encode_contiguous_request(&mut session, None, CREATE_STREAM_CODE, &payload).unwrap(); assert_eq!(decode_request_header(&first).request, 1); assert_eq!(decode_request_header(&second).request, 2); @@ -631,18 +697,12 @@ mod tests { fn ping_uses_non_replicated_operation() { let mut session = ConsensusSession::with_client_id(42); session.bind(99); - let bytes = encode_contiguous_request(&mut session, PING_CODE, &Bytes::new()).unwrap(); + let bytes = + encode_contiguous_request(&mut session, None, PING_CODE, &Bytes::new()).unwrap(); let header = decode_request_header(&bytes); assert_eq!(header.operation, Operation::NonReplicated); - assert_eq!( - u32::from_le_bytes( - header.reserved[NON_REPLICATED_CODE_RANGE] - .try_into() - .unwrap() - ), - PING_CODE - ); + assert_eq!(read_non_replicated_code(&header), PING_CODE); assert_eq!(header.session, 99); assert_eq!(header.namespace, 0); } @@ -652,7 +712,7 @@ mod tests { let mut session = ConsensusSession::with_client_id(42); session.bind(99); let bytes = - encode_contiguous_request(&mut session, LOGOUT_USER_CODE, &Bytes::new()).unwrap(); + encode_contiguous_request(&mut session, None, LOGOUT_USER_CODE, &Bytes::new()).unwrap(); let header = decode_request_header(&bytes); assert_eq!(header.operation, Operation::Logout); @@ -668,18 +728,11 @@ mod tests { let mut session = ConsensusSession::with_client_id(42); session.bind(99); let bytes = - encode_contiguous_request(&mut session, GET_STREAM_CODE, &Bytes::new()).unwrap(); + encode_contiguous_request(&mut session, None, GET_STREAM_CODE, &Bytes::new()).unwrap(); let header = decode_request_header(&bytes); assert_eq!(header.operation, Operation::NonReplicated); - assert_eq!( - u32::from_le_bytes( - header.reserved[NON_REPLICATED_CODE_RANGE] - .try_into() - .unwrap() - ), - GET_STREAM_CODE - ); + assert_eq!(read_non_replicated_code(&header), GET_STREAM_CODE); assert_eq!(header.session, 99); } @@ -836,4 +889,150 @@ mod tests { let out = split_metadata_result(Operation::SendMessages, body.clone()).unwrap(); assert_eq!(out, body); } + + #[test] + fn vendor_non_replicated_code_encodes_with_the_code_in_reserved() { + let mut session = bound_session(); + let payload = Bytes::from_static(b"vendor-body"); + + let bytes = encode_contiguous_request( + &mut session, + Some(BinaryRequestKind::NonReplicated), + VENDOR_CODE, + &payload, + ) + .unwrap(); + let header = decode_request_header(&bytes); + + assert_eq!(header.operation, Operation::NonReplicated); + assert_eq!(read_non_replicated_code(&header), VENDOR_CODE); + assert_eq!(header.namespace, 0); + assert_eq!(header.session, 99); + assert_eq!(&bytes[HEADER_SIZE..], &payload[..]); + assert_eq!(header.size as usize, HEADER_SIZE + payload.len()); + } + + #[test] + fn vendor_non_replicated_code_does_not_advance_the_request_identifier() { + let mut session = bound_session(); + let payload = CreateStreamRequest { + name: WireName::new("stream").unwrap(), + } + .to_bytes(); + + for _ in 0..3 { + encode_contiguous_request( + &mut session, + Some(BinaryRequestKind::NonReplicated), + VENDOR_CODE, + &Bytes::new(), + ) + .unwrap(); + } + // The next replicated request must still claim id 1: a gap makes the + // primary's `request_preflight` drop it as a `RequestGap`. + let replicated = + encode_contiguous_request(&mut session, None, CREATE_STREAM_CODE, &payload).unwrap(); + + assert_eq!(decode_request_header(&replicated).request, 1); + } + + #[test] + fn vendor_replicated_code_is_unavailable_until_the_registry_exists() { + let mut session = bound_session(); + let error = encode_contiguous_request( + &mut session, + Some(BinaryRequestKind::Replicated), + VENDOR_CODE, + &Bytes::new(), + ) + .unwrap_err(); + + assert!(matches!(error, IggyError::FeatureUnavailable)); + } + + #[test] + fn vendor_code_without_a_declaration_stays_closed_world() { + let mut session = bound_session(); + let error = + encode_contiguous_request(&mut session, None, VENDOR_CODE, &Bytes::new()).unwrap_err(); + + assert!(matches!(error, IggyError::InvalidCommand)); + } + + #[test] + fn every_standard_code_accepts_its_own_class_and_rejects_the_other() { + for meta in COMMAND_TABLE { + let Ok(operation) = operation_for_code(meta.code, None) else { + // A replicated table entry with no `Operation` mapping stays + // unknown, and no declaration may rescue it. + for kind in [ + BinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated, + ] { + assert!( + matches!( + operation_for_code(meta.code, Some(kind)), + Err(IggyError::UnknownReplicatedCommand(_)) + ), + "{} must stay unknown when declared {kind}", + meta.name + ); + } + continue; + }; + let (matching, conflicting) = if operation == Operation::NonReplicated { + ( + BinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated, + ) + } else { + ( + BinaryRequestKind::Replicated, + BinaryRequestKind::NonReplicated, + ) + }; + + assert_eq!( + operation_for_code(meta.code, Some(matching)).unwrap(), + operation, + "{} must keep {operation:?} when declared {matching}", + meta.name + ); + assert!( + matches!( + operation_for_code(meta.code, Some(conflicting)), + Err(IggyError::InvalidCommand) + ), + "{} must not be redirected by a {conflicting} declaration", + meta.name + ); + } + } + + #[test] + fn logout_keeps_its_replicated_class_despite_the_table_entry() { + // The command table lists logout as non-replicated because classic + // framing runs it locally, but VSR commits it through `Operation::Logout`. + assert!( + matches!( + operation_for_code(LOGOUT_USER_CODE, Some(BinaryRequestKind::NonReplicated)), + Err(IggyError::InvalidCommand) + ), + "logout must not be reachable as a non-replicated raw request" + ); + assert_eq!( + operation_for_code(LOGOUT_USER_CODE, Some(BinaryRequestKind::Replicated)).unwrap(), + Operation::Logout + ); + } + + #[test] + fn vendor_replicated_code_cannot_reach_partition_routing() { + // Partition namespaces are decoded from a payload the SDK must + // understand, so no raw request can select one. + let error = + namespace_for_request(VENDOR_CODE, &Bytes::new(), Operation::SendMessages).unwrap_err(); + assert!(matches!(error, IggyError::FeatureUnavailable)); + } } diff --git a/core/sdk/src/websocket/websocket_client.rs b/core/sdk/src/websocket/websocket_client.rs index 537d68de2..3e76e8646 100644 --- a/core/sdk/src/websocket/websocket_client.rs +++ b/core/sdk/src/websocket/websocket_client.rs @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +use crate::binary::validate_binary_request_code; use crate::leader_aware::{LeaderRedirectionState, check_and_redirect_to_leader}; #[cfg(feature = "vsr")] use crate::session::ConsensusSession; @@ -39,7 +40,9 @@ use iggy_common::{ AutoLogin, ClientState, ConnectionString, Credentials, DiagnosticEvent, IggyDuration, IggyError, IggyTimestamp, WebSocketClientConfig, WebSocketConnectionStringOptions, }; -use iggy_common::{BinaryClient, BinaryTransport, PersonalAccessTokenClient, UserClient}; +use iggy_common::{ + BinaryClient, BinaryRequestKind, BinaryTransport, PersonalAccessTokenClient, UserClient, +}; use secrecy::ExposeSecret; use std::net::SocketAddr; use std::sync::Arc; @@ -136,61 +139,17 @@ impl BinaryTransport for WebSocketClient { } async fn send_raw_with_response(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError> { - let result = self.send_raw(code, payload.clone()).await; - if result.is_ok() { - return result; - } - - let error = result.unwrap_err(); - if !matches!( - error, - IggyError::Disconnected - | IggyError::EmptyResponse - | IggyError::Unauthenticated - | IggyError::StaleClient - | IggyError::NotConnected - | IggyError::CannotEstablishConnection - | IggyError::TcpError - | IggyError::ConnectionClosed - | IggyError::WebSocketSendError - | IggyError::WebSocketReceiveError - ) { - return Err(error); - } - - if !self.config.reconnection.enabled { - return Err(IggyError::Disconnected); - } - - #[cfg(feature = "vsr")] - if matches!(self.config.auto_login, AutoLogin::Disabled) { - return Err(error); - } - - self.disconnect().await?; - - #[cfg(feature = "vsr")] - let skip_auto_login = is_login_register_code(code); - #[cfg(feature = "vsr")] - if skip_auto_login { - *self.skip_auto_login_once.lock().await = true; - } - - { - let client_address = self.get_client_address_value().await; - info!( - "Reconnecting to the server: {} by client: {client_address}...", - self.config.server_address - ); - } + self.send_with_reconnect(None, code, payload).await + } - let reconnect = self.connect().await; - #[cfg(feature = "vsr")] - if skip_auto_login && reconnect.is_err() { - *self.skip_auto_login_once.lock().await = false; - } - reconnect?; - self.send_raw(code, payload).await + async fn send_raw_with_kind( + &self, + kind: BinaryRequestKind, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { + validate_binary_request_code(code)?; + self.send_with_reconnect(Some(kind), code, payload).await } fn get_heartbeat_interval(&self) -> IggyDuration { @@ -683,7 +642,78 @@ impl WebSocketClient { Ok(()) } - async fn send_raw(&self, code: u32, payload: Bytes) -> Result<Bytes, IggyError> { + /// Single reconnect-and-replay path shared by the typed and the raw entry + /// points, so `kind` survives the retry attempt unchanged. + async fn send_with_reconnect( + &self, + kind: Option<BinaryRequestKind>, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { + let result = self.send_raw(kind, code, payload.clone()).await; + if result.is_ok() { + return result; + } + + let error = result.unwrap_err(); + if !matches!( + error, + IggyError::Disconnected + | IggyError::EmptyResponse + | IggyError::Unauthenticated + | IggyError::StaleClient + | IggyError::NotConnected + | IggyError::CannotEstablishConnection + | IggyError::TcpError + | IggyError::ConnectionClosed + | IggyError::WebSocketSendError + | IggyError::WebSocketReceiveError + ) { + return Err(error); + } + + if !self.config.reconnection.enabled { + return Err(IggyError::Disconnected); + } + + #[cfg(feature = "vsr")] + if matches!(self.config.auto_login, AutoLogin::Disabled) { + return Err(error); + } + + self.disconnect().await?; + + #[cfg(feature = "vsr")] + let skip_auto_login = is_login_register_code(code); + #[cfg(feature = "vsr")] + if skip_auto_login { + *self.skip_auto_login_once.lock().await = true; + } + + { + let client_address = self.get_client_address_value().await; + info!( + "Reconnecting to the server: {} by client: {client_address}...", + self.config.server_address + ); + } + + let reconnect = self.connect().await; + #[cfg(feature = "vsr")] + if skip_auto_login && reconnect.is_err() { + *self.skip_auto_login_once.lock().await = false; + } + reconnect?; + self.send_raw(kind, code, payload).await + } + + #[cfg_attr(not(feature = "vsr"), expect(unused_variables))] + async fn send_raw( + &self, + kind: Option<BinaryRequestKind>, + code: u32, + payload: Bytes, + ) -> Result<Bytes, IggyError> { match self.get_state().await { ClientState::Shutdown => { trace!("Cannot send data. Client is shutdown."); @@ -720,7 +750,7 @@ impl WebSocketClient { .consensus_session .lock() .expect("consensus session mutex poisoned"); - crate::vsr::encode_contiguous_request(&mut consensus_session, code, &payload)? + crate::vsr::encode_contiguous_request(&mut consensus_session, kind, code, &payload)? }; trace!( "Sending {NAME} VSR request of size {} with code: {code}", @@ -875,6 +905,27 @@ mod tests { use super::*; use std::str::FromStr; + #[tokio::test] + async fn raw_binary_transport_rejects_session_control_before_io() { + let client = + WebSocketClient::from_connection_string("iggy+ws://iggy:[email protected]:8092").unwrap(); + + for kind in [ + BinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated, + ] { + let error = client + .send_raw_with_kind( + kind, + iggy_binary_protocol::codes::LOGIN_USER_CODE, + Bytes::new(), + ) + .await + .unwrap_err(); + assert!(matches!(error, IggyError::InvalidCommand)); + } + } + #[test] fn should_be_created_with_default_config() { let client = WebSocketClient::default(); diff --git a/examples/python/uv.lock b/examples/python/uv.lock index 639a0f563..a33c0238f 100644 --- a/examples/python/uv.lock +++ b/examples/python/uv.lock @@ -8,7 +8,7 @@ exclude-newer-span = "P7D" [[package]] name = "apache-iggy" -version = "0.8.1.dev3" +version = "0.8.1.dev4" source = { directory = "../../foreign/python" } [package.metadata] diff --git a/foreign/python/Cargo.toml b/foreign/python/Cargo.toml index 2dec15c20..c6e10b396 100644 --- a/foreign/python/Cargo.toml +++ b/foreign/python/Cargo.toml @@ -17,7 +17,7 @@ [package] name = "apache-iggy" -version = "0.8.1-dev3" +version = "0.8.1-dev4" edition = "2024" authors = ["Iggy Committers <[email protected]>"] license = "Apache-2.0" @@ -34,10 +34,13 @@ name = "stub_gen" path = "src/bin/stub_gen.rs" doc = false +[features] +vsr = ["iggy/vsr"] + [dependencies] bytes = "1.12.1" futures = "0.3.33" -iggy = { path = "../../core/sdk", version = "0.10.3-edge.2" } +iggy = { path = "../../core/sdk", version = "0.10.3-edge.3" } pyo3 = "0.29.0" pyo3-async-runtimes = { version = "0.29.0", features = [ "attributes", diff --git a/foreign/python/README.md b/foreign/python/README.md index f01754f6d..caf4740d0 100644 --- a/foreign/python/README.md +++ b/foreign/python/README.md @@ -58,6 +58,36 @@ maturin develop pytest tests/ -v # Run tests (requires iggy-server running) ``` +### VSR builds + +The Python extension uses the Rust SDK's compile-time framing mode. The default +build speaks classic framing. Build a VSR extension explicitly: + +```bash +uv run maturin develop --features vsr +``` + +A built wheel contains one framing mode; it cannot switch between classic and +VSR at runtime. + +Raw requests retain the compatibility method and provide an explicit extension +path: + +```python +from apache_iggy import BinaryRequestKind + +response = await client.send_binary_request(code, payload) +response = await client.send_binary_request_with_kind( + BinaryRequestKind.NonReplicated, + code, + payload, +) +``` + +With VSR framing, standard command tables remain authoritative. Unknown +non-replicated codes are forwarded for the server to judge. Unknown replicated +codes are unavailable until the protocol has a replicated extension registry. + ## Examples Refer to the [examples/python/](https://github.com/apache/iggy/tree/master/examples/python) directory for usage examples. diff --git a/foreign/python/apache_iggy.pyi b/foreign/python/apache_iggy.pyi index 5b980c2e0..1a432ae46 100644 --- a/foreign/python/apache_iggy.pyi +++ b/foreign/python/apache_iggy.pyi @@ -29,6 +29,7 @@ __all__ = [ "AutoCommit", "AutoCommitAfter", "AutoCommitWhen", + "BinaryRequestKind", "ConsumerGroup", "ConsumerGroupDetails", "ConsumerGroupMember", @@ -744,6 +745,27 @@ class IggyClient: Raises: PyRuntimeError: If the command cannot be sent or the server returns an error. """ + def send_binary_request_with_kind( + self, kind: BinaryRequestKind, code: builtins.int, payload: builtins.bytes + ) -> collections.abc.Awaitable[bytes]: + r""" + Send a raw command with an explicit replication declaration. + + The declaration is inert on classic framing. With a VSR build, standard + command tables remain authoritative, unknown non-replicated codes are + forwarded, and unknown replicated codes are unavailable. + + Args: + kind: How the command executes, as `BinaryRequestKind`. + code: Command code as `int`. + payload: Request payload as `bytes`. + + Returns: + An awaitable that resolves to the raw response `bytes`. + + Raises: + PyRuntimeError: If the command cannot be sent or the server returns an error. + """ @typing.final class IggyConsumer: @@ -1018,6 +1040,24 @@ class UserInfoDetails: The username of the user. """ [email protected] +class BinaryRequestKind(enum.Enum): + r""" + How a raw binary request executes on the server. + """ + + NonReplicated = ... + r""" + Runs on the receiving node only, outside consensus. + """ + Replicated = ... + r""" + Replicated through consensus before it takes effect. Inert on classic + framing, where both kinds encode identical bytes; under `vsr` an + unknown code declared this way is rejected until the server grows a + replicated extension registry. + """ + @typing.final class UserStatus(enum.Enum): r""" diff --git a/foreign/python/pyproject.toml b/foreign/python/pyproject.toml index d7f878885..d062ec1e7 100644 --- a/foreign/python/pyproject.toml +++ b/foreign/python/pyproject.toml @@ -22,7 +22,7 @@ build-backend = "maturin" [project] name = "apache-iggy" requires-python = ">=3.10" -version = "0.8.1.dev3" +version = "0.8.1.dev4" description = "Apache Iggy is the persistent message streaming platform written in Rust, supporting QUIC, TCP and HTTP transport protocols, capable of processing millions of messages per second." readme = "README.md" license = { file = "LICENSE" } @@ -141,6 +141,7 @@ addopts = ["-v", "--strict-markers", "--tb=short", "--disable-warnings"] markers = [ "integration: Integration tests that require a running server", "unit: Fast unit tests", + "vsr: Integration tests that require a VSR-built extension and server", ] filterwarnings = [ "ignore::DeprecationWarning", diff --git a/foreign/python/src/binary_request_kind.rs b/foreign/python/src/binary_request_kind.rs new file mode 100644 index 000000000..796fe140a --- /dev/null +++ b/foreign/python/src/binary_request_kind.rs @@ -0,0 +1,52 @@ +// 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. + +use iggy::prelude::BinaryRequestKind as RustBinaryRequestKind; +use pyo3::prelude::*; +use pyo3_stub_gen::derive::gen_stub_pyclass_enum; + +/// How a raw binary request executes on the server. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[gen_stub_pyclass_enum] +#[pyclass(eq, from_py_object)] +pub enum BinaryRequestKind { + /// Runs on the receiving node only, outside consensus. + NonReplicated, + /// Replicated through consensus before it takes effect. Inert on classic + /// framing, where both kinds encode identical bytes; under `vsr` an + /// unknown code declared this way is rejected until the server grows a + /// replicated extension registry. + Replicated, +} + +impl From<BinaryRequestKind> for RustBinaryRequestKind { + fn from(kind: BinaryRequestKind) -> Self { + match kind { + BinaryRequestKind::NonReplicated => RustBinaryRequestKind::NonReplicated, + BinaryRequestKind::Replicated => RustBinaryRequestKind::Replicated, + } + } +} + +impl From<RustBinaryRequestKind> for BinaryRequestKind { + fn from(kind: RustBinaryRequestKind) -> Self { + match kind { + RustBinaryRequestKind::NonReplicated => BinaryRequestKind::NonReplicated, + RustBinaryRequestKind::Replicated => BinaryRequestKind::Replicated, + } + } +} diff --git a/foreign/python/src/client.rs b/foreign/python/src/client.rs index b860f19b5..27f2d4f71 100644 --- a/foreign/python/src/client.rs +++ b/foreign/python/src/client.rs @@ -17,7 +17,8 @@ use bytes::Bytes; use iggy::prelude::{ - Consumer as RustConsumer, IggyClient as RustIggyClient, IggyMessage as RustMessage, + BinaryRequestKind as RustBinaryRequestKind, Consumer as RustConsumer, + IggyClient as RustIggyClient, IggyMessage as RustMessage, PollingStrategy as RustPollingStrategy, *, }; use pyo3::PyRef; @@ -29,6 +30,7 @@ use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pymethods}; use std::str::FromStr; use std::sync::Arc; +use crate::binary_request_kind::BinaryRequestKind; use crate::consumer::{ AutoCommit, ConsumerGroup as PyConsumerGroup, ConsumerGroupDetails as PyConsumerGroupDetails, IggyConsumer, py_delta_to_iggy_duration, @@ -999,6 +1001,41 @@ impl IggyClient { Ok(Python::attach(|py| PyBytes::new(py, &response).unbind())) }) } + + /// Send a raw command with an explicit replication declaration. + /// + /// The declaration is inert on classic framing. With a VSR build, standard + /// command tables remain authoritative, unknown non-replicated codes are + /// forwarded, and unknown replicated codes are unavailable. + /// + /// Args: + /// kind: How the command executes, as `BinaryRequestKind`. + /// code: Command code as `int`. + /// payload: Request payload as `bytes`. + /// + /// Returns: + /// An awaitable that resolves to the raw response `bytes`. + /// + /// Raises: + /// PyRuntimeError: If the command cannot be sent or the server returns an error. + #[gen_stub(override_return_type(type_repr="collections.abc.Awaitable[bytes]", imports=("collections.abc")))] + fn send_binary_request_with_kind<'a>( + &self, + py: Python<'a>, + kind: BinaryRequestKind, + code: u32, + #[gen_stub(override_type(type_repr = "builtins.bytes"))] payload: Vec<u8>, + ) -> PyResult<Bound<'a, PyAny>> { + let inner = self.inner.clone(); + let kind = RustBinaryRequestKind::from(kind); + future_into_py(py, async move { + let response = inner + .send_binary_request_with_kind(kind, code, Bytes::from(payload)) + .await + .map_err(|e| PyErr::new::<pyo3::exceptions::PyRuntimeError, _>(e.to_string()))?; + Ok(Python::attach(|py| PyBytes::new(py, &response).unbind())) + }) + } } define_stub_info_gatherer!(stub_info); diff --git a/foreign/python/src/lib.rs b/foreign/python/src/lib.rs index f831fd703..be458ced6 100644 --- a/foreign/python/src/lib.rs +++ b/foreign/python/src/lib.rs @@ -15,6 +15,7 @@ // specific language governing permissions and limitations // under the License. +mod binary_request_kind; pub mod client; mod consumer; mod identifier; @@ -24,6 +25,7 @@ mod stream; mod topic; mod user; +use binary_request_kind::BinaryRequestKind; use client::IggyClient; use consumer::{ AutoCommit, AutoCommitAfter, AutoCommitWhen, ConsumerGroup, ConsumerGroupDetails, @@ -57,5 +59,6 @@ fn apache_iggy(_py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::<UserStatus>()?; m.add_class::<UserInfo>()?; m.add_class::<UserInfoDetails>()?; + m.add_class::<BinaryRequestKind>()?; Ok(()) } diff --git a/foreign/python/tests/test_raw_command.py b/foreign/python/tests/test_raw_command.py index 3313b6b2e..571a3c9ec 100644 --- a/foreign/python/tests/test_raw_command.py +++ b/foreign/python/tests/test_raw_command.py @@ -15,9 +15,17 @@ # specific language governing permissions and limitations # under the License. +from typing import cast + import pytest -from apache_iggy import IggyClient +from apache_iggy import BinaryRequestKind, IggyClient + +KINDS = [BinaryRequestKind.NonReplicated, BinaryRequestKind.Replicated] + +# No server registers a handler for this code, and it sits past every range the +# protocol assigns. +VENDOR_CODE = 60_001 @pytest.mark.asyncio @@ -34,6 +42,31 @@ async def test_raw_get_stats_returns_non_empty_response(iggy_client: IggyClient) assert response [email protected] +async def test_raw_replicated_declaration_is_ignored_on_classic_framing( + iggy_client: IggyClient, +): + # The kind is inert on classic framing, so a replicated declaration on a + # standard command still succeeds. + response = await iggy_client.send_binary_request_with_kind( + BinaryRequestKind.Replicated, 10, b"" + ) + + assert response + + [email protected] +async def test_raw_undefined_kind_is_rejected_before_sending( + iggy_client: IggyClient, +): + # The cast defeats the static signature on purpose: the runtime boundary + # itself must reject a value that is not a BinaryRequestKind. + with pytest.raises(TypeError): + await iggy_client.send_binary_request_with_kind( + cast(BinaryRequestKind, "auto"), 1, b"" + ) + + @pytest.mark.asyncio @pytest.mark.parametrize("code", [38, 39, 40, 44, 45]) async def test_raw_session_control_code_is_rejected(iggy_client: IggyClient, code: int): @@ -42,6 +75,29 @@ async def test_raw_session_control_code_is_rejected(iggy_client: IggyClient, cod @pytest.mark.asyncio -async def test_raw_unknown_code_is_rejected_by_server(iggy_client: IggyClient): [email protected]("kind", KINDS) [email protected]("code", [38, 39, 40, 44, 45]) +async def test_raw_session_control_code_with_kind_is_rejected( + iggy_client: IggyClient, kind: BinaryRequestKind, code: int +): + with pytest.raises(RuntimeError, match="(?i)invalid command"): + await iggy_client.send_binary_request_with_kind(kind, code, b"") + + [email protected] +async def test_raw_vendor_code_is_rejected_by_server(iggy_client: IggyClient): with pytest.raises(RuntimeError, match="(?i)invalid command"): - await iggy_client.send_binary_request(60_000, b"") + await iggy_client.send_binary_request_with_kind( + BinaryRequestKind.NonReplicated, VENDOR_CODE, b"" + ) + + # The rejection is request-level, so the connection stays usable. + assert await iggy_client.send_binary_request(1, b"") == b"" + + [email protected] +async def test_raw_replicated_vendor_code_has_no_handler(iggy_client: IggyClient): + with pytest.raises(RuntimeError): + await iggy_client.send_binary_request_with_kind( + BinaryRequestKind.Replicated, VENDOR_CODE, b"" + ) diff --git a/foreign/python/tests/test_raw_command_vsr.py b/foreign/python/tests/test_raw_command_vsr.py new file mode 100644 index 000000000..7436f345f --- /dev/null +++ b/foreign/python/tests/test_raw_command_vsr.py @@ -0,0 +1,70 @@ +# 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. + +import os + +import pytest + +from apache_iggy import BinaryRequestKind, IggyClient + +VENDOR_CODE = 60_001 + +pytestmark = [ + pytest.mark.asyncio, + pytest.mark.vsr, + pytest.mark.skipif( + os.getenv("IGGY_TEST_PROTOCOL") != "vsr", + reason="requires a VSR-built Python extension and VSR server", + ), +] + + +async def test_vsr_raw_request_kinds_preserve_session_sequence( + iggy_client: IggyClient, unique_name +): + assert await iggy_client.send_binary_request(1, b"") == b"" + assert ( + await iggy_client.send_binary_request_with_kind( + BinaryRequestKind.NonReplicated, 1, b"" + ) + == b"" + ) + + for _ in range(3): + with pytest.raises(RuntimeError, match="(?i)invalid command"): + await iggy_client.send_binary_request_with_kind( + BinaryRequestKind.NonReplicated, VENDOR_CODE, b"vendor-body" + ) + + with pytest.raises(RuntimeError, match="(?i)feature.*unavailable"): + await iggy_client.send_binary_request_with_kind( + BinaryRequestKind.Replicated, VENDOR_CODE, b"vendor-body" + ) + + with pytest.raises(RuntimeError, match="(?i)invalid command"): + await iggy_client.send_binary_request_with_kind( + BinaryRequestKind.Replicated, 10, b"" + ) + + with pytest.raises(RuntimeError, match="(?i)invalid command"): + await iggy_client.send_binary_request_with_kind( + BinaryRequestKind.NonReplicated, 38, b"" + ) + + # A metadata mutation must still use request ID 1. Advancing the counter + # for any non-replicated request would create a gap and stall this call. + await iggy_client.create_stream(unique_name("raw-vsr-")) diff --git a/foreign/python/uv.lock b/foreign/python/uv.lock index 55960c278..72fc9918d 100644 --- a/foreign/python/uv.lock +++ b/foreign/python/uv.lock @@ -12,7 +12,7 @@ exclude-newer-span = "P7D" [[package]] name = "apache-iggy" -version = "0.8.1.dev3" +version = "0.8.1.dev4" source = { editable = "." } [package.optional-dependencies]
