diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d99a20fa..bb4bbc8fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,8 @@ jobs: test: strategy: fail-fast: false - # The test suite doesn't support concurrent runs. + # Each row spins up its own Docker container via testcontainers, so + # keep them sequential to avoid contention on a single runner. max-parallel: 1 matrix: include: @@ -88,8 +89,52 @@ jobs: toolchain: ${{ env.rust_version }} - run: sudo apt-get update - run: sudo apt-get install -y libcurl4-openssl-dev - # - run: sudo apt-get install -qy valgrind # Valgrind currently disabled in testing - - run: ./test_suite.sh + # `--features zstd` so the zstd compression round-trip test in + # `tests/future_producer.rs` actually links zstd. The default build + # passes `--disable-zstd` to librdkafka (see rdkafka-sys/build.rs), + # which would make a `compression.type=zstd` producer reject the + # config at creation. + - run: cargo test --features zstd env: KAFKA_VERSION: ${{ matrix.kafka-version }} - TERM: xterm-256color + RUST_LOG: off + RUST_BACKTRACE: 1 + + # Smoke-test the smol and async-std runtime examples against a real + # broker. The integration suite covers the tokio path via testcontainers; + # this job catches breakage in the alternative runtimes that + # `cargo build --all-targets` would miss. + runtime-examples: + runs-on: ubuntu-24.04 + services: + kafka: + image: apache/kafka:4.0.2 + ports: + - 9092:9092 + env: + KAFKA_NODE_ID: 1 + KAFKA_PROCESS_ROLES: broker,controller + KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 + KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + options: >- + --health-cmd "/opt/kafka/bin/kafka-topics.sh --bootstrap-server localhost:9092 --list" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + --health-start-period 30s + steps: + - uses: actions/checkout@v4 + - uses: lukka/get-cmake@latest + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.rust_version }} + - run: sudo apt-get update + - run: sudo apt-get install -y libcurl4-openssl-dev + - run: cargo run --example runtime_smol --no-default-features --features cmake-build -- --topic smol + - run: cargo run --example runtime_async_std --no-default-features --features cmake-build -- --topic async-std diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 987ad1ce2..5c15fa28a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,27 +26,27 @@ The unit tests can run without a Kafka broker present: cargo test --lib ``` -### Automatic testing +### Integration tests -rust-rdkafka contains a suite of tests which is automatically executed by travis in -docker-compose. Given the interaction with C code that rust-rdkafka has to do, tests -are executed in valgrind to check eventual memory errors and leaks. - -To run the full suite using docker-compose: +The integration tests start their own Kafka broker via +[testcontainers-rs], so all you need locally is a running Docker daemon +and the usual Rust toolchain: ```bash -./test_suite.sh +cargo test ``` -To run locally, instead: +To pick a specific Kafka version (default `4.0`), set `KAFKA_VERSION`: ```bash -KAFKA_HOST="kafka_server:9092" cargo test +KAFKA_VERSION=3.9 cargo test ``` -In this case there is a broker expected to be running on `KAFKA_HOST`. -The broker must be configured with default partition number 3 and topic -autocreation in order for the tests to succeed. +For the full walkthrough, including how the shared broker is wired up, +how to add a new test, the helper cheatsheet, and known quirks, see +[`tests/README.md`](tests/README.md). + +[testcontainers-rs]: https://github.com/testcontainers/testcontainers-rs ## Releasing diff --git a/Cargo.lock b/Cargo.lock index ac80a1dd6..89353adaf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -43,39 +43,45 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys 0.60.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "async-attributes" version = "1.1.2" @@ -111,9 +117,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -169,9 +175,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener 5.4.1", "event-listener-strategy", @@ -209,9 +215,9 @@ dependencies = [ [[package]] name = "async-signal" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ "async-io", "async-lock", @@ -258,6 +264,17 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -285,13 +302,19 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bindgen" version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools", @@ -300,14 +323,20 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.109", + "syn 2.0.117", ] [[package]] name = "bitflags" -version = "2.10.0" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "blocking" @@ -322,11 +351,61 @@ dependencies = [ "piper", ] +[[package]] +name = "bollard" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +dependencies = [ + "base64 0.22.1", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.47.1-rc.27.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -334,11 +413,17 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "cc" -version = "1.2.45" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -363,13 +448,14 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -387,18 +473,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -408,24 +494,24 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "concurrent-queue" @@ -436,6 +522,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -468,9 +564,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "curl-sys" -version = "0.4.80+curl-8.12.1" +version = "0.4.88+curl-8.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f7df2eac63200c3ab25bde3b2268ef2ee56af3d238e76d61f01c3c49bff734" +checksum = "644816de6547255eff4e491a1dda1c19b7237f00b62a61e6e64859ce4f2906d0" dependencies = [ "cc", "libc", @@ -478,7 +574,74 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64 0.22.1", + "serde", + "serde_json", ] [[package]] @@ -493,17 +656,23 @@ dependencies = [ "shared_child", ] +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "env_filter" -version = "0.1.4" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" dependencies = [ "log", "regex", @@ -511,9 +680,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" dependencies = [ "anstream", "anstyle", @@ -535,7 +704,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "etcetera" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26c7b13d0780cb82722fd59f6f57f925e143427e4a75313a6c77243bf5326ae6" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.59.0", ] [[package]] @@ -567,31 +747,56 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] [[package]] name = "find-msvc-tools" -version = "0.1.4" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -604,9 +809,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -614,15 +819,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -631,9 +836,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -650,32 +855,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -685,10 +890,20 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -721,9 +936,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hdrhistogram" @@ -731,7 +952,7 @@ version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" dependencies = [ - "base64", + "base64 0.21.7", "byteorder", "crossbeam-channel", "flate2", @@ -746,168 +967,448 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] -name = "iana-time-zone" -version = "0.1.64" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "home" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "cc", + "windows-sys 0.59.0", ] [[package]] -name = "indexmap" -version = "2.12.0" +name = "http" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ - "equivalent", - "hashbrown", + "bytes", + "itoa", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.2" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] [[package]] -name = "itertools" -version = "0.13.0" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "either", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "itoa" -version = "1.0.15" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "jiff" -version = "0.2.16" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49cce2b81f2098e7e3efc35bc2e0a6b7abec9d34128283d7a26fa8f32a6dbb35" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", -] +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "jiff-static" -version = "0.2.16" +name = "hyper" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980af8b43c3ad5d8d349ace167ec8170839f753a42d233ba19e08afe1850fa69" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.109", + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", ] [[package]] -name = "jobserver" -version = "0.1.34" +name = "hyper-named-pipe" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ - "getrandom", - "libc", + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", ] [[package]] -name = "js-sys" -version = "0.3.82" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "once_cell", - "wasm-bindgen", + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] -name = "krb5-src" -version = "0.3.4" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a04b2f0dfefd7b54af22d22768dc3ab1ed5e55e6d5ff6af3f619069aa17c0ba4" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "duct", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] -name = "kv-log-macro" -version = "1.0.7" +name = "hyperlocal" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ - "log", + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", ] [[package]] -name = "libc" -version = "0.2.177" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] [[package]] -name = "libloading" -version = "0.8.9" +name = "iana-time-zone-haiku" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ - "cfg-if", - "windows-link", + "cc", ] [[package]] -name = "libz-sys" -version = "1.1.22" +name = "icu_collections" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "linux-raw-sys" -version = "0.11.0" +name = "icu_locale_core" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "93cca704c2d63cf8a91f5c2c5f88e027940dede132319b85a52939db9758f7e5" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] [[package]] -name = "log" -version = "0.4.28" +name = "icu_normalizer" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "8b24a59706036ba941c9476a55cd57b82b77f38a3c667d637ee7cabbc85eaedc" dependencies = [ - "value-bag", + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "lz4-sys" -version = "1.11.1+lz4-1.10.0" +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5a97b8ac6235e69506e8dacfb2adf38461d2ce6d3e9bd9c94c4cbc3cd4400a4" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "krb5-src" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a04b2f0dfefd7b54af22d22768dc3ab1ed5e55e6d5ff6af3f619069aa17c0ba4" +dependencies = [ + "duct", +] + +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libz-sys" +version = "1.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" dependencies = [ @@ -923,9 +1424,9 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "minimal-lexical" @@ -943,6 +1444,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "nom" version = "7.1.3" @@ -953,6 +1465,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-traits" version = "0.2.19" @@ -964,9 +1482,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -974,21 +1492,21 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.117", ] [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -996,20 +1514,26 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-src" -version = "300.5.4+3.5.4" +version = "300.6.0+3.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.110" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -1025,7 +1549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1034,11 +1558,42 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.117", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -1048,9 +1603,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -1059,9 +1614,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "polling" @@ -1079,19 +1634,34 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1103,27 +1673,27 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -1136,9 +1706,9 @@ checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core", @@ -1156,17 +1726,18 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom", + "getrandom 0.3.4", ] [[package]] name = "rdkafka" version = "0.39.0" dependencies = [ + "anyhow", "async-std", "backon", "chrono", @@ -1188,6 +1759,7 @@ dependencies = [ "serde_json", "slab", "smol", + "testcontainers-modules", "tokio", "tracing", ] @@ -1208,11 +1780,40 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "redox_syscall" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -1222,9 +1823,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1233,27 +1834,96 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -1264,9 +1934,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "sasl2-sys" @@ -1281,6 +1951,62 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.228" @@ -1292,36 +2018,91 @@ dependencies = [ ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5" dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde", "serde_derive", + "serde_json", + "serde_with_macros", + "time", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "serde_with_macros" +version = "3.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f" dependencies = [ + "darling", "proc-macro2", "quote", - "syn 2.0.109", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", + "syn 2.0.117", ] [[package]] @@ -1364,24 +2145,31 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "smol" @@ -1400,12 +2188,57 @@ dependencies = [ "futures-lite", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.117", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -1419,52 +2252,217 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.109" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "testcontainers" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bb7577dca13ad86a78e8271ef5d322f37229ec83b8d98da6d996c588a1ddb1" +dependencies = [ + "async-trait", + "bollard", + "bollard-stubs", + "bytes", + "docker_credential", + "either", + "etcetera", + "futures", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tokio", + "tokio-stream", + "tokio-tar", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac95cde96549fc19c6bf19ef34cc42bd56e264c1cb97e700e21555be0ecf9e2" +dependencies = [ + "testcontainers", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tokio" -version = "1.48.0" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tar" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5714c010ca3e5c27114c1cdeb9d14641ace49874aa5626d7149e47aedace75" +dependencies = [ + "filetime", + "futures-core", + "libc", + "redox_syscall", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", ] [[package]] name = "toml_datetime" -version = "0.7.3" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -1472,18 +2470,24 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -1492,29 +2496,59 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" @@ -1524,9 +2558,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "value-bag" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943ce29a8a743eb10d6082545d861b24f9d1b160b7d741e0f2cdf726bec909c5" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" [[package]] name = "vcpkg" @@ -1534,20 +2568,35 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -1558,22 +2607,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1581,36 +2627,48 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] [[package]] -name = "web-sys" -version = "0.3.82" +name = "winapi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ - "js-sys", - "wasm-bindgen", + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", ] +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" @@ -1632,7 +2690,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.117", ] [[package]] @@ -1643,7 +2701,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.117", ] [[package]] @@ -1679,6 +2737,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -1828,39 +2895,146 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.117", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zstd-sys" version = "2.0.16+zstd.1.5.7" diff --git a/Cargo.toml b/Cargo.toml index 0a88cf44b..e3f56330f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,8 @@ rand = "0.9.1" regex = "1.11.1" smol = "2.0.2" tokio = { version = "1.18", features = ["macros", "rt-multi-thread", "time"] } +testcontainers-modules = { version = "0.12.1", features = ["kafka"] } +anyhow = { version = "1.0.100" } # These features are re-exports of the features that the rdkafka-sys crate # provides. See the rdkafka-sys documentation for details. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index d7047b995..000000000 --- a/Dockerfile +++ /dev/null @@ -1,30 +0,0 @@ -FROM ubuntu:24.10 - -RUN apt-get update && apt-get install -y build-essential \ - curl \ - openssl libssl-dev \ - pkg-config \ - python \ - valgrind \ - zlib1g-dev - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain 1.74 -ENV PATH=/root/.cargo/bin/:$PATH - -# # Create dummy project for rdkafka -# COPY Cargo.toml /rdkafka/ -# RUN mkdir -p /rdkafka/src && echo "fn main() {}" > /rdkafka/src/main.rs -# -# # Create dummy project for rdkafka -# RUN mkdir /rdkafka/rdkafka-sys -# COPY rdkafka-sys/Cargo.toml /rdkafka/rdkafka-sys -# RUN mkdir -p /rdkafka/rdkafka-sys/src && touch /rdkafka/rdkafka-sys/src/lib.rs -# RUN echo "fn main() {}" > /rdkafka/rdkafka-sys/build.rs -# -# RUN cd /rdkafka && test --no-run - -COPY docker/run_tests.sh /rdkafka/ - -ENV KAFKA_HOST=kafka:9092 - -WORKDIR /rdkafka diff --git a/coverage.sh b/coverage.sh deleted file mode 100755 index 83ec20a81..000000000 --- a/coverage.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash - -GREEN='\033[0;32m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -INCLUDE="/src" -EXCLUDE="/.cargo,rdkafka-sys/librdkafka,rdkafka-sys/src/bindings" -TARGET="target/cov" - -KCOV_ARGS="--include-pattern=$INCLUDE --exclude-pattern=$EXCLUDE --verify $TARGET" - -RDKAFKA_UNIT_TESTS="target/debug/rdkafka-" -RDKAFKASYS_UNIT_TESTS="rdkafka-sys/target/debug/rdkafka_sys-" -INTEGRATION_TESTS="target/debug/test_" - -export RUSTFLAGS="-C link-dead-code" - -echo -e "${GREEN}*** Clean previous coverage results and executables ***${NC}" -rm -rf "$TARGET" -rm -f "$RDKAFKA_UNIT_TESTS"* -rm -f "$RDKAFKASYS_UNIT_TESTS"* -rm -f "$INTEGRATION_TESTS"* - -echo -e "${GREEN}*** Rebuilding tests ***${NC}" -cargo test --no-run -pushd rdkafka-sys && cargo test --no-run && popd - -echo -e "${GREEN}*** Run coverage on rdkafka unit tests ***${NC}" -for test_file in `ls "$RDKAFKA_UNIT_TESTS"*` -do - if [[ ! -x "$test_file" ]]; then - continue - fi - kcov $KCOV_ARGS "$test_file" - if [ "$?" != "0" ]; then - echo -e "${RED}*** Failure during unit test converage ***${NC}" - exit 1 - fi -done - -echo -e "${GREEN}*** Run coverage on rdkafka-sys unit tests ***${NC}" -for test_file in `ls "$RDKAFKASYS_UNIT_TESTS"*` -do - if [[ ! -x "$test_file" ]]; then - continue - fi - kcov $KCOV_ARGS "$test_file" - if [ "$?" != "0" ]; then - echo -e "${RED}*** Failure during rdkafka-sys unit test converage ***${NC}" - exit 1 - fi -done - -echo -e "${GREEN}*** Run coverage on rdkafka integration tests ***${NC}" -for test_file in `ls "$INTEGRATION_TESTS"*` -do - if [[ ! -x "$test_file" ]]; then - continue - fi - echo -e "${GREEN}Executing "$test_file"${NC}" - kcov $KCOV_ARGS "$test_file" - if [ "$?" != "0" ]; then - echo -e "${RED}*** Failure during integration converage ***${NC}" - exit 1 - fi -done - -echo -e "${GREEN}*** Coverage completed successfully ***${NC}" diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index dcbd43155..000000000 --- a/docker-compose.yaml +++ /dev/null @@ -1,23 +0,0 @@ -services: - kafka: - image: bitnamilegacy/kafka:${KAFKA_VERSION:-4.0} - environment: - # Enable KRaft mode (combined broker and controller) - - KAFKA_CFG_NODE_ID=0 - - KAFKA_CFG_BROKER_ID=0 # In KRaft, this should be the same as the node ID - - KAFKA_CFG_PROCESS_ROLES=broker,controller - - KAFKA_CFG_CONTROLLER_LISTENER_NAMES=CONTROLLER - - KAFKA_CFG_LISTENERS=PLAINTEXT://:9092,CONTROLLER://:9093 - - KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://localhost:9092 - - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP=CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT - - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS=0@localhost:9093 - - # Bitnami defaults - - KAFKA_CFG_OFFSETS_TOPIC_REPLICATION_FACTOR=1 - - KAFKA_CFG_TRANSACTION_STATE_LOG_REPLICATION_FACTOR=1 - - KAFKA_CFG_TRANSACTION_STATE_LOG_MIN_ISR=1 - - KAFKA_CFG_NUM_PARTITIONS=3 - - # This is a Bitnami-specific variable to disable ZooKeeper - - KAFKA_KRAFT_ENABLED=true - ports: ["9092:9092"] diff --git a/rdkafka.suppressions b/rdkafka.suppressions deleted file mode 100644 index ed248e58b..000000000 --- a/rdkafka.suppressions +++ /dev/null @@ -1,22 +0,0 @@ -# Valgrind suppression file. - -# Spurious statx complaints: https://github.com/rust-lang/rust/issues/68979. -# TODO(benesch): remove when CI upgrades to Valgrind 3.16. -{ - - Memcheck:Param - statx(file_name) - fun:statx - fun:statx - fun:_ZN3std3sys4unix2fs9try_statx* - ... -} -{ - - Memcheck:Param - statx(buf) - fun:statx - fun:statx - fun:_ZN3std3sys4unix2fs9try_statx* - ... -} diff --git a/src/consumer/base_consumer.rs b/src/consumer/base_consumer.rs index a25e36611..f69ccffc0 100644 --- a/src/consumer/base_consumer.rs +++ b/src/consumer/base_consumer.rs @@ -1,6 +1,7 @@ //! Low-level consumers. use std::ffi::{CStr, CString}; +use std::fmt; use std::mem::ManuallyDrop; use std::os::raw::c_void; use std::ptr; @@ -41,6 +42,20 @@ where nonempty_callback: Option>>, } +impl fmt::Debug for BaseConsumer +where + C: ConsumerContext, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BaseConsumer") + .field("native_ptr", &self.client.native_ptr()) + .field("queue", &self.queue) + .field("group_id", &self.group_id) + .field("has_nonempty_callback", &self.nonempty_callback.is_some()) + .finish() + } +} + impl FromClientConfig for BaseConsumer { fn from_config(config: &ClientConfig) -> KafkaResult { BaseConsumer::from_config_and_context(config, DefaultConsumerContext) diff --git a/src/producer/base_producer.rs b/src/producer/base_producer.rs index 0841bafba..7f6d82478 100644 --- a/src/producer/base_producer.rs +++ b/src/producer/base_producer.rs @@ -42,6 +42,7 @@ //! should wait and try again. use std::ffi::{CStr, CString}; +use std::fmt; use std::marker::PhantomData; use std::mem; use std::os::raw::c_void; @@ -340,6 +341,19 @@ where _partitioner: PhantomData, } +impl fmt::Debug for BaseProducer +where + Part: Partitioner, + C: ProducerContext, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BaseProducer") + .field("native_ptr", &self.native_ptr()) + .field("queue", &self.queue) + .finish() + } +} + impl BaseProducer where Part: Partitioner, diff --git a/test_suite.sh b/test_suite.sh deleted file mode 100755 index 0ff14c926..000000000 --- a/test_suite.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash - -GREEN='\033[0;32m' -RED='\033[0;31m' -NC='\033[0m' # No Color - -set -euo pipefail - -echo_good() { - tput setaf 2 - echo "$@" - tput sgr0 -} - -echo_bad() { - tput setaf 1 - echo "$@" - tput sgr0 -} - -run_with_valgrind() { - if ! valgrind --error-exitcode=100 --suppressions=rdkafka.suppressions --gen-suppressions=all --leak-check=full "$1" --nocapture --test-threads=1 - then - echo_bad "*** Failure in $1 ***" - exit 1 - fi -} - -# Initialize. - -git submodule update --init -docker compose up --wait - -# Run integration tests -export RUST_LOG=${RUST_LOG:-off} -RUST_BACKTRACE=1 cargo test "$@" - - -# Run unit tests. - -#echo_good "*** Run unit tests ***" -#for test_file in target/debug/deps/rdkafka-* -#do -# if [[ -x "$test_file" ]] -# then -# echo_good "Executing "$test_file"" -# run_with_valgrind "$test_file" -# fi -#done -#echo_good "*** Unit tests succeeded ***" -# -## Run integration tests. -# -#echo_good "*** Run integration tests ***" -#for test_file in target/debug/deps/test_* -#do -# if [[ -x "$test_file" ]] -# then -# #echo_good "*** Restarting kafka/zk ***" -# #docker-compose restart --timeout 30 -# echo_good "Executing "$test_file"" -# run_with_valgrind "$test_file" -# fi -#done -#echo_good "*** Integration tests succeeded ***" - -# Run smol runtime example. - -echo_good "*** Run runtime_smol example ***" -cargo run --example runtime_smol --no-default-features --features cmake-build -- --topic smol -echo_good "*** runtime_smol example succeeded ***" - -# Run async-std runtime example. - -echo_good "*** Run runtime_async_std example ***" -cargo run --example runtime_async_std --no-default-features --features cmake-build -- --topic async-std -echo_good "*** runtime_async_std example succeeded ***" diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..c75256d19 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,358 @@ +# Integration test suite + +This is a tour of how the integration tests work, aimed at someone new +to the codebase. If you just want to run them, jump to [Running the +tests](#running-the-tests). If you want to add one, read the rest. + +## Overview + +Each test file under `tests/` is a separate Cargo integration-test binary +(`tests/admin.rs`, `tests/base_producer.rs`, and so on). Every binary +needs a real Kafka broker to talk to. + +We start that broker with [testcontainers-rs]. When the first test in a +binary calls `KafkaContext::shared()`, testcontainers pulls and starts +an `apache/kafka` Docker image, waits for it to be ready, and hands back +its bootstrap address (host plus a randomly-allocated port). Every +subsequent call in that same binary reuses the same container. + +You don't need to start the broker yourself. You don't need +`docker-compose`. You don't need to set `KAFKA_HOST`. `cargo test` does +the right thing as long as Docker is running. + +[testcontainers-rs]: https://github.com/testcontainers/testcontainers-rs + +## Prerequisites + +- **A Docker daemon** the current user can talk to (Docker Desktop, + colima, OrbStack, native Docker, ...). Tests fail at + `KafkaContext::shared()` if Docker isn't reachable. +- **Rust >= 1.85.** This is the MSRV the library targets, and the + `testcontainers-modules` dep is pinned at `0.12.1` to keep us on it. +- **librdkafka build deps.** `cmake`, a C/C++ toolchain, plus + `libcurl4-openssl-dev` on Debian/Ubuntu. See the top-level `README.md` + for the full list. + +## Running the tests + +From the repository root: + +```bash +cargo test +``` + +That builds every integration-test binary in `tests/` and runs them +sequentially. The first binary to run pays the cost of pulling the +Kafka image (one-time) and starting a container (a few seconds). Within +a binary, individual tests run in parallel against the shared broker. + +To run just one file: + +```bash +cargo test --test admin +``` + +To run a single test: + +```bash +cargo test --test admin test_topic_create_and_delete +``` + +To pick a specific Kafka version (defaults to `4.0`): + +```bash +KAFKA_VERSION=3.9 cargo test +``` + +`KAFKA_VERSION` accepts either a short series (`3.7`, `3.8`, `3.9`, +`4.0`) or a full tag (`3.9.2`). The mapping lives in +`tests/utils/containers.rs::resolve_kafka_image_tag`. + +To enable librdkafka log output: + +```bash +RUST_LOG="librdkafka=trace,rdkafka::client=debug" cargo test +``` + +## How the broker is managed + +`tests/utils/containers.rs` defines a single `KafkaContext`: + +```rust +pub struct KafkaContext { + kafka_node: ContainerAsync, + pub bootstrap_servers: String, + pub version: String, +} +``` + +`KafkaContext::shared()` is the only constructor. It wraps a +`tokio::sync::OnceCell` so that the first caller in a test binary spins +the container up and every other caller gets the same `Arc` +back: + +```rust +let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); +``` + +A few things to internalise: + +- **One broker per test binary.** Cargo runs each `tests/*.rs` file as + a separate process, so each binary gets its own container. Two tests + in the same binary share state on the broker. Two tests in different + binaries do not. +- **Tests in a binary run in parallel.** That's Cargo's default. If your + test needs an isolated topic or consumer group, use the random-name + helpers (see below). Don't hardcode names; you will collide with + another test. +- **No fresh broker between tests.** The container lives for the whole + binary. State you leave behind (topics, consumer groups, offsets) is + visible to later tests in the same binary, which is occasionally what + you want and occasionally a footgun. +- **Random host port.** `kafka_context.bootstrap_servers` is something + like `127.0.0.1:54731`. Never assume `9092`. + +The container is started with two non-default env vars: + +```rust +.with_env_var("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", "1") +.with_env_var("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", "1") +``` + +Kafka defaults to replication factor 3 for `__transaction_state`. We +only have a single broker, so the transactions tests would hang forever +waiting for the internal topic to come up without these overrides. If +you ever see transactions tests hang, this is the first thing to check. + +We also call `.with_jvm_image()` because the kafka-native variant of the +`apache/kafka` image doesn't publish `3.7.x` tags, and the CI matrix +needs them. + +## Test file layout + +``` +tests/ + admin.rs integration tests, one file per area + base_consumer.rs + base_producer.rs + consumer_groups.rs + future_producer.rs + metadata.rs + producer.rs + stream_consumers.rs + topic_partition_lists.rs (pure unit-style; no broker) + transactions.rs + + utils/ shared test helpers + mod.rs message-production helpers, + ConsumerTestContext, KafkaVersion, + BROKER_ID (= 1, the container's hardcoded id) + containers.rs KafkaContext + shared() / OnceCell + admin.rs create_admin_client, create_topic, new_topic_vec + consumer/ + mod.rs base-consumer helpers + stream_consumer.rs stream-consumer helpers + producer/ + mod.rs + base_producer.rs create_producer, send_record, poll_and_flush + future_producer.rs create_producer (FutureProducer) + topics.rs populate_topic_using_future_producer + rand.rs rand_test_topic / rand_test_group / + rand_test_transactional_id + logging.rs init_test_logger (env_logger, one-shot) +``` + +Each test file lives at the top of `tests/`. Helpers live under +`tests/utils/` and are pulled in with `mod utils;` at the top of each +file. The split lets Cargo treat each integration test as its own +binary while sharing utility code. + +## Writing a new integration test + +Start with a small example. Drop a new file at `tests/my_feature.rs`: + +```rust +use rdkafka::admin::AdminOptions; + +use crate::utils::admin; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::producer; +use crate::utils::rand::{rand_test_group, rand_test_topic}; + +mod utils; + +#[tokio::test] +async fn my_feature_works() { + init_test_logger(); + + // 1. Get (or start, if we're the first test) the shared broker. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // 2. Create an admin client and a unique topic for this test. + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + + let topic_name = rand_test_topic("my_feature"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + // 3. Do the thing under test. Make assertions. + let producer = producer::future_producer::create_producer( + &kafka_context.bootstrap_servers, + ) + .await + .expect("could not create producer"); + + // ... produce, consume, assert ... + + drop(producer); +} +``` + +Things to notice: + +- `mod utils;` at the top is mandatory; without it the `crate::utils::*` + paths don't resolve. +- `init_test_logger()` is a one-shot guarded by `Once`, so it's safe to + call from every test. +- `rand_test_topic("my_feature")` returns `my_feature_aB3xQ...`, a + test-specific topic. Always do this for topics and consumer groups, + or you'll see flaky tests when binaries are run in parallel locally. +- Pass `&kafka_context.bootstrap_servers` into every helper that builds + a client. There is no global "bootstrap servers" anywhere; the + container's address is only known after `shared()` resolves. + +## Helper cheatsheet + +Most of what you'll need is in `tests/utils/`: + +| You want to ... | Use | +| -------------------------------- | ------------------------------------------------------------------ | +| Get the shared broker | `containers::KafkaContext::shared().await` | +| Make an admin client | `admin::create_admin_client(bootstrap_servers).await` | +| Create a topic | `admin::create_topic(client, name).await` (one partition, RF 1) | +| Get a `NewTopic` vec for finer control | `admin::new_topic_vec(name, Some(num_partitions))` | +| Make a `BaseProducer` | `producer::base_producer::create_producer(bootstrap_servers).await` | +| Make a `FutureProducer` | `producer::future_producer::create_producer(bootstrap_servers).await` | +| `FutureProducer` with config overrides | `producer::future_producer::create_producer_with_overrides(bootstrap_servers, &[(key, value)]).await` | +| Make a `BaseConsumer` | `consumer::create_subscribed_base_consumer(bootstrap_servers, group, topic).await` | +| Make a `StreamConsumer` | `consumer::stream_consumer::create_stream_consumer(bootstrap_servers, Some(group)).await` | +| Produce N messages | `produce_messages(producer, topic, n, partition, timestamp).await` (from `utils::*`) | +| Produce N to a partition | `produce_messages_to_partition(producer, topic, n, partition).await` | +| Populate a topic with a `FutureProducer` | `topics::populate_topic_using_future_producer(producer, topic, n, partition).await` | +| Random topic name | `rand::rand_test_topic("test_name")` | +| Random consumer group | `rand::rand_test_group()` | +| Random transactional id | `rand::rand_test_transactional_id()` | +| Init `env_logger` once | `logging::init_test_logger()` | + +Constants you'll see: + +- `utils::BROKER_ID == 1`. The single-broker testcontainers image + hardcodes its broker id to 1. Assert against the constant, not a + magic number. + +## Known quirks + +A few tests have non-obvious shapes; read these before debugging a +failure you didn't introduce. + +- **`tests/base_producer.rs::test_base_producer_timeout`** points the + producer at `127.0.0.1:1` (a deliberately unreachable address) instead + of the real broker. The test is exercising the delivery callback when + `message.timeout.ms` fires. Pointing at the real broker, with + `auto.create.topics.enable=true`, lets the message get delivered + inside the 100ms deadline on fast CI hardware and the test flakes. + +- **`tests/consumer_groups.rs::test_delete_unknown_group`** accepts + either `GroupIdNotFound` or `NotCoordinator`. Both mean "the group + doesn't exist," but which one you get depends on whether any earlier + test in the binary has caused the consumer-group coordinator to + initialise. We share a broker across the file, so coordinator state + is non-deterministic from this test's point of view. + +- **`tests/metadata.rs`** uses `BROKER_ID = 1` (the testcontainers + default) and does not assert on host port. The old test suite + hardcoded `0` and `9092` for the docker-compose broker; both are wrong + here. + +- **Transactions tests need the replication overrides.** If you ever + copy `containers.rs::init` and drop the + `KAFKA_TRANSACTION_STATE_LOG_*` env vars, the transactions tests will + hang on broker startup waiting for `__transaction_state` to reach RF + 3. They won't fail with a clear error; they'll just sit there until + the timeout. + +- **`tests/base_consumer.rs::test_produce_consume_message_queue_nonempty_callback`** + asserts wakeup-count deltas against a baseline captured after initial + setup, not absolute counts. apache/kafka 3.7.x posts an event to the + split partition queue during the initial position query (the partition + is assigned at `Offset::Beginning`), which fires the nonempty callback + once before any messages are produced. 3.8+ doesn't. Comparing deltas + is portable across the matrix. + +## CI + +`.github/workflows/ci.yml` runs five jobs: + +- **lint**: `cargo fmt --check`, `cargo clippy -- -Dwarnings`, + `cargo clippy --tests -- -Dwarnings`, `cargo test --doc`. Lint + failures break the build. Always run these locally before pushing. +- **check**: cross-platform builds on macOS, Windows, and Ubuntu with + various feature combinations. No tests, just `cargo build` and + `cargo test` in `rdkafka-sys` (its tests don't need a broker). +- **check-minimal-versions**: makes sure the declared semver + constraints actually resolve. +- **test**: the integration suite. Fans out across `KAFKA_VERSION = + 3.7, 3.8, 3.9, 4.0`. Each row resolves to a specific + `apache/kafka:` via `resolve_kafka_image_tag` and runs + `cargo test --features zstd`. The `zstd` feature is on so the + compression round-trip test for zstd in `tests/future_producer.rs` + actually links the codec (rdkafka-sys passes `--disable-zstd` to + librdkafka by default). Rows run sequentially (`max-parallel: 1`) + because they share an Actions runner and each spawns its own Docker + container. +- **runtime-examples**: smoke-tests `examples/runtime_smol.rs` and + `examples/runtime_async_std.rs` against a pinned `apache/kafka:4.0.2` + service container. The integration suite covers the tokio path via + testcontainers; this job catches breakage in the alternative runtimes + that `cargo build --all-targets` would miss. Not matrixed: it's a + runtime-correctness check, not a broker-compatibility check. + +## Troubleshooting + +**"could not create kafka context" / Docker errors.** The Docker +daemon isn't running, or the current user can't reach it. Start Docker +Desktop / colima / OrbStack and retry. + +**Image pull is slow on first run.** Expected. testcontainers pulls +`apache/kafka:` on first use and caches it in your local Docker +image store. Subsequent runs reuse it. + +**Test hangs on the transactions suite.** Almost certainly the +`__transaction_state` topic can't reach its replication factor. Check +that the `KAFKA_TRANSACTION_STATE_LOG_*` env vars are still set in +`containers.rs::init`. + +**Port 9092 in use.** Doesn't matter to testcontainers (it allocates a +random host port), but examples under `examples/` default to +`localhost:9092`. If you're running an example against a separate +broker you started by hand, make sure nothing else is bound to that +port. + +**A test passes locally but fails in CI on Kafka 3.7.** The broker is a +different version. Run the same matrix locally: `KAFKA_VERSION=3.7 +cargo test --test `. + +**Clippy complains in CI but not locally.** The lint job runs +`cargo clippy --tests -- -Dwarnings`. Reproduce that flag set locally. diff --git a/tests/admin.rs b/tests/admin.rs new file mode 100644 index 000000000..c01d4722b --- /dev/null +++ b/tests/admin.rs @@ -0,0 +1,781 @@ +use crate::utils::admin::create_topic; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::rand::rand_test_topic; +use crate::utils::{get_broker_version, KafkaVersion}; +use backon::{BlockingRetryable, ExponentialBuilder}; +use rdkafka::admin::{ + AdminClient, AdminOptions, AlterConfig, ConfigEntry, ConfigSource, NewPartitions, NewTopic, + OwnedResourceSpecifier, ResourceSpecifier, TopicReplication, +}; +use rdkafka::client::DefaultClientContext; +use rdkafka::error::KafkaError; +use rdkafka::producer::{FutureRecord, Producer}; +use rdkafka::{ClientConfig, Offset, TopicPartitionList}; +use rdkafka_sys::RDKafkaErrorCode; +use std::time::Duration; + +#[path = "utils/mod.rs"] +mod utils; + +/// Validates thast topics can be properly created. +#[tokio::test] +pub async fn test_topic_creation() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context_result = KafkaContext::shared().await; + let Ok(kafka_context) = kafka_context_result else { + panic!( + "could not create kafka context: {}", + kafka_context_result.unwrap_err() + ); + }; + let test_topic_name = rand_test_topic("testing-topic"); + + let admin_client_result = + utils::admin::create_admin_client(&kafka_context.bootstrap_servers).await; + let Ok(admin_client) = admin_client_result else { + panic!( + "could not create admin client: {}", + admin_client_result.unwrap_err() + ); + }; + + if let Err(err) = create_topic(&admin_client, &test_topic_name).await { + panic!("could not create topic: {}", err); + }; +} + +/// Verify that topics are created as specified, and that they can later +/// be deleted. +#[tokio::test] +pub async fn test_topic_create_and_delete() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // Create admin client + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + let opts = AdminOptions::new().operation_timeout(Some(Duration::from_secs(30))); + + // Create consumer client + let consumer_client = + utils::consumer::create_unsubscribed_base_consumer(&kafka_context.bootstrap_servers, None) + .await + .expect("could not create consumer client"); + + let topic_name_1 = rand_test_topic("test_topics"); + let topic_name_2 = rand_test_topic("test_topics"); + let topic1 = NewTopic::new(&topic_name_1, 1, TopicReplication::Fixed(1)) + .set("max.message.bytes", "1234"); + let topic2 = NewTopic { + name: &topic_name_2, + num_partitions: 3, + replication: TopicReplication::Variable(&[ + &[utils::BROKER_ID], + &[utils::BROKER_ID], + &[utils::BROKER_ID], + ]), + config: Vec::new(), + }; + + // Topics created + let topic_results = admin_client + .create_topics(&[topic1, topic2], &opts) + .await + .expect("topic creation failed"); + assert_eq!( + topic_results, + &[Ok(topic_name_1.clone()), Ok(topic_name_2.clone())] + ); + + // Verify metadata + let metadata1 = utils::consumer::fetch_consumer_metadata(&consumer_client, &topic_name_1) + .unwrap_or_else(|_| panic!("failed to fetch metadata for {}", &topic_name_1)); + let metadata2 = utils::consumer::fetch_consumer_metadata(&consumer_client, &topic_name_2) + .unwrap_or_else(|_| panic!("failed to fetch metadata for {}", topic_name_2)); + assert_eq!(1, metadata1.topics().len()); + assert_eq!(1, metadata2.topics().len()); + let metadata_topic1 = &metadata1.topics()[0]; + let metadata_topic2 = &metadata2.topics()[0]; + assert_eq!(&topic_name_1, metadata_topic1.name()); + assert_eq!(&topic_name_2, metadata_topic2.name()); + assert_eq!(1, metadata_topic1.partitions().len()); + assert_eq!(3, metadata_topic2.partitions().len()); + + // Verifying topic configurations + let config_resource_results = admin_client + .describe_configs( + &[ + ResourceSpecifier::Topic(&topic_name_1), + ResourceSpecifier::Topic(&topic_name_2), + ], + &opts, + ) + .await + .expect("could not describe configs"); + let topic_config1 = &config_resource_results[0] + .as_ref() + .unwrap_or_else(|_| panic!("failed to describe config for {}", &topic_name_1)); + let topic_config2 = &config_resource_results[1] + .as_ref() + .unwrap_or_else(|_| panic!("failed to describe config for {}", &topic_name_2)); + let mut expected_entry1 = ConfigEntry { + name: "max.message.bytes".into(), + value: Some("1234".into()), + source: ConfigSource::DynamicTopic, + is_read_only: false, + is_default: false, + is_sensitive: false, + }; + let default_max_msg_bytes = if get_broker_version(&kafka_context) <= KafkaVersion(2, 3, 0, 0) { + "1000012" + } else { + "1048588" + }; + let expected_entry2 = ConfigEntry { + name: "max.message.bytes".into(), + value: Some(default_max_msg_bytes.into()), + source: ConfigSource::Default, + is_read_only: false, + is_default: true, + is_sensitive: false, + }; + if get_broker_version(&kafka_context) < KafkaVersion(1, 1, 0, 0) { + expected_entry1.source = ConfigSource::Unknown; + } + assert_eq!( + Some(&expected_entry1), + topic_config1.get("max.message.bytes") + ); + assert_eq!( + Some(&expected_entry2), + topic_config2.get("max.message.bytes") + ); + let config_entries1 = topic_config1.entry_map(); + let config_entries2 = topic_config2.entry_map(); + assert_eq!(topic_config1.entries.len(), config_entries1.len()); + assert_eq!(topic_config2.entries.len(), config_entries2.len()); + assert_eq!( + Some(&&expected_entry1), + config_entries1.get("max.message.bytes") + ); + assert_eq!( + Some(&&expected_entry2), + config_entries2.get("max.message.bytes") + ); + + let partitions1 = NewPartitions::new(&topic_name_1, 5); + let res = admin_client + .create_partitions(&[partitions1], &opts) + .await + .expect("partition creation failed"); + assert_eq!(res, &[Ok(topic_name_1.clone())]); + + let mut tries = 0; + loop { + let metadata = utils::consumer::fetch_consumer_metadata(&consumer_client, &topic_name_1) + .unwrap_or_else(|_| panic!("failed to fetch metadata for {}", &topic_name_1)); + let topic = &metadata.topics()[0]; + let n = topic.partitions().len(); + if n == 5 { + break; + } else if tries >= 5 { + panic!("topic has {} partitions, but expected {}", n, 5); + } else { + tries += 1; + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + + let res = admin_client + .delete_topics(&[&topic_name_1, &topic_name_2], &opts) + .await + .expect("topic deletion failed"); + assert_eq!(res, &[Ok(topic_name_1.clone()), Ok(topic_name_2.clone())]); + utils::consumer::verify_topic_deleted(&consumer_client, &topic_name_1) + .unwrap_or_else(|_| panic!("could not delete topic for {}", &topic_name_1)); + utils::consumer::verify_topic_deleted(&consumer_client, &topic_name_2) + .unwrap_or_else(|_| panic!("could not delete topic for {}", &topic_name_2)); +} + +/// Verify that incorrect replication configurations are ignored when +/// creating topics. +#[tokio::test] +pub async fn test_incorrect_replication_factors_are_ignored_when_creating_topics() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context_result = KafkaContext::shared().await; + let Ok(kafka_context) = kafka_context_result else { + panic!( + "could not create kafka context: {}", + kafka_context_result.unwrap_err() + ); + }; + + let admin_client_result = + utils::admin::create_admin_client(&kafka_context.bootstrap_servers).await; + let Ok(admin_client) = admin_client_result else { + panic!( + "could not create admin client: {}", + admin_client_result.unwrap_err() + ); + }; + let opts = AdminOptions::new().operation_timeout(Some(Duration::from_secs(30))); + + let topic = NewTopic::new( + "ignored", + 1, + TopicReplication::Variable(&[&[utils::BROKER_ID], &[utils::BROKER_ID]]), + ); + let res = admin_client.create_topics(&[topic], &opts).await; + assert_eq!( + Err(KafkaError::AdminOpCreation( + "replication configuration for topic 'ignored' assigns 2 partition(s), \ + which does not match the specified number of partitions (1)" + .into() + )), + res, + ) +} + +/// Verify that incorrect replication configurations are ignored when +/// creating partitions. +#[tokio::test] +pub async fn test_incorrect_replication_factors_are_ignored_when_creating_partitions() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context_result = KafkaContext::shared().await; + let Ok(kafka_context) = kafka_context_result else { + panic!( + "could not create kafka context: {}", + kafka_context_result.unwrap_err() + ); + }; + + let admin_client_result = + utils::admin::create_admin_client(&kafka_context.bootstrap_servers).await; + let Ok(admin_client) = admin_client_result else { + panic!( + "could not create admin client: {}", + admin_client_result.unwrap_err() + ); + }; + let opts = AdminOptions::new().operation_timeout(Some(Duration::from_secs(30))); + + // Create consumer client + let consumer_client = + utils::consumer::create_unsubscribed_base_consumer(&kafka_context.bootstrap_servers, None) + .await + .expect("could not create consumer client"); + + let name = rand_test_topic("test_topics"); + let topic = NewTopic::new(&name, 1, TopicReplication::Fixed(1)); + + let res = admin_client + .create_topics(vec![&topic], &opts) + .await + .expect("topic creation failed"); + assert_eq!(res, &[Ok(name.clone())]); + let _ = utils::consumer::fetch_consumer_metadata(&consumer_client, &name); + + // This partition specification is obviously garbage, and so trips + // a client-side error. + let partitions = NewPartitions::new(&name, 2).assign(&[&[0], &[0], &[0]]); + let res = admin_client.create_partitions(&[partitions], &opts).await; + assert_eq!( + res, + Err(KafkaError::AdminOpCreation(format!( + "partition assignment for topic '{}' assigns 3 partition(s), \ + which is more than the requested total number of partitions (2)", + name + ))) + ); + + // Only the server knows that this partition specification is garbage. + let partitions = NewPartitions::new(&name, 2).assign(&[&[0], &[0]]); + let res = admin_client + .create_partitions(&[partitions], &opts) + .await + .expect("partition creation failed"); + assert_eq!( + res, + &[Err((name, RDKafkaErrorCode::InvalidReplicaAssignment))], + ); +} + +/// Verify that deleting a non-existent topic fails. +#[tokio::test] +pub async fn test_delete_nonexistent_topics() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // Create admin client + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + let opts = AdminOptions::new().operation_timeout(Some(Duration::from_secs(30))); + + let name = rand_test_topic("test_topics"); + let res = admin_client + .delete_topics(&[&name], &opts) + .await + .expect("delete topics failed"); + assert_eq!( + res, + &[Err((name, RDKafkaErrorCode::UnknownTopicOrPartition))] + ); +} + +/// Verify that mixed-success operations properly report the successful and +/// failing operators. +#[tokio::test] +pub async fn test_mixed_success_results() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // Create admin client + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + let opts = AdminOptions::new().operation_timeout(Some(Duration::from_secs(30))); + + // Create consumer client + let consumer_client = + utils::consumer::create_unsubscribed_base_consumer(&kafka_context.bootstrap_servers, None) + .await + .expect("could not create consumer client"); + + let name1 = rand_test_topic("test_topics"); + let name2 = rand_test_topic("test_topics"); + + let topic1 = NewTopic::new(&name1, 1, TopicReplication::Fixed(1)); + let topic2 = NewTopic::new(&name2, 1, TopicReplication::Fixed(1)); + + let res = admin_client + .create_topics(vec![&topic1], &opts) + .await + .expect("topic creation failed"); + assert_eq!(res, &[Ok(name1.clone())]); + let _ = utils::consumer::fetch_consumer_metadata(&consumer_client, &name1) + .unwrap_or_else(|_| panic!("could not fetch consumer metadata for {}", name1)); + + let res = admin_client + .create_topics(vec![&topic1, &topic2], &opts) + .await + .expect("topic creation failed"); + assert_eq!( + res, + &[ + Err((name1.clone(), RDKafkaErrorCode::TopicAlreadyExists)), + Ok(name2.clone()) + ] + ); + let _ = utils::consumer::fetch_consumer_metadata(&consumer_client, &name2) + .unwrap_or_else(|_| panic!("could not fetch consumer metadata for {}", name2)); + + let res = admin_client + .delete_topics(&[&name1], &opts) + .await + .expect("topic deletion failed"); + assert_eq!(res, &[Ok(name1.clone())]); + utils::consumer::verify_topic_deleted(&consumer_client, &name1) + .unwrap_or_else(|_| panic!("could not verify topic \"{}\" was deleted", name1)); + + let res = admin_client + .delete_topics(&[&name2, &name1], &opts) + .await + .expect("topic deletion failed"); + assert_eq!( + res, + &[ + Ok(name2.clone()), + Err((name1.clone(), RDKafkaErrorCode::UnknownTopicOrPartition)) + ] + ); +} + +/// Test the admin client's delete records functionality. +#[tokio::test] +async fn test_delete_records() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // Create admin client + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + + // Create producer client + let producer_client = + utils::producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("could not create producer_client"); + + let timeout = Some(Duration::from_secs(1)); + let opts = AdminOptions::new().operation_timeout(timeout); + let topic = rand_test_topic("test_delete_records"); + let make_record = || FutureRecord::::to(&topic).payload("data"); + + // Create a topic with a single partition. + admin_client + .create_topics( + &[NewTopic::new(&topic, 1, TopicReplication::Fixed(1))], + &opts, + ) + .await + .expect("topic creation failed"); + + // Ensure that the topic begins with low and high water marks of 0. + let (lo, hi) = (|| { + producer_client + .client() + .fetch_watermarks(&topic, 0, timeout) + }) + .retry(ExponentialBuilder::default().with_max_delay(Duration::from_secs(5))) + .call() + .unwrap(); + assert_eq!(lo, 0); + assert_eq!(hi, 0); + + // Produce five messages to the topic. + for _ in 0..5 { + producer_client.send(make_record(), timeout).await.unwrap(); + } + + // Ensure that the high water mark has advanced to 5. + let (lo, hi) = producer_client + .client() + .fetch_watermarks(&topic, 0, timeout) + .unwrap(); + assert_eq!(lo, 0); + assert_eq!(hi, 5); + + // Delete the record at offset 0. + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic, 0, Offset::Offset(1)) + .unwrap(); + let res_tpl = admin_client.delete_records(&tpl, &opts).await.unwrap(); + assert_eq!(res_tpl.count(), 1); + assert_eq!(res_tpl.elements()[0].topic(), topic); + assert_eq!(res_tpl.elements()[0].partition(), 0); + assert_eq!(res_tpl.elements()[0].offset(), Offset::Offset(1)); + assert_eq!(res_tpl.elements()[0].error(), Ok(())); + + // Ensure that the low water mark has advanced to 1. + let (lo, hi) = producer_client + .client() + .fetch_watermarks(&topic, 0, timeout) + .unwrap(); + assert_eq!(lo, 1); + assert_eq!(hi, 5); + + // Delete the record at offset 1 and also include an invalid partition in + // the request. The invalid partition should not cause the request to fail, + // but we should be able to see the per-partition error in the returned + // topic partition list. + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic, 0, Offset::Offset(2)) + .unwrap(); + tpl.add_partition_offset(&topic, 1, Offset::Offset(1)) + .unwrap(); + let res_tpl = admin_client.delete_records(&tpl, &opts).await.unwrap(); + assert_eq!(res_tpl.count(), 2); + assert_eq!(res_tpl.elements()[0].topic(), topic); + assert_eq!(res_tpl.elements()[0].partition(), 0); + assert_eq!(res_tpl.elements()[0].offset(), Offset::Offset(2)); + assert_eq!(res_tpl.elements()[0].error(), Ok(())); + assert_eq!(res_tpl.elements()[1].topic(), topic); + assert_eq!(res_tpl.elements()[1].partition(), 1); + assert_eq!( + res_tpl.elements()[1].error(), + Err(KafkaError::OffsetFetch(RDKafkaErrorCode::UnknownPartition)) + ); + + // Ensure that the low water mark has advanced to 2. + let (lo, hi) = producer_client + .client() + .fetch_watermarks(&topic, 0, timeout) + .unwrap(); + assert_eq!(lo, 2); + assert_eq!(hi, 5); + + // Delete all records up to offset 5. + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic, 0, Offset::End).unwrap(); + let res_tpl = admin_client.delete_records(&tpl, &opts).await.unwrap(); + assert_eq!(res_tpl.count(), 1); + assert_eq!(res_tpl.elements()[0].topic(), topic); + assert_eq!(res_tpl.elements()[0].partition(), 0); + assert_eq!(res_tpl.elements()[0].offset(), Offset::Offset(5)); + assert_eq!(res_tpl.elements()[0].error(), Ok(())); + + // Ensure that the low water mark has advanced to 5. + let (lo, hi) = producer_client + .client() + .fetch_watermarks(&topic, 0, timeout) + .unwrap(); + assert_eq!(lo, 5); + assert_eq!(hi, 5); +} + +#[tokio::test] +async fn test_configs() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // Create admin client + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + let opts = AdminOptions::new(); + let broker = ResourceSpecifier::Broker(utils::BROKER_ID); + + let res = admin_client + .describe_configs(&[broker], &opts) + .await + .expect("describe configs failed"); + let config = &res[0].as_ref().expect("describe configs failed"); + let orig_val = config + .get("log.flush.interval.messages") + .expect("original config entry missing") + .value + .as_ref() + .expect("original value missing"); + + let config = AlterConfig::new(broker).set("log.flush.interval.messages", "1234"); + let res = admin_client + .alter_configs(&[config], &opts) + .await + .expect("alter configs failed"); + assert_eq!(res, &[Ok(OwnedResourceSpecifier::Broker(utils::BROKER_ID))]); + + let mut tries = 0; + loop { + let res = admin_client + .describe_configs(&[broker], &opts) + .await + .expect("describe configs failed"); + let config = &res[0].as_ref().expect("describe configs failed"); + let entry = config.get("log.flush.interval.messages"); + let expected_entry = if get_broker_version(&kafka_context) < KafkaVersion(1, 1, 0, 0) { + // Pre-1.1, the AlterConfig operation will silently fail, and the + // config will remain unchanged, which I guess is worth testing. + ConfigEntry { + name: "log.flush.interval.messages".into(), + value: Some(orig_val.clone()), + source: ConfigSource::Default, + is_read_only: true, + is_default: true, + is_sensitive: false, + } + } else { + ConfigEntry { + name: "log.flush.interval.messages".into(), + value: Some("1234".into()), + source: ConfigSource::DynamicBroker, + is_read_only: false, + is_default: false, + is_sensitive: false, + } + }; + if entry == Some(&expected_entry) { + break; + } else if tries >= 5 { + panic!("{:?} != {:?}", entry, Some(&expected_entry)); + } else { + tries += 1; + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + + let config = AlterConfig::new(broker).set("log.flush.interval.ms", orig_val); + let res = admin_client + .alter_configs(&[config], &opts) + .await + .expect("alter configs failed"); + assert_eq!(res, &[Ok(OwnedResourceSpecifier::Broker(utils::BROKER_ID))]); +} + +/// Tests whether each admin operation properly reports an error if the entire +/// request fails. The original implementations failed to check this, resulting +/// in confusing situations where a failed admin request would return Ok([]). +#[tokio::test] +async fn test_event_errors() { + // Configure an admin client to target a Kafka server that doesn't exist, + // then set an impossible timeout. This will ensure that every request fails + // with an OperationTimedOut error, assuming, of course, that the request + // passes client-side validation. + let admin_client = ClientConfig::new() + .set("bootstrap.servers", "noexist") + .create::>() + .expect("admin client creation failed"); + let opts = AdminOptions::new().request_timeout(Some(Duration::from_nanos(1))); + + let res = admin_client.create_topics(&[], &opts).await; + assert_eq!( + res, + Err(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) + ); + + let res = admin_client.create_partitions(&[], &opts).await; + assert_eq!( + res, + Err(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) + ); + + let res = admin_client.delete_topics(&[], &opts).await; + assert_eq!( + res, + Err(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) + ); + + let res = admin_client.describe_configs(&[], &opts).await; + assert_eq!( + res.err(), + Some(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) + ); + + let res = admin_client.alter_configs(&[], &opts).await; + assert_eq!( + res, + Err(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) + ); +} + +// `test_incorrect_replication_factors_are_ignored_when_creating_topics` +// exercises the client-side validation for a `TopicReplication::Variable` +// mismatch. This test covers the broker-side path: it asks for +// `TopicReplication::Fixed(3)` against the single-broker container, expects +// the broker to reject the request, and pins the surfaced error to +// `RDKafkaErrorCode::InvalidReplicationFactor`. A binding regression that +// swallowed the per-topic error (returning `Ok` from `create_topics`) or +// remapped the code would fail one of those assertions. +#[tokio::test] +async fn test_create_topics_fixed_replication_too_high() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + let opts = AdminOptions::new().operation_timeout(Some(Duration::from_secs(30))); + + let topic_name = rand_test_topic("test_create_topics_fixed_replication_too_high"); + let topic = NewTopic::new(&topic_name, 1, TopicReplication::Fixed(3)); + let results = admin_client + .create_topics(&[topic], &opts) + .await + .expect("create_topics request itself should succeed"); + assert_eq!(results.len(), 1); + match &results[0] { + Err((name, code)) => { + assert_eq!(name, &topic_name); + assert_eq!( + *code, + RDKafkaErrorCode::InvalidReplicationFactor, + "expected InvalidReplicationFactor, got {:?}", + code + ); + } + Ok(_) => panic!( + "create_topics unexpectedly succeeded for replication-factor=3 on a single broker" + ), + } +} + +// `test_configs` covers broker-scoped alter_configs; this test covers the +// topic-scoped path. It creates a fresh topic, alters `retention.ms` via +// `alter_configs` on a `ResourceSpecifier::Topic`, then issues +// `describe_configs` and asserts the entry value updated to the new number +// and its `source` switched to `ConfigSource::DynamicTopic`. A binding +// regression that misrouted the topic-scoped AlterConfigs request to a +// broker handler, or that misclassified the readback source, would fail +// here. +#[tokio::test] +async fn test_alter_topic_configs_retention_ms_dynamic_topic() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + let opts = AdminOptions::new().operation_timeout(Some(Duration::from_secs(30))); + + let topic_name = rand_test_topic("test_alter_topic_retention"); + let create_results = admin_client + .create_topics(&utils::admin::new_topic_vec(&topic_name, Some(1)), &opts) + .await + .expect("could not create topic"); + assert_eq!(create_results, vec![Ok(topic_name.clone())]); + + let resource = ResourceSpecifier::Topic(&topic_name); + let new_value = "604800000"; + let config = AlterConfig::new(resource).set("retention.ms", new_value); + let alter_results = admin_client + .alter_configs(&[config], &opts) + .await + .expect("alter configs failed"); + assert_eq!( + alter_results, + vec![Ok(OwnedResourceSpecifier::Topic(topic_name.clone()))] + ); + + let mut tries = 0; + loop { + let describe_results = admin_client + .describe_configs(&[resource], &opts) + .await + .expect("describe configs failed"); + let cfg = describe_results[0] + .as_ref() + .expect("describe configs returned an error"); + let entry = cfg.get("retention.ms").expect("retention.ms entry missing"); + let expected = ConfigEntry { + name: "retention.ms".into(), + value: Some(new_value.into()), + source: ConfigSource::DynamicTopic, + is_read_only: false, + is_default: false, + is_sensitive: false, + }; + if entry == &expected { + break; + } else if tries >= 5 { + panic!("retention.ms did not converge: got {:?}", entry); + } else { + tries += 1; + tokio::time::sleep(Duration::from_secs(1)).await; + } + } +} diff --git a/tests/test_low_consumers.rs b/tests/base_consumer.rs similarity index 56% rename from tests/test_low_consumers.rs rename to tests/base_consumer.rs index c97802243..484675d29 100644 --- a/tests/test_low_consumers.rs +++ b/tests/base_consumer.rs @@ -3,42 +3,61 @@ use std::collections::HashMap; use std::convert::TryInto; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::thread; use std::time::{Duration, Instant}; -use rdkafka::consumer::{BaseConsumer, Consumer, ConsumerContext}; +use rdkafka::admin::AdminOptions; +use rdkafka::client::ClientContext; +use rdkafka::consumer::{BaseConsumer, Consumer, ConsumerContext, Rebalance}; use rdkafka::error::{KafkaError, RDKafkaErrorCode}; use rdkafka::topic_partition_list::{Offset, TopicPartitionList}; use rdkafka::util::{current_time_millis, Timeout}; use rdkafka::{ClientConfig, Message, Timestamp}; +use crate::utils::admin; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::producer; +use crate::utils::rand::*; use crate::utils::*; mod utils; -fn create_base_consumer( - group_id: &str, - config_overrides: Option>, -) -> BaseConsumer { - consumer_config(group_id, config_overrides) - .create_with_context(ConsumerTestContext { _n: 64 }) - .expect("Consumer creation failed") -} - // Seeking should allow replaying messages and skipping messages. #[tokio::test] async fn test_produce_consume_seek() { - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let topic_name = rand_test_topic("test_produce_consume_seek"); - populate_topic(&topic_name, 5, &value_fn, &key_fn, Some(0), None).await; - let consumer = create_base_consumer(&rand_test_group(), None); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + produce_messages_to_partition(&producer, &topic_name, 5, 0).await; + + let group_id = rand_test_group(); + let consumer = + utils::consumer::create_base_consumer(&kafka_context.bootstrap_servers, &group_id, None) + .expect("could not create base consumer"); consumer.subscribe(&[topic_name.as_str()]).unwrap(); for (i, message) in consumer.iter().take(3).enumerate() { match message { - Ok(message) => assert_eq!(dbg!(message.offset()), i as i64), + Ok(message) => assert_eq!(message.offset(), i as i64), Err(e) => panic!("Error receiving message: {:?}", e), } } @@ -94,12 +113,31 @@ async fn test_produce_consume_seek() { // Seeking should allow replaying messages and skipping messages. #[tokio::test] async fn test_produce_consume_seek_partitions() { - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let topic_name = rand_test_topic("test_produce_consume_seek_partitions"); - populate_topic(&topic_name, 30, &value_fn, &key_fn, None, None).await; + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + produce_messages(&producer, &topic_name, 30, None, None).await; - let consumer = create_base_consumer(&rand_test_group(), None); + let group_id = rand_test_group(); + let consumer = + utils::consumer::create_base_consumer(&kafka_context.bootstrap_servers, &group_id, None) + .expect("could not create base consumer"); consumer.subscribe(&[topic_name.as_str()]).unwrap(); let mut partition_offset_map = HashMap::new(); @@ -155,12 +193,33 @@ async fn test_produce_consume_seek_partitions() { // All produced messages should be consumed. #[tokio::test] async fn test_produce_consume_iter() { - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let start_time = current_time_millis(); let topic_name = rand_test_topic("test_produce_consume_iter"); - let message_map = populate_topic(&topic_name, 100, &value_fn, &key_fn, None, None).await; - let consumer = create_base_consumer(&rand_test_group(), None); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + let message_map = produce_messages(&producer, &topic_name, 100, None, None).await; + + let group_id = rand_test_group(); + let consumer = + utils::consumer::create_base_consumer(&kafka_context.bootstrap_servers, &group_id, None) + .expect("could not create base consumer"); consumer.subscribe(&[topic_name.as_str()]).unwrap(); for message in consumer.iter().take(100) { @@ -194,20 +253,30 @@ async fn test_pause_resume_consumer_iter() { const MESSAGE_COUNT: i32 = 300; const MESSAGES_PER_PAUSE: i32 = MESSAGE_COUNT / PAUSE_COUNT; - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let topic_name = rand_test_topic("test_pause_resume_consumer_iter"); - populate_topic( - &topic_name, - MESSAGE_COUNT, - &value_fn, - &key_fn, - Some(0), - None, - ) - .await; + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + produce_messages_to_partition(&producer, &topic_name, MESSAGE_COUNT as usize, 0).await; let group_id = rand_test_group(); - let consumer = create_base_consumer(&group_id, None); + let consumer = + utils::consumer::create_base_consumer(&kafka_context.bootstrap_servers, &group_id, None) + .expect("could not create base consumer"); consumer.subscribe(&[topic_name.as_str()]).unwrap(); for _ in 0..PAUSE_COUNT { @@ -235,17 +304,41 @@ async fn test_pause_resume_consumer_iter() { #[tokio::test] async fn test_consume_partition_order() { - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let topic_name = rand_test_topic("test_consume_partition_order"); - populate_topic(&topic_name, 4, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 4, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 4, &value_fn, &key_fn, Some(2), None).await; + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + produce_messages_to_partition(&producer, &topic_name, 4, 0).await; + produce_messages_to_partition(&producer, &topic_name, 4, 1).await; + produce_messages_to_partition(&producer, &topic_name, 4, 2).await; // Using partition queues should allow us to consume the partitions // in a round-robin fashion. { - let consumer = Arc::new(create_base_consumer(&rand_test_group(), None)); + let group_id = rand_test_group(); + let consumer = Arc::new( + utils::consumer::create_base_consumer( + &kafka_context.bootstrap_servers, + &group_id, + None, + ) + .expect("could not create base consumer"), + ); let mut tpl = TopicPartitionList::new(); tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) .unwrap(); @@ -273,7 +366,15 @@ async fn test_consume_partition_order() { // When not all partitions have been split into separate queues, the // unsplit partitions should still be accessible via the main queue. { - let consumer = Arc::new(create_base_consumer(&rand_test_group(), None)); + let group_id = rand_test_group(); + let consumer = Arc::new( + utils::consumer::create_base_consumer( + &kafka_context.bootstrap_servers, + &group_id, + None, + ) + .expect("could not create base consumer"), + ); let mut tpl = TopicPartitionList::new(); tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) .unwrap(); @@ -333,7 +434,15 @@ async fn test_consume_partition_order() { // should be continuously polled to serve callbacks, but it should not panic // or result in memory unsafety, etc. { - let consumer = Arc::new(create_base_consumer(&rand_test_group(), None)); + let group_id = rand_test_group(); + let consumer = Arc::new( + utils::consumer::create_base_consumer( + &kafka_context.bootstrap_servers, + &group_id, + None, + ) + .expect("could not create base consumer"), + ); let mut tpl = TopicPartitionList::new(); tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) .unwrap(); @@ -355,15 +464,28 @@ async fn test_consume_partition_order() { #[tokio::test] async fn test_produce_consume_message_queue_nonempty_callback() { - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let topic_name = rand_test_topic("test_produce_consume_message_queue_nonempty_callback"); - create_topic(&topic_name, 1).await; + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); - let consumer: BaseConsumer<_> = consumer_config(&rand_test_group(), None) - .create_with_context(ConsumerTestContext { _n: 64 }) - .expect("Consumer creation failed"); + let group_id = rand_test_group(); + let consumer = + utils::consumer::create_base_consumer(&kafka_context.bootstrap_servers, &group_id, None) + .expect("could not create base consumer"); let consumer = Arc::new(consumer); let mut tpl = TopicPartitionList::new(); @@ -400,26 +522,35 @@ async fn test_produce_consume_message_queue_nonempty_callback() { // Initiate connection. assert!(consumer.poll(Duration::from_secs(0)).is_none()); - // Expect no wakeups for 1s. + // Let any startup events drain through. apache/kafka 3.7.x posts an + // event to the split partition queue during initial position setup + // (the partition is assigned at Offset::Beginning, so librdkafka has + // to query the log start offset), which invokes the nonempty + // callback once before any messages exist. 3.8+ doesn't show this. + // Capture the post-setup wakeup count as our baseline and assert + // deltas from here on. thread::sleep(Duration::from_secs(1)); - assert_eq!(wakeups.load(Ordering::SeqCst), 0); + let baseline = wakeups.load(Ordering::SeqCst); // Verify there are no messages waiting. assert!(consumer.poll(Duration::from_secs(0)).is_none()); assert!(queue.poll(Duration::from_secs(0)).is_none()); // Populate the topic, and expect a wakeup notifying us of the new messages. - populate_topic(&topic_name, 2, &value_fn, &key_fn, None, None).await; - wait_for_wakeups(1); + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + produce_messages(&producer, &topic_name, 2, None, None).await; + wait_for_wakeups(baseline + 1); // Read one of the messages. assert!(queue.poll(Duration::from_secs(0)).is_some()); // Add more messages to the topic. Expect no additional wakeups, as the // queue is not fully drained, for 1s. - populate_topic(&topic_name, 2, &value_fn, &key_fn, None, None).await; + produce_messages(&producer, &topic_name, 2, None, None).await; thread::sleep(Duration::from_secs(1)); - assert_eq!(wakeups.load(Ordering::SeqCst), 1); + assert_eq!(wakeups.load(Ordering::SeqCst), baseline + 1); // Drain the queue. assert!(queue.poll(None).is_some()); @@ -428,23 +559,23 @@ async fn test_produce_consume_message_queue_nonempty_callback() { // Expect no additional wakeups for 1s. thread::sleep(Duration::from_secs(1)); - assert_eq!(wakeups.load(Ordering::SeqCst), 1); + assert_eq!(wakeups.load(Ordering::SeqCst), baseline + 1); // Add another message, and expect a wakeup. - populate_topic(&topic_name, 1, &value_fn, &key_fn, None, None).await; - wait_for_wakeups(2); + produce_messages(&producer, &topic_name, 1, None, None).await; + wait_for_wakeups(baseline + 2); // Expect no additional wakeups for 1s. thread::sleep(Duration::from_secs(1)); - assert_eq!(wakeups.load(Ordering::SeqCst), 2); + assert_eq!(wakeups.load(Ordering::SeqCst), baseline + 2); // Disable the queue and add another message. queue.set_nonempty_callback(|| ()); - populate_topic(&topic_name, 1, &value_fn, &key_fn, None, None).await; + produce_messages(&producer, &topic_name, 1, None, None).await; // Expect no additional wakeups for 1s. thread::sleep(Duration::from_secs(1)); - assert_eq!(wakeups.load(Ordering::SeqCst), 2); + assert_eq!(wakeups.load(Ordering::SeqCst), baseline + 2); } //TODO: adjust the test to work, today set_nonempty_callback param is never called. @@ -555,3 +686,204 @@ async fn test_invalid_consumer_position() { Err(KafkaError::MetadataFetch(RDKafkaErrorCode::UnknownGroup)) ); } + +#[derive(Clone, Debug, PartialEq, Eq)] +enum RebalanceEventKind { + Assign, + Revoke, + Error, +} + +#[derive(Clone, Debug)] +struct RebalanceEvent { + kind: RebalanceEventKind, + partitions: Vec<(String, i32)>, +} + +#[derive(Clone)] +struct RecordingRebalanceContext { + pre: Arc>>, + post: Arc>>, +} + +impl RecordingRebalanceContext { + fn new() -> Self { + Self { + pre: Arc::new(Mutex::new(Vec::new())), + post: Arc::new(Mutex::new(Vec::new())), + } + } + + fn drain(&self) -> (Vec, Vec) { + let pre = self.pre.lock().unwrap().clone(); + let post = self.post.lock().unwrap().clone(); + (pre, post) + } +} + +fn record_rebalance(rebalance: &Rebalance) -> RebalanceEvent { + match rebalance { + Rebalance::Assign(tpl) => RebalanceEvent { + kind: RebalanceEventKind::Assign, + partitions: tpl + .elements() + .iter() + .map(|e| (e.topic().to_string(), e.partition())) + .collect(), + }, + Rebalance::Revoke(tpl) => RebalanceEvent { + kind: RebalanceEventKind::Revoke, + partitions: tpl + .elements() + .iter() + .map(|e| (e.topic().to_string(), e.partition())) + .collect(), + }, + Rebalance::Error(_) => RebalanceEvent { + kind: RebalanceEventKind::Error, + partitions: Vec::new(), + }, + } +} + +impl ClientContext for RecordingRebalanceContext {} + +impl ConsumerContext for RecordingRebalanceContext { + fn pre_rebalance(&self, _: &BaseConsumer, rebalance: &Rebalance) { + self.pre.lock().unwrap().push(record_rebalance(rebalance)); + } + + fn post_rebalance(&self, _: &BaseConsumer, rebalance: &Rebalance) { + self.post.lock().unwrap().push(record_rebalance(rebalance)); + } +} + +fn build_recording_consumer( + bootstrap_servers: &str, + group_id: &str, +) -> BaseConsumer { + let mut config = ClientConfig::new(); + config + .set("group.id", group_id) + .set("bootstrap.servers", bootstrap_servers) + .set("enable.partition.eof", "false") + .set("session.timeout.ms", "6000") + .set("enable.auto.commit", "false") + .set("auto.offset.reset", "earliest"); + config + .create_with_context::>( + RecordingRebalanceContext::new(), + ) + .expect("could not create recording base consumer") +} + +#[tokio::test] +async fn test_consumer_rebalance_callbacks() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_consumer_rebalance_callbacks"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(2)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let group = rand_test_group(); + + let consumer1 = build_recording_consumer(&kafka_context.bootstrap_servers, &group); + let context1 = consumer1.context().clone(); + consumer1.subscribe(&[topic_name.as_str()]).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(20); + loop { + consumer1.poll(Duration::from_millis(200)); + let assignment = consumer1.assignment().unwrap(); + if assignment.count() == 2 { + break; + } + if Instant::now() > deadline { + panic!( + "consumer1 never got both partitions; assignment count {}", + assignment.count() + ); + } + } + + let consumer2 = build_recording_consumer(&kafka_context.bootstrap_servers, &group); + let context2 = consumer2.context().clone(); + consumer2.subscribe(&[topic_name.as_str()]).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(30); + loop { + consumer1.poll(Duration::from_millis(200)); + consumer2.poll(Duration::from_millis(200)); + let a1 = consumer1.assignment().unwrap().count(); + let a2 = consumer2.assignment().unwrap().count(); + if a1 == 1 && a2 == 1 { + break; + } + if Instant::now() > deadline { + panic!( + "rebalance did not converge to one partition per consumer: c1={}, c2={}", + a1, a2 + ); + } + } + + let (pre1, post1) = context1.drain(); + let (pre2, post2) = context2.drain(); + + assert!( + pre1.iter().any(|e| e.kind == RebalanceEventKind::Assign), + "consumer1 never observed a pre-rebalance Assign event: {:?}", + pre1 + ); + assert!( + post1.iter().any(|e| e.kind == RebalanceEventKind::Assign), + "consumer1 never observed a post-rebalance Assign event: {:?}", + post1 + ); + let first_assign1 = post1 + .iter() + .find(|e| e.kind == RebalanceEventKind::Assign) + .expect("missing initial assign on consumer1"); + assert_eq!( + first_assign1.partitions.len(), + 2, + "consumer1's first post-rebalance assign should hold both partitions, got {:?}", + first_assign1.partitions + ); + for (topic, _) in &first_assign1.partitions { + assert_eq!(topic, &topic_name); + } + assert!( + post1.iter().any(|e| e.kind == RebalanceEventKind::Revoke), + "consumer1 never observed a post-rebalance Revoke event after consumer2 joined: {:?}", + post1 + ); + + assert!( + pre2.iter().any(|e| e.kind == RebalanceEventKind::Assign), + "consumer2 never observed a pre-rebalance Assign event: {:?}", + pre2 + ); + let assign2 = post2 + .iter() + .find(|e| e.kind == RebalanceEventKind::Assign) + .expect("consumer2 never observed a post-rebalance Assign event"); + assert_eq!( + assign2.partitions.len(), + 1, + "consumer2 should have been assigned exactly one partition, got {:?}", + assign2.partitions + ); + assert_eq!(assign2.partitions[0].0, topic_name); +} diff --git a/tests/base_producer.rs b/tests/base_producer.rs new file mode 100644 index 000000000..515b6766c --- /dev/null +++ b/tests/base_producer.rs @@ -0,0 +1,884 @@ +//! Test data production using low level producers. + +use std::collections::HashSet; +use std::error::Error; +use std::ffi::CString; +use std::sync::Arc; +use std::sync::Mutex; +use std::thread; +use std::time::Duration; + +use rdkafka::admin::AdminOptions; +use rdkafka::error::{KafkaError, RDKafkaErrorCode}; +use rdkafka::message::{Header, Headers, Message, OwnedHeaders, OwnedMessage}; +use rdkafka::producer::{ + BaseRecord, DeliveryResult, NoCustomPartitioner, Partitioner, Producer, ProducerContext, +}; +use rdkafka::types::RDKafkaRespErr; +use rdkafka::util::current_time_millis; +use rdkafka::{ClientContext, Statistics}; + +use crate::utils::admin; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::producer::base_producer as base_producer_utils; +use crate::utils::rand::*; + +mod utils; + +struct PrintingContext { + _n: i64, // Add data for memory access validation +} + +impl ClientContext for PrintingContext { + // Access and use all stats. + fn stats(&self, stats: Statistics) { + let stats_str = format!("{:?}", stats); + println!("Stats received: {} bytes", stats_str.len()); + } +} + +impl ProducerContext for PrintingContext { + type DeliveryOpaque = usize; + + fn delivery(&self, delivery_result: &DeliveryResult, delivery_opaque: Self::DeliveryOpaque) { + println!("Delivery: {:?} {:?}", delivery_result, delivery_opaque); + } +} + +type TestProducerDeliveryResult = (OwnedMessage, Option, usize); + +#[derive(Clone)] +struct CollectingContext { + stats: Arc>>, + results: Arc>>, + partitioner: Option, +} + +impl CollectingContext { + fn new() -> CollectingContext { + CollectingContext { + stats: Arc::new(Mutex::new(Vec::new())), + results: Arc::new(Mutex::new(Vec::new())), + partitioner: None, + } + } +} + +impl CollectingContext { + fn new_with_custom_partitioner(partitioner: Part) -> CollectingContext { + CollectingContext { + stats: Arc::new(Mutex::new(Vec::new())), + results: Arc::new(Mutex::new(Vec::new())), + partitioner: Some(partitioner), + } + } +} + +impl ClientContext for CollectingContext { + // Access and use all stats. + fn stats(&self, stats: Statistics) { + let mut stats_vec = self.stats.lock().unwrap(); + (*stats_vec).push(stats); + } +} + +impl ProducerContext for CollectingContext { + type DeliveryOpaque = usize; + + fn delivery(&self, delivery_result: &DeliveryResult, delivery_opaque: Self::DeliveryOpaque) { + let mut results = self.results.lock().unwrap(); + match *delivery_result { + Ok(ref message) => (*results).push((message.detach(), None, delivery_opaque)), + Err((ref err, ref message)) => { + (*results).push((message.detach(), Some(err.clone()), delivery_opaque)) + } + } + } + + fn get_custom_partitioner(&self) -> Option<&Part> { + match &self.partitioner { + None => None, + Some(p) => Some(p), + } + } +} + +// Partitioner sending all messages to single, defined partition. +#[derive(Clone)] +pub struct FixedPartitioner { + partition: i32, +} + +impl FixedPartitioner { + fn new(partition: i32) -> Self { + Self { partition } + } +} + +impl Partitioner for FixedPartitioner { + fn partition( + &self, + _topic_name: &str, + _key: Option<&[u8]>, + _partition_cnt: i32, + _is_paritition_available: impl Fn(i32) -> bool, + ) -> i32 { + self.partition + } +} + +#[derive(Clone)] +pub struct PanicPartitioner {} + +impl Partitioner for PanicPartitioner { + fn partition( + &self, + _topic_name: &str, + _key: Option<&[u8]>, + _partition_cnt: i32, + _is_paritition_available: impl Fn(i32) -> bool, + ) -> i32 { + panic!("partition() panic"); + } +} + +// TESTS + +#[tokio::test(flavor = "multi_thread")] +async fn test_base_producer_queue_full() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_base_producer_queue_full"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + PrintingContext { _n: 123 }, + &[("queue.buffering.max.messages", "10")], + ) + .expect("failed to create base producer"); + + let results = (0..30) + .map(|id| { + producer.send( + BaseRecord::with_opaque_to(&topic_name, id) + .payload("payload") + .key("key") + .timestamp(current_time_millis()), + ) + }) + .collect::>(); + while producer.in_flight_count() > 0 { + producer.poll(Duration::from_millis(100)); + } + + let errors = results + .iter() + .filter(|&e| { + matches!( + e, + &Err(( + KafkaError::MessageProduction(RDKafkaErrorCode::QueueFull), + _ + )) + ) + }) + .count(); + + let success = results.iter().filter(|&r| r.is_ok()).count(); + + assert_eq!(results.len(), 30); + assert_eq!(success, 10); + assert_eq!(errors, 20); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_base_producer_timeout() { + init_test_logger(); + + let context = CollectingContext::new(); + // Point the producer at an unreachable broker so messages cannot be + // delivered within message.timeout.ms and the delivery callback is + // invoked with MessageTimedOut. Using a real broker is unreliable here + // because Kafka's default auto.create.topics.enable racing with a 100ms + // timeout has produced flaky results on faster CI hardware. + let topic_name = rand_test_topic("test_base_producer_timeout"); + + let producer = base_producer_utils::create_base_producer_with_context( + "127.0.0.1:1", + context.clone(), + &[("message.timeout.ms", "100")], + ) + .expect("failed to create base producer"); + + let results_count = (0..10) + .map(|id| { + producer.send( + BaseRecord::with_opaque_to(&topic_name, id) + .payload("A") + .key("B"), + ) + }) + .filter(|r| r.is_ok()) + .count(); + assert_eq!(results_count, 10); + + thread::sleep(Duration::from_secs(5)); // Make sure messages expire + producer.flush(Duration::from_secs(10)).unwrap(); + + let delivery_results = context.results.lock().unwrap(); + let mut ids = HashSet::new(); + for &(ref message, ref error, id) in &(*delivery_results) { + assert_eq!(message.payload_view::(), Some(Ok("A"))); + assert_eq!(message.key_view::(), Some(Ok("B"))); + assert_eq!( + error, + &Some(KafkaError::MessageProduction( + RDKafkaErrorCode::MessageTimedOut + )) + ); + ids.insert(id); + } + assert_eq!(ids.len(), 10); +} + +struct HeaderCheckContext { + ids: Arc>>, +} + +impl ClientContext for HeaderCheckContext {} + +impl ProducerContext for HeaderCheckContext { + type DeliveryOpaque = usize; + + fn delivery(&self, delivery_result: &DeliveryResult, message_id: usize) { + let message = delivery_result.as_ref().unwrap(); + if message_id % 2 == 0 { + let headers = message.headers().unwrap(); + assert_eq!(headers.count(), 4); + assert_eq!( + headers.get(0), + Header { + key: "header1", + value: Some(&[1, 2, 3, 4][..]) + } + ); + assert_eq!( + headers.get_as::(1), + Ok(Header { + key: "header2", + value: Some("value2") + }) + ); + assert_eq!( + headers.get_as::<[u8]>(2), + Ok(Header { + key: "header3", + value: Some(&[][..]) + }) + ); + assert_eq!( + headers.get_as::<[u8]>(3), + Ok(Header { + key: "header4", + value: None + }) + ); + let headers: Vec<_> = headers.iter().collect(); + assert_eq!( + headers, + &[ + Header { + key: "header1", + value: Some(&[1, 2, 3, 4][..]), + }, + Header { + key: "header2", + value: Some(b"value2"), + }, + Header { + key: "header3", + value: Some(&[][..]), + }, + Header { + key: "header4", + value: None, + }, + ], + ) + } else { + assert!(message.headers().is_none()); + } + (*self.ids.lock().unwrap()).insert(message_id); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_base_producer_headers() { + init_test_logger(); + + let ids_set = Arc::new(Mutex::new(HashSet::new())); + let context = HeaderCheckContext { + ids: ids_set.clone(), + }; + let topic_name = rand_test_topic("test_base_producer_headers"); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + context, + &[], + ) + .expect("failed to create base producer"); + + let results_count = (0..10) + .map(|id| { + let mut record = BaseRecord::with_opaque_to(&topic_name, id).payload("A"); + if id % 2 == 0 { + record = record.headers( + OwnedHeaders::new() + .insert(Header { + key: "header1", + value: Some(&[1, 2, 3, 4]), + }) + .insert(Header { + key: "header2", + value: Some("value2"), + }) + .insert(Header { + key: "header3", + value: Some(&[]), + }) + .insert::>(Header { + key: "header4", + value: None, + }), + ); + } + producer.send::(record) + }) + .filter(|r| r.is_ok()) + .count(); + + producer.flush(Duration::from_secs(10)).unwrap(); + + assert_eq!(results_count, 10); + assert_eq!((*ids_set.lock().unwrap()).len(), 10); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_threaded_producer_send() { + init_test_logger(); + + let context = CollectingContext::new(); + let topic_name = rand_test_topic("test_threaded_producer_send"); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = base_producer_utils::create_threaded_producer_with_context( + &kafka_context.bootstrap_servers, + context.clone(), + &[], + ) + .expect("failed to create threaded producer"); + + let results_count = (0..10) + .map(|id| { + producer.send( + BaseRecord::with_opaque_to(&topic_name, id) + .payload("A") + .key("B"), + ) + }) + .filter(|r| r.is_ok()) + .count(); + + assert_eq!(results_count, 10); + producer.flush(Duration::from_secs(10)).unwrap(); + + let delivery_results = context.results.lock().unwrap(); + let mut ids = HashSet::new(); + for &(ref message, ref error, id) in &(*delivery_results) { + assert_eq!(message.payload_view::(), Some(Ok("A"))); + assert_eq!(message.key_view::(), Some(Ok("B"))); + assert_eq!(error, &None); + ids.insert(id); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_base_producer_opaque_arc() -> Result<(), Box> { + init_test_logger(); + + struct OpaqueArcContext {} + impl ClientContext for OpaqueArcContext {} + impl ProducerContext for OpaqueArcContext { + type DeliveryOpaque = Arc>; + + fn delivery(&self, _: &DeliveryResult, opaque: Self::DeliveryOpaque) { + let mut shared_count = opaque.lock().unwrap(); + *shared_count += 1; + } + } + + let shared_count = Arc::new(Mutex::new(0)); + let context = OpaqueArcContext {}; + let topic_name = rand_test_topic("test_base_producer_opaque_arc"); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + context, + &[], + ) + .expect("failed to create base producer"); + + let results_count = (0..10) + .map(|_| { + let record = BaseRecord::with_opaque_to(&topic_name, shared_count.clone()).payload("A"); + producer.send::(record) + }) + .filter(|r| r.is_ok()) + .count(); + + producer.flush(Duration::from_secs(10)).unwrap(); + + let shared_count = Arc::try_unwrap(shared_count).unwrap().into_inner()?; + assert_eq!(results_count, shared_count); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_fatal_errors() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + PrintingContext { _n: 123 }, + &[], + ) + .expect("failed to create base producer"); + + assert_eq!(producer.client().fatal_error(), None); + + let msg = CString::new("fake error").unwrap(); + unsafe { + rdkafka_sys::rd_kafka_test_fatal_error( + producer.client().native_ptr(), + RDKafkaRespErr::RD_KAFKA_RESP_ERR_OUT_OF_ORDER_SEQUENCE_NUMBER, + msg.as_ptr(), + ); + } + + assert_eq!( + producer.client().fatal_error(), + Some(( + RDKafkaErrorCode::OutOfOrderSequenceNumber, + "test_fatal_error: fake error".into() + )) + ) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_register_custom_partitioner_linger_non_zero_key_null() { + // Custom partitioner is not used when sticky.partitioning.linger.ms > 0 and key is null. + // https://github.com/confluentinc/librdkafka/blob/081fd972fa97f88a1e6d9a69fc893865ffbb561a/src/rdkafka_msg.c#L1192-L1196 + init_test_logger(); + + let context = CollectingContext::new_with_custom_partitioner(PanicPartitioner {}); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_register_custom_partitioner_linger_non_zero_key_null"); + + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + context.clone(), + &[("sticky.partitioning.linger.ms", "10")], + ) + .expect("failed to create base producer"); + + producer + .send(BaseRecord::<(), str, usize>::with_opaque_to(&topic_name, 0).payload("")) + .unwrap(); + producer.flush(Duration::from_secs(10)).unwrap(); + + let delivery_results = context.results.lock().unwrap(); + + assert_eq!(delivery_results.len(), 1); + + for (_, error, _) in &(*delivery_results) { + assert_eq!(*error, None); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_custom_partitioner_base_producer() { + init_test_logger(); + + let context = CollectingContext::new_with_custom_partitioner(FixedPartitioner::new(2)); + let topic_name = rand_test_topic("test_custom_partitioner_base_producer"); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + context.clone(), + &[], + ) + .expect("failed to create base producer"); + + let results_count = (0..10) + .map(|id| { + producer.send( + BaseRecord::with_opaque_to(&topic_name, id) + .payload("") + .key(""), + ) + }) + .filter(|r| r.is_ok()) + .count(); + + assert_eq!(results_count, 10); + producer.flush(Duration::from_secs(10)).unwrap(); + + let delivery_results = context.results.lock().unwrap(); + + for (message, error, _) in &(*delivery_results) { + assert_eq!(error, &None); + assert_eq!(message.partition(), 2); + } +} + +// `statistics.interval.ms=100` directs librdkafka to invoke +// `ClientContext::stats` every 100ms. This test runs a send loop for ~400ms +// against a real broker, asserts the callback fired at least twice, and (via +// the `Statistics` struct that the binding already deserialises into) that +// each delivered struct carries non-empty top-level metadata. A binding +// regression that swallowed the stats callback or deserialised the JSON into +// a partial / default struct would fail one of those assertions. +#[tokio::test(flavor = "multi_thread")] +async fn test_base_producer_statistics_callback_invoked() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_base_producer_statistics_callback_invoked"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let context = CollectingContext::new(); + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + context.clone(), + &[("statistics.interval.ms", "100")], + ) + .expect("failed to create base producer"); + + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_millis(400) { + producer + .send( + BaseRecord::with_opaque_to(&topic_name, 0usize) + .payload("p") + .key("k"), + ) + .expect("send failed"); + producer.poll(Duration::from_millis(50)); + } + producer + .flush(Duration::from_secs(10)) + .expect("flush failed"); + + let stats = context.stats.lock().unwrap(); + assert!( + stats.len() >= 2, + "expected at least two stats callbacks over 400ms with interval 100ms, got {}", + stats.len() + ); + for snapshot in stats.iter() { + assert!( + !snapshot.name.is_empty(), + "Statistics.name should be populated: {:?}", + snapshot + ); + assert_eq!( + snapshot.client_type, "producer", + "Statistics.client_type should report producer: {:?}", + snapshot.client_type + ); + } +} + +// Asserts the synchronous contract of `Producer::flush`: when `flush(timeout)` +// returns successfully, every previously-queued record has reached its +// delivery callback and the in-flight counter is zero. A binding regression +// that returned early from `flush` (or surfaced the wrong in-flight value) +// would let a caller drop the producer while messages were still buffered. +#[tokio::test(flavor = "multi_thread")] +async fn test_base_producer_flush_drains_inflight() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_base_producer_flush_drains_inflight"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let context = CollectingContext::new(); + // `linger.ms=100` buffers records for up to 100ms before sending, so the + // in-flight count is non-zero immediately after the loop and `flush` has + // real work to drain. + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + context.clone(), + &[("linger.ms", "100")], + ) + .expect("failed to create base producer"); + + const N: usize = 200; + for id in 0..N { + producer + .send( + BaseRecord::with_opaque_to(&topic_name, id) + .payload("payload") + .key("key"), + ) + .expect("send failed"); + } + assert!( + producer.in_flight_count() > 0, + "expected non-zero in-flight count after queuing {} records, got 0", + N + ); + + producer + .flush(Duration::from_secs(20)) + .expect("flush returned an error"); + + assert_eq!( + producer.in_flight_count(), + 0, + "in-flight count must be zero after a successful flush", + ); + let delivered = context.results.lock().unwrap(); + assert_eq!( + delivered.len(), + N, + "every queued record should have hit the delivery callback by the time flush returns", + ); + for (_, error, _) in delivered.iter() { + assert!(error.is_none(), "unexpected delivery error: {:?}", error); + } +} + +// librdkafka rejects payloads larger than `message.max.bytes` synchronously +// inside `rd_kafka_produce`, surfacing +// `MessageProduction(RDKafkaErrorCode::MessageSizeTooLarge)` from +// `BaseProducer::send`. A binding regression that swallows or remaps that +// error would cause the producer to hang on the eventual flush or silently +// drop the message; this test asserts the synchronous error shape so that +// regression is loud. +#[tokio::test(flavor = "multi_thread")] +async fn test_base_producer_message_too_large() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_base_producer_message_too_large"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let context = CollectingContext::new(); + let producer = base_producer_utils::create_base_producer_with_context( + &kafka_context.bootstrap_servers, + context.clone(), + &[("message.max.bytes", "1024")], + ) + .expect("failed to create base producer"); + + let oversized = vec![b'x'; 4096]; + let result = producer.send( + BaseRecord::with_opaque_to(&topic_name, 0usize) + .payload(&oversized) + .key("k"), + ); + + match result { + Err((KafkaError::MessageProduction(RDKafkaErrorCode::MessageSizeTooLarge), _record)) => {} + Err((other, _)) => panic!("unexpected error variant: {:?}", other), + Ok(()) => panic!("send unexpectedly succeeded for an oversized payload"), + } + + let small_record = BaseRecord::with_opaque_to(&topic_name, 1usize) + .payload(b"ok" as &[u8]) + .key("k"); + producer + .send(small_record) + .expect("baseline small send should succeed"); + producer.flush(Duration::from_secs(10)).unwrap(); + + let delivered: Vec<_> = context + .results + .lock() + .unwrap() + .iter() + .map(|(_, err, id)| (err.clone(), *id)) + .collect(); + assert_eq!( + delivered.len(), + 1, + "only the baseline small payload should reach the delivery callback: {:?}", + delivered + ); + assert_eq!(delivered[0], (None, 1)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_custom_partitioner_threaded_producer() { + init_test_logger(); + + let context = CollectingContext::new_with_custom_partitioner(FixedPartitioner::new(2)); + let topic_name = rand_test_topic("test_custom_partitioner_threaded_producer"); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = base_producer_utils::create_threaded_producer_with_context( + &kafka_context.bootstrap_servers, + context.clone(), + &[], + ) + .expect("failed to create threaded producer"); + + let results_count = (0..10) + .map(|id| { + producer.send( + BaseRecord::with_opaque_to(&topic_name, id) + .payload("") + .key(""), + ) + }) + .filter(|r| r.is_ok()) + .count(); + + assert_eq!(results_count, 10); + producer.flush(Duration::from_secs(10)).unwrap(); + + let delivery_results = context.results.lock().unwrap(); + + for (message, error, _) in &(*delivery_results) { + assert_eq!(error, &None); + assert_eq!(message.partition(), 2); + } +} diff --git a/tests/consumer_groups.rs b/tests/consumer_groups.rs new file mode 100644 index 000000000..e41325665 --- /dev/null +++ b/tests/consumer_groups.rs @@ -0,0 +1,258 @@ +use std::time::{Duration, Instant}; + +use crate::utils::consumer; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::rand::{rand_test_group, rand_test_topic}; +use rdkafka::admin::{AdminOptions, GroupResult, NewTopic, TopicReplication}; +use rdkafka::consumer::Consumer; +use rdkafka_sys::RDKafkaErrorCode; + +mod utils; + +/// Verify that a valid group can be deleted. +#[tokio::test] +pub async fn test_consumer_groups_deletion() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // Create admin client + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + + // Create consumer_client + let group_name = rand_test_group(); + let topic_name = rand_test_topic("test_topic"); + let consumer_client = utils::consumer::create_unsubscribed_base_consumer( + &kafka_context.bootstrap_servers, + Some(&group_name), + ) + .await + .expect("could not create subscribed base consumer"); + + admin_client + .create_topics( + &[NewTopic { + name: &topic_name, + num_partitions: 1, + replication: TopicReplication::Fixed(1), + config: vec![], + }], + &AdminOptions::default(), + ) + .await + .expect("topic creation failed"); + + utils::consumer::create_consumer_group_on_topic(&consumer_client, &topic_name) + .await + .expect("could not create group"); + let res = admin_client + .delete_groups(&[&group_name], &AdminOptions::default()) + .await + .expect("could not delete groups"); + assert_eq!(res, [Ok(group_name.to_string())]); +} + +/// Verify that attempting to delete an unknown group returns a "group not +/// found" error. +#[tokio::test] +pub async fn test_delete_unknown_group() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // Create admin client + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + + let unknown_group_name = rand_test_group(); + let res = admin_client + .delete_groups(&[&unknown_group_name], &AdminOptions::default()) + .await + .expect("delete_groups call failed"); + // The broker reports GroupIdNotFound once the consumer-group coordinator + // has been initialised (any prior test in the binary that touched a + // group is enough), and NotCoordinator on a cold broker. Both indicate + // the same thing for this test: the group does not exist. + let group_result: &GroupResult = res.first().expect("expected one result"); + let (returned_name, code) = group_result + .as_ref() + .expect_err("expected an error for an unknown group"); + assert_eq!(returned_name, &unknown_group_name); + assert!( + matches!( + code, + RDKafkaErrorCode::GroupIdNotFound | RDKafkaErrorCode::NotCoordinator + ), + "unexpected error code: {:?}", + code + ); +} + +// `delete_groups` cannot remove a group while it still has an active member. +// This test subscribes a consumer to a topic, drives it until it has actually +// joined the group, calls `delete_groups`, and asserts the per-group result +// is `NonEmptyGroup`. It then drops the consumer (which sends LeaveGroup), +// retries `delete_groups`, and asserts the second call succeeds. A binding +// regression that misclassified the per-group error or that lost the +// active-membership signal in the second-call retry would fail one of those +// assertions. +#[tokio::test] +pub async fn test_delete_non_empty_consumer_group() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + + let group_name = rand_test_group(); + let topic_name = rand_test_topic("test_delete_non_empty_group"); + + admin_client + .create_topics( + &[NewTopic { + name: &topic_name, + num_partitions: 1, + replication: TopicReplication::Fixed(1), + config: vec![], + }], + &AdminOptions::default(), + ) + .await + .expect("topic creation failed"); + + let consumer_client = utils::consumer::create_subscribed_base_consumer( + &kafka_context.bootstrap_servers, + Some(&group_name), + &topic_name, + ) + .await + .expect("could not create subscribed consumer"); + + let deadline = Instant::now() + Duration::from_secs(20); + loop { + consumer_client.poll(Duration::from_millis(200)); + if consumer_client.assignment().unwrap().count() > 0 { + break; + } + if Instant::now() > deadline { + panic!("consumer never joined the group"); + } + } + + let res = admin_client + .delete_groups(&[&group_name], &AdminOptions::default()) + .await + .expect("delete_groups call should not itself fail"); + let first: &GroupResult = res.first().expect("expected one result"); + let (returned_name, code) = first + .as_ref() + .expect_err("delete_groups on a non-empty group should be an error"); + assert_eq!(returned_name, &group_name); + assert_eq!( + *code, + RDKafkaErrorCode::NonEmptyGroup, + "expected NonEmptyGroup while the consumer is still active, got {:?}", + code + ); + + drop(consumer_client); + + let deadline = Instant::now() + Duration::from_secs(30); + let last_err: RDKafkaErrorCode = loop { + let res = admin_client + .delete_groups(&[&group_name], &AdminOptions::default()) + .await + .expect("delete_groups call should not itself fail"); + match res.first().expect("expected one result") { + Ok(name) => { + assert_eq!(name, &group_name); + return; + } + Err((_, code)) => { + if Instant::now() > deadline { + break *code; + } + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + }; + panic!( + "delete_groups never converged to success after consumer drop (last error: {:?})", + last_err + ); +} + +/// Verify that deleting a valid and invalid group results in a mixed result +/// set. +#[tokio::test] +pub async fn test_consumer_group_action_mixed_results() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + // Create admin client + let admin_client = utils::admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + + // Create consumer_client + let group_name = rand_test_group(); + let topic_name = rand_test_topic("test_topic"); + let consumer_client = utils::consumer::create_unsubscribed_base_consumer( + &kafka_context.bootstrap_servers, + Some(&group_name), + ) + .await + .expect("could not create subscribed base consumer"); + + admin_client + .create_topics( + &[NewTopic { + name: &topic_name, + num_partitions: 1, + replication: TopicReplication::Fixed(1), + config: vec![], + }], + &AdminOptions::default(), + ) + .await + .expect("topic creation failed"); + + let unknown_group_name = rand_test_group(); + consumer::create_consumer_group_on_topic(&consumer_client, &topic_name) + .await + .expect("could not create group"); + let res = admin_client + .delete_groups( + &[&group_name, &unknown_group_name], + &AdminOptions::default(), + ) + .await; + assert_eq!( + res, + Ok(vec![ + Ok(group_name.to_string()), + Err(( + unknown_group_name.to_string(), + RDKafkaErrorCode::GroupIdNotFound + )) + ]) + ); +} diff --git a/tests/future_producer.rs b/tests/future_producer.rs new file mode 100644 index 000000000..699d13609 --- /dev/null +++ b/tests/future_producer.rs @@ -0,0 +1,532 @@ +//! Test data production using high level producers. + +use std::time::{Duration, Instant}; + +use futures::future; +use futures::stream::{FuturesUnordered, StreamExt}; + +use rdkafka::admin::AdminOptions; +use rdkafka::client::DefaultClientContext; +use rdkafka::config::ClientConfig; +use rdkafka::consumer::Consumer; +use rdkafka::error::{KafkaError, RDKafkaErrorCode}; +use rdkafka::message::{Header, Headers, Message, OwnedHeaders}; +use rdkafka::producer::{FutureProducer, FutureRecord, Producer}; +use rdkafka::util::Timeout; +use rdkafka::Timestamp; + +use crate::utils::admin; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::producer; +use crate::utils::rand::*; + +mod utils; + +#[tokio::test] +async fn test_future_producer_send() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_future_producer_send"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let results: FuturesUnordered<_> = (0..10) + .map(|_| { + producer.send( + FutureRecord::to(&topic_name).payload("A").key("B"), + Duration::from_secs(0), + ) + }) + .collect(); + + let results: Vec<_> = results.collect().await; + assert!(results.len() == 10); + for (i, result) in results.into_iter().enumerate() { + let delivered = result.unwrap(); + assert_eq!(delivered.partition, 1); + assert_eq!(delivered.offset, i as i64); + assert!(delivered.timestamp < Timestamp::now()); + } +} + +#[tokio::test] +async fn test_future_producer_send_full() { + // Connect to a nonexistent Kafka broker with a long message timeout and a + // tiny producer queue, so we can fill up the queue for a while by sending a + // single message. + let mut config = ClientConfig::new(); + config + .set("bootstrap.servers", "") + .set("message.timeout.ms", "5000") + .set("queue.buffering.max.messages", "1"); + let producer: FutureProducer = + config.create().expect("Failed to create producer"); + let producer = &producer; + let topic_name = &rand_test_topic("test_future_producer_send_full"); + + // Fill up the queue. + producer + .send_result(FutureRecord::to(topic_name).payload("A").key("B")) + .unwrap(); + + let send_message = |timeout| async move { + let start = Instant::now(); + let res = producer + .send(FutureRecord::to(topic_name).payload("A").key("B"), timeout) + .await; + match res { + Ok(_) => panic!("send unexpectedly succeeded"), + Err((KafkaError::MessageProduction(RDKafkaErrorCode::QueueFull), _)) => start.elapsed(), + Err((e, _)) => panic!("got incorrect error: {}", e), + } + }; + + // Sending a message with no timeout should return a `QueueFull` error + // approximately immediately. + let elapsed = send_message(Duration::from_secs(0)).await; + assert!(elapsed < Duration::from_millis(20)); + + // Sending a message with a 1s timeout should return a `QueueFull` error + // in about 1s. + let elapsed = send_message(Duration::from_secs(1)).await; + assert!(elapsed > Duration::from_millis(800)); + assert!(elapsed < Duration::from_millis(1200)); + + producer.flush(Timeout::Never).unwrap(); +} + +#[tokio::test] +async fn test_future_producer_send_fail() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_future_producer_send_fail"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let future = producer.send( + FutureRecord::to(&topic_name) + .payload("payload") + .key("key") + .partition(100) // Fail + .headers( + OwnedHeaders::new() + .insert(Header { + key: "0", + value: Some("A"), + }) + .insert(Header { + key: "1", + value: Some("B"), + }) + .insert(Header { + key: "2", + value: Some("C"), + }), + ), + Duration::from_secs(10), + ); + + match future.await { + Err((kafka_error, owned_message)) => { + assert_eq!( + kafka_error.to_string(), + "Message production error: UnknownPartition (Local: Unknown partition)" + ); + assert_eq!(owned_message.topic(), topic_name.as_str()); + let headers = owned_message.headers().unwrap(); + assert_eq!(headers.count(), 3); + assert_eq!( + headers.get_as::(0), + Ok(Header { + key: "0", + value: Some("A") + }) + ); + assert_eq!( + headers.get_as::(1), + Ok(Header { + key: "1", + value: Some("B") + }) + ); + assert_eq!( + headers.get_as::(2), + Ok(Header { + key: "2", + value: Some("C") + }) + ); + } + e => { + panic!("Unexpected return value: {:?}", e); + } + } +} + +async fn run_compression_round_trip(codec: &str) { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic(&format!("test_compression_{}", codec)); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer_with_overrides( + &kafka_context.bootstrap_servers, + &[("compression.type", codec), ("linger.ms", "20")], + ) + .await + .expect("could not create future producer"); + + const N: usize = 64; + let payload = "rust-rdkafka compression round trip ".repeat(8); + let keys: Vec = (0..N).map(|i| format!("k{}", i)).collect(); + let values: Vec = (0..N).map(|i| format!("{}:{}", i, payload)).collect(); + let mut futures = Vec::with_capacity(N); + for i in 0..N { + futures.push( + producer.send( + FutureRecord::to(&topic_name) + .partition(0) + .key(&keys[i]) + .payload(&values[i]), + Duration::from_secs(10), + ), + ); + } + let mut expected = std::collections::HashMap::with_capacity(N); + for (i, future) in futures.into_iter().enumerate() { + let delivered = future.await.unwrap_or_else(|(e, _)| { + panic!("delivery failed for codec {} message {}: {}", codec, i, e) + }); + expected.insert(delivered.offset, (keys[i].clone(), values[i].clone())); + } + producer + .flush(Timeout::After(Duration::from_secs(10))) + .unwrap(); + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + consumer.subscribe(&[topic_name.as_str()]).unwrap(); + + let mut seen = 0usize; + consumer + .stream() + .take(N) + .for_each(|message| { + let m = message.expect("error receiving message"); + let (expected_key, expected_value) = expected + .remove(&m.offset()) + .unwrap_or_else(|| panic!("unexpected offset {} for codec {}", m.offset(), codec)); + assert_eq!(m.key_view::().unwrap().unwrap(), expected_key); + assert_eq!(m.payload_view::().unwrap().unwrap(), expected_value); + seen += 1; + future::ready(()) + }) + .await; + assert_eq!(seen, N, "codec {} did not yield all messages", codec); + assert!( + expected.is_empty(), + "codec {} left {} unmatched offsets", + codec, + expected.len() + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_future_producer_compression_gzip() { + run_compression_round_trip("gzip").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_future_producer_compression_snappy() { + run_compression_round_trip("snappy").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_future_producer_compression_lz4() { + run_compression_round_trip("lz4").await; +} + +// librdkafka is built with `--disable-zstd` unless the `zstd` Cargo feature is +// enabled (see rdkafka-sys/build.rs), so this test can only run when that +// feature is on. CI exercises it by passing `--features zstd` to the test job. +#[cfg(feature = "zstd")] +#[tokio::test(flavor = "multi_thread")] +async fn test_future_producer_compression_zstd() { + run_compression_round_trip("zstd").await; +} + +// Enables the idempotent producer and produces in two batches separated by a +// flush, so the second batch starts after the first has fully drained. If the +// PID/epoch tracking that librdkafka enables under `enable.idempotence=true` +// (acks=all, retries, in-flight bound, sequence numbers) regresses in the +// binding, the consumer side will see duplicate or missing offsets / payloads. +// +// A true "forced reconnect midway" requires either a proxy or a privileged +// in-process disconnect; both are out of scope for the testcontainer setup, +// and aggressive `connections.max.idle.ms` produces MessageTimedOut errors +// rather than testing idempotence. The flush boundary is a cheap stand-in +// that exercises the producer's recovery from a fully-drained pipeline. +#[tokio::test(flavor = "multi_thread")] +async fn test_future_producer_idempotence() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_future_producer_idempotence"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer_with_overrides( + &kafka_context.bootstrap_servers, + &[ + ("enable.idempotence", "true"), + ("message.timeout.ms", "30000"), + ], + ) + .await + .expect("could not create idempotent future producer"); + + const N: usize = 1000; + const HALF: usize = N / 2; + let payloads: Vec = (0..N).map(|i| format!("idempotent-{:04}", i)).collect(); + + let mut delivery_futures = Vec::with_capacity(HALF); + for payload in &payloads[..HALF] { + delivery_futures.push( + producer.send( + FutureRecord::<(), str>::to(&topic_name) + .partition(0) + .payload(payload), + Duration::from_secs(30), + ), + ); + } + for (i, fut) in delivery_futures.into_iter().enumerate() { + fut.await + .unwrap_or_else(|(e, _)| panic!("first-half delivery {} failed: {}", i, e)); + } + + producer + .flush(Timeout::After(Duration::from_secs(30))) + .unwrap(); + + let mut delivery_futures = Vec::with_capacity(N - HALF); + for payload in &payloads[HALF..] { + delivery_futures.push( + producer.send( + FutureRecord::<(), str>::to(&topic_name) + .partition(0) + .payload(payload), + Duration::from_secs(30), + ), + ); + } + for (i, fut) in delivery_futures.into_iter().enumerate() { + fut.await + .unwrap_or_else(|(e, _)| panic!("second-half delivery {} failed: {}", i, e)); + } + producer + .flush(Timeout::After(Duration::from_secs(30))) + .unwrap(); + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + consumer.subscribe(&[topic_name.as_str()]).unwrap(); + + let mut seen_offsets = std::collections::BTreeSet::new(); + let mut seen_payloads = std::collections::HashSet::new(); + consumer + .stream() + .take(N) + .for_each(|message| { + let m = message.expect("error receiving message"); + assert!( + seen_offsets.insert(m.offset()), + "duplicate offset {} delivered", + m.offset() + ); + let payload = m.payload_view::().unwrap().unwrap().to_string(); + assert!( + seen_payloads.insert(payload.clone()), + "duplicate payload {} delivered", + payload + ); + future::ready(()) + }) + .await; + assert_eq!(seen_offsets.len(), N); + assert_eq!(*seen_offsets.iter().next().unwrap(), 0); + assert_eq!(*seen_offsets.iter().next_back().unwrap(), (N as i64) - 1); + assert_eq!(seen_payloads.len(), N); + for payload in &payloads { + assert!( + seen_payloads.contains(payload), + "missing payload {}", + payload + ); + } +} + +// librdkafka's default `consistent_random` partitioner hashes keyed records +// to a partition with CRC32. A binding regression that drops or rewrites the +// key on the way into librdkafka would cause the same key to map to +// different partitions across sends; this test produces 16 copies of each +// key across 4 keys and asserts every copy of each key landed on exactly one +// partition. +#[tokio::test(flavor = "multi_thread")] +async fn test_future_producer_default_partitioner_is_deterministic() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_future_producer_default_partitioner"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(6)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("could not create future producer"); + + let keys = ["alpha", "beta", "gamma", "delta"]; + const COPIES: usize = 16; + + let mut per_key: std::collections::HashMap<&str, std::collections::HashSet> = + std::collections::HashMap::new(); + for (k_idx, key) in keys.iter().enumerate() { + for c in 0..COPIES { + let payload = format!("payload-{}-{}", k_idx, c); + let delivered = producer + .send( + FutureRecord::to(&topic_name).key(*key).payload(&payload), + Duration::from_secs(10), + ) + .await + .unwrap_or_else(|(e, _)| panic!("delivery failed for key {}: {}", key, e)); + per_key.entry(*key).or_default().insert(delivered.partition); + } + } + + for key in keys { + let partitions = per_key.get(key).expect("missing deliveries for key"); + assert_eq!( + partitions.len(), + 1, + "key {} mapped to multiple partitions: {:?}", + key, + partitions + ); + } + let chosen: std::collections::HashSet = per_key.values().flatten().copied().collect(); + assert!( + chosen.len() >= 2, + "with four keys over six partitions we should see at least two distinct partitions, got {:?}", + chosen + ); +} + +#[tokio::test] +async fn test_future_undelivered() { + let delivery_future = { + let mut config = ClientConfig::new(); + // There's no server running there + config + .set("bootstrap.servers", "localhost:47021") + .set("message.timeout.ms", "1"); + let producer: FutureProducer = config.create().expect("Failed to create producer"); + + producer + .send_result( + FutureRecord::to("topic") + .payload("payload") + .key("key") + .partition(100), + ) + .expect("Failed to queue message") + + // drop producer. This should resolve the future as per purge API (couldn't be delivered) + }; + + match delivery_future.await { + Ok(Err((kafka_error, owned_message))) => { + assert_eq!( + kafka_error.to_string(), + "Message production error: PurgeQueue (Local: Purged in queue)" + ); + assert_eq!(owned_message.topic(), "topic"); + assert_eq!(owned_message.key(), Some(b"key" as _)); + assert_eq!(owned_message.payload(), Some(b"payload" as _)); + } + v => { + panic!("Unexpected return value: {:?}", v); + } + } +} diff --git a/tests/test_metadata.rs b/tests/metadata.rs similarity index 51% rename from tests/test_metadata.rs rename to tests/metadata.rs index eab0731b5..ba67cefcf 100644 --- a/tests/test_metadata.rs +++ b/tests/metadata.rs @@ -2,6 +2,7 @@ use std::time::Duration; +use rdkafka::admin::AdminOptions; use rdkafka::config::ClientConfig; use rdkafka::consumer::{Consumer, StreamConsumer}; use rdkafka::error::KafkaError; @@ -9,52 +10,76 @@ use rdkafka::topic_partition_list::TopicPartitionList; use rdkafka_sys::types::RDKafkaConfRes; +use crate::utils::admin; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::producer; +use crate::utils::rand::*; use crate::utils::*; mod utils; -fn create_consumer(group_id: &str) -> StreamConsumer { - ClientConfig::new() - .set("group.id", group_id) - .set("enable.partition.eof", "true") - .set("client.id", "rdkafka_integration_test_client") - .set("bootstrap.servers", get_bootstrap_server().as_str()) - .set("session.timeout.ms", "6000") - .set("debug", "all") - .set("auto.offset.reset", "earliest") - .create() - .expect("Failed to create StreamConsumer") +async fn create_consumer(kafka_context: &KafkaContext, group_id: &str) -> StreamConsumer { + utils::consumer::stream_consumer::create_stream_consumer_with_options( + &kafka_context.bootstrap_servers, + group_id, + &[ + ("enable.partition.eof", "true"), + ("client.id", "rdkafka_integration_test_client"), + ("session.timeout.ms", "6000"), + ("debug", "all"), + ], + ) + .await + .expect("Failed to create StreamConsumer") } #[tokio::test] async fn test_metadata() { - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let topic_name = rand_test_topic("test_metadata"); - populate_topic(&topic_name, 1, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 1, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 1, &value_fn, &key_fn, Some(2), None).await; - let consumer = create_consumer(&rand_test_group()); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + produce_messages_to_partition(&producer, &topic_name, 1, 0).await; + produce_messages_to_partition(&producer, &topic_name, 1, 1).await; + produce_messages_to_partition(&producer, &topic_name, 1, 2).await; + + let consumer = create_consumer(&kafka_context, &rand_test_group()).await; let metadata = consumer .fetch_metadata(None, Duration::from_secs(5)) .unwrap(); let orig_broker_id = metadata.orig_broker_id(); // The orig_broker_id may be -1 if librdkafka's bootstrap "broker" handles - // the request. - if orig_broker_id != -1 && orig_broker_id != 0 { + // the request. The testcontainers Kafka image assigns BROKER_ID=1. + if orig_broker_id != -1 && orig_broker_id != BROKER_ID { panic!( - "metadata.orig_broker_id = {}, not 0 or 1 as expected", - orig_broker_id + "metadata.orig_broker_id = {}, not -1 or {} as expected", + orig_broker_id, BROKER_ID ) } assert!(!metadata.orig_broker_name().is_empty()); let broker_metadata = metadata.brokers(); assert_eq!(broker_metadata.len(), 1); - assert_eq!(broker_metadata[0].id(), 0); + assert_eq!(broker_metadata[0].id(), BROKER_ID); assert!(!broker_metadata[0].host().is_empty()); - assert_eq!(broker_metadata[0].port(), 9092); let topic_metadata = metadata .topics() @@ -75,11 +100,11 @@ async fn test_metadata() { assert_eq!(ids, vec![0, 1, 2]); assert_eq!(topic_metadata.error(), None); assert_eq!(topic_metadata.partitions().len(), 3); - assert_eq!(topic_metadata.partitions()[0].leader(), 0); - assert_eq!(topic_metadata.partitions()[1].leader(), 0); - assert_eq!(topic_metadata.partitions()[2].leader(), 0); - assert_eq!(topic_metadata.partitions()[0].replicas(), &[0]); - assert_eq!(topic_metadata.partitions()[0].isr(), &[0]); + assert_eq!(topic_metadata.partitions()[0].leader(), BROKER_ID); + assert_eq!(topic_metadata.partitions()[1].leader(), BROKER_ID); + assert_eq!(topic_metadata.partitions()[2].leader(), BROKER_ID); + assert_eq!(topic_metadata.partitions()[0].replicas(), &[BROKER_ID]); + assert_eq!(topic_metadata.partitions()[0].isr(), &[BROKER_ID]); let metadata_one_topic = consumer .fetch_metadata(Some(&topic_name), Duration::from_secs(5)) @@ -89,11 +114,27 @@ async fn test_metadata() { #[tokio::test] async fn test_subscription() { - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let topic_name = rand_test_topic("test_subscription"); - populate_topic(&topic_name, 10, &value_fn, &key_fn, None, None).await; - let consumer = create_consumer(&rand_test_group()); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + produce_messages(&producer, &topic_name, 10, None, None).await; + let consumer = create_consumer(&kafka_context, &rand_test_group()).await; consumer.subscribe(&[topic_name.as_str()]).unwrap(); // Make sure the consumer joins the group. @@ -106,14 +147,32 @@ async fn test_subscription() { #[tokio::test] async fn test_group_membership() { - let _r = env_logger::try_init(); + init_test_logger(); + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); let topic_name = rand_test_topic("test_group_membership"); let group_name = rand_test_group(); - populate_topic(&topic_name, 1, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 1, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 1, &value_fn, &key_fn, Some(2), None).await; - let consumer = create_consumer(&group_name); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + produce_messages_to_partition(&producer, &topic_name, 1, 0).await; + produce_messages_to_partition(&producer, &topic_name, 1, 1).await; + produce_messages_to_partition(&producer, &topic_name, 1, 2).await; + + let consumer = create_consumer(&kafka_context, &group_name).await; consumer.subscribe(&[topic_name.as_str()]).unwrap(); // Make sure the consumer joins the group. diff --git a/tests/producer.rs b/tests/producer.rs new file mode 100644 index 000000000..f3d53feff --- /dev/null +++ b/tests/producer.rs @@ -0,0 +1,83 @@ +use crate::utils::admin::{create_admin_client, create_topic}; +use crate::utils::consumer::{create_subscribed_base_consumer, poll_x_times_for_messages}; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::producer::base_producer::create_producer; +use crate::utils::rand::rand_test_topic; +use rdkafka::producer::BaseRecord; +use rdkafka::Message; + +#[path = "utils/mod.rs"] +mod utils; + +#[tokio::test] +pub async fn test_basic_produce() { + init_test_logger(); + + let kafka_context_result = KafkaContext::shared().await; + let Ok(kafka_context) = kafka_context_result else { + panic!( + "could not create kafka context: {}", + kafka_context_result.unwrap_err() + ); + }; + let test_topic_name = rand_test_topic("testing-topic"); + + let admin_client_result = create_admin_client(&kafka_context.bootstrap_servers).await; + let Ok(admin_client) = admin_client_result else { + panic!( + "could not create admin client: {}", + admin_client_result.unwrap_err() + ); + }; + if let Err(err) = create_topic(&admin_client, &test_topic_name).await { + panic!("could not create topic: {}", err); + } + + let consumer_result = + create_subscribed_base_consumer(&kafka_context.bootstrap_servers, None, &test_topic_name) + .await; + let Ok(consumer) = consumer_result else { + panic!( + "could not create consumer: {}", + consumer_result.unwrap_err() + ); + }; + + let create_producer_result = create_producer(&kafka_context.bootstrap_servers).await; + let Ok(base_producer) = create_producer_result else { + panic!( + "could not create base producer: {}", + create_producer_result.unwrap_err() + ); + }; + + let record = BaseRecord::to(&test_topic_name) // destination topic + .key(&[1, 2, 3, 4]) // message key + .payload("content"); // message payload + if let Err(err) = + crate::utils::producer::base_producer::send_record(&base_producer, record).await + { + panic!("could not send record: {}", err); + } + + let messages_result = poll_x_times_for_messages(&consumer, 10).await; + let Ok(messages) = messages_result else { + panic!("could not get messages from consumer"); + }; + if messages.len() != 1 { + panic!("expected exactly one message"); + } + let borrowed_next_message = messages.first().unwrap(); + + let owned_next_message = borrowed_next_message.detach(); + let Some(message_payload) = owned_next_message.payload() else { + panic!("message payload is empty"); + }; + let message_string_result = String::from_utf8(message_payload.to_vec()); + let Ok(message_string) = message_string_result else { + panic!("message payload is not valid UTF-8"); + }; + + assert!(message_string.contains("content")); +} diff --git a/tests/stream_consumers.rs b/tests/stream_consumers.rs new file mode 100644 index 000000000..bc6047200 --- /dev/null +++ b/tests/stream_consumers.rs @@ -0,0 +1,1349 @@ +//! Test data consumption using high level consumers. + +use std::error::Error; +use std::sync::Arc; + +use anyhow::Context; +use futures::future; +use futures::stream::StreamExt; +use maplit::hashmap; +use rdkafka_sys::RDKafkaErrorCode; +use tokio::time::{self, Duration}; + +use rdkafka::admin::AdminOptions; +use rdkafka::consumer::{CommitMode, Consumer, RebalanceProtocol, StreamConsumer}; +use rdkafka::error::KafkaError; +use rdkafka::message::{Header, Headers, OwnedHeaders}; +use rdkafka::producer::FutureRecord; +use rdkafka::topic_partition_list::{Offset, TopicPartitionList}; +use rdkafka::util::current_time_millis; +use rdkafka::{Message, Timestamp}; +use rdkafka_sys::types::RDKafkaConfRes; + +use crate::utils::admin::new_topic_vec; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::rand::*; +use crate::utils::*; + +mod utils; + +#[tokio::test] +async fn test_invalid_max_poll_interval() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let res: Result = consumer_config( + &kafka_context.bootstrap_servers, + &crate::utils::rand::rand_test_group(), + Some(hashmap! { "max.poll.interval.ms" => "-1" }), + ) + .create(); + match res { + Err(KafkaError::ClientConfig(RDKafkaConfRes::RD_KAFKA_CONF_INVALID, desc, key, value)) => { + assert_eq!( + desc, + "Configuration property \"max.poll.interval.ms\" value -1 is outside allowed range 1..86400000\n" + ); + assert_eq!(key, "max.poll.interval.ms"); + assert_eq!(value, "-1"); + } + Ok(_) => panic!("invalid max poll interval configuration accepted"), + Err(e) => panic!( + "incorrect error returned for invalid max poll interval: {:?}", + e + ), + } +} + +// All produced messages should be consumed. +#[tokio::test(flavor = "multi_thread")] +async fn test_produce_consume_base() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let num_of_messages_to_send = 100usize; + let start_time = current_time_millis(); + let topic_name = rand_test_topic("test_produce_consume_base"); + let message_map = topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + None, + ) + .await + .expect("Could not populate topic using Future producer"); + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + consumer + .subscribe(&[topic_name.as_str()]) + .expect("could not subscribe to kafka topic"); + + consumer + .stream() + .take(num_of_messages_to_send) + .for_each(|message| { + match message { + Ok(m) => { + let id = message_map[&(m.partition(), m.offset())]; + match m.timestamp() { + Timestamp::CreateTime(timestamp) => assert!(timestamp >= start_time), + _ => panic!("Expected create time for message timestamp"), + }; + assert_eq!(m.payload_view::().unwrap().unwrap(), id.to_string()); + assert_eq!(m.key_view::().unwrap().unwrap(), id.to_string()); + assert_eq!(m.topic(), topic_name.as_str()); + } + Err(e) => panic!("Error receiving message: {:?}", e), + }; + future::ready(()) + }) + .await; +} + +/// Test that multiple message streams from the same consumer all receive +/// messages. In a previous version of rust-rdkafka, the `StreamConsumerContext` +/// could only manage one waker, so each `MessageStream` would compete for the +/// waker slot. +#[tokio::test(flavor = "multi_thread")] +async fn test_produce_consume_base_concurrent() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let num_of_messages_to_send = 100usize; + let topic_name = rand_test_topic("test_produce_consume_base_concurrent"); + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + None, + ) + .await + .expect("Could not populate topic using Future producer"); + let consumer = Arc::new( + consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"), + ); + consumer + .subscribe(&[topic_name.as_str()]) + .expect("could not subscribe to kafka topic"); + + let mk_task = || { + let consumer = consumer.clone(); + tokio::spawn(async move { + consumer + .stream() + .take(20) + .for_each(|message| match message { + Ok(_) => future::ready(()), + Err(e) => panic!("Error receiving message: {:?}", e), + }) + .await; + }) + }; + + for res in future::join_all((0..5).map(|_| mk_task())).await { + res.unwrap(); + } +} + +// All produced messages should be consumed. +#[tokio::test(flavor = "multi_thread")] +async fn test_produce_consume_base_assign() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let topic_name = rand_test_topic("test_produce_consume_base_assign"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topics"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let num_of_messages_to_send = 10usize; + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(0), + ) + .await + .expect("Could not populate topic using Future producer"); + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(1), + ) + .await + .expect("Could not populate topic using Future producer"); + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(2), + ) + .await + .expect("Could not populate topic using Future producer"); + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) + .unwrap(); + tpl.add_partition_offset(&topic_name, 1, Offset::Offset(2)) + .unwrap(); + tpl.add_partition_offset(&topic_name, 2, Offset::Offset(9)) + .unwrap(); + consumer.assign(&tpl).unwrap(); + + let mut partition_count = vec![0, 0, 0]; + + let _consumer_future = consumer + .stream() + .take(19) + .for_each(|message| { + match message { + Ok(m) => partition_count[m.partition() as usize] += 1, + Err(e) => panic!("Error receiving message: {:?}", e), + }; + future::ready(()) + }) + .await; + + assert_eq!(partition_count, vec![10, 8, 1]); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_produce_consume_base_unassign() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let topic_name = rand_test_topic("test_produce_consume_base_assign"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topics"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + + let num_of_messages_to_send = 10usize; + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(0), + ) + .await + .expect("Could not populate topic using Future producer"); + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(1), + ) + .await + .expect("Could not populate topic using Future producer"); + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(2), + ) + .await + .expect("Could not populate topic using Future producer"); + + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) + .unwrap(); + tpl.add_partition_offset(&topic_name, 1, Offset::Offset(2)) + .unwrap(); + tpl.add_partition_offset(&topic_name, 2, Offset::Offset(9)) + .unwrap(); + consumer.assign(&tpl).unwrap(); + let mut assignments = consumer.assignment().unwrap(); + assert_eq!(assignments.count(), 3); + + consumer.unassign().unwrap(); + assignments = consumer.assignment().unwrap(); + assert_eq!(assignments.count(), 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_produce_consume_base_incremental_assign_and_unassign() { + init_test_logger(); + + // Get Kafka container context. + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + + let topic_name = rand_test_topic("test_produce_consume_base_incremental_assign_and_unassign"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topics"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + + let num_of_messages_to_send = 10usize; + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(0), + ) + .await + .expect("Could not populate topic using Future producer"); + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(1), + ) + .await + .expect("Could not populate topic using Future producer"); + topics::populate_topic_using_future_producer( + &producer, + &topic_name, + num_of_messages_to_send, + Some(2), + ) + .await + .expect("Could not populate topic using Future producer"); + + // Adding a simple partition + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) + .unwrap(); + consumer.incremental_assign(&tpl).unwrap(); + let mut assignments = consumer.assignment().unwrap(); + assert_eq!(assignments.count(), 1); + + // Adding another partition + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic_name, 1, Offset::Beginning) + .unwrap(); + consumer.incremental_assign(&tpl).unwrap(); + assignments = consumer.assignment().unwrap(); + assert_eq!(assignments.count(), 2); + + // Removing one partition + consumer.incremental_unassign(&tpl).unwrap(); + assignments = consumer.assignment().unwrap(); + assert_eq!(assignments.count(), 1); + + // unassigning an non assigned partition should fail + let err = consumer.incremental_unassign(&tpl); + + assert_eq!( + err, + Err(KafkaError::Subscription("_INVALID_ARG".to_string())) + ) +} + +// All produced messages should be consumed. +#[tokio::test(flavor = "multi_thread")] +async fn test_produce_consume_with_timestamp() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_produce_consume_with_timestamp"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let message_map = produce_messages_with_timestamp(&producer, &topic_name, 100, 0, 1111).await; + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + consumer.subscribe(&[topic_name.as_str()]).unwrap(); + + let _consumer_future = consumer + .stream() + .take(100) + .for_each(|message| { + match message { + Ok(m) => { + let id = message_map[&(m.partition(), m.offset())]; + assert_eq!(m.timestamp(), Timestamp::CreateTime(1111)); + assert_eq!(m.payload_view::().unwrap().unwrap(), value_fn(id)); + assert_eq!(m.key_view::().unwrap().unwrap(), key_fn(id)); + } + Err(e) => panic!("Error receiving message: {:?}", e), + }; + future::ready(()) + }) + .await; + + let _ = produce_messages_with_timestamp(&producer, &topic_name, 10, 0, 999_999).await; + + // Lookup the offsets + let tpl = consumer + .offsets_for_timestamp(999_999, Duration::from_secs(10)) + .unwrap(); + let tp = tpl.find_partition(&topic_name, 0).unwrap(); + assert_eq!(tp.topic(), topic_name); + assert_eq!(tp.offset(), Offset::Offset(100)); + assert_eq!(tp.partition(), 0); + assert_eq!(tp.error(), Ok(())); +} + +// TODO: add check that commit cb gets called correctly +#[tokio::test(flavor = "multi_thread")] +async fn test_consumer_commit_message() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_consumer_commit_message"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topics"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let _ = produce_messages_to_partition(&producer, &topic_name, 10, 0).await; + let _ = produce_messages_to_partition(&producer, &topic_name, 11, 1).await; + let _ = produce_messages_to_partition(&producer, &topic_name, 12, 2).await; + + let group_name = rand_test_group(); + let consumer = utils::consumer::stream_consumer::create_stream_consumer_with_options( + &kafka_context.bootstrap_servers, + &group_name, + &[], + ) + .await + .expect("could not create stream consumer"); + consumer.subscribe(&[topic_name.as_str()]).unwrap(); + + let _consumer_future = consumer + .stream() + .take(33) + .for_each(|message| { + match message { + Ok(m) => { + if m.partition() == 1 { + consumer.commit_message(&m, CommitMode::Async).unwrap(); + } + } + Err(e) => panic!("error receiving message: {:?}", e), + }; + future::ready(()) + }) + .await; + + let timeout = Duration::from_secs(5); + assert_eq!( + consumer.fetch_watermarks(&topic_name, 0, timeout).unwrap(), + (0, 10) + ); + assert_eq!( + consumer.fetch_watermarks(&topic_name, 1, timeout).unwrap(), + (0, 11) + ); + assert_eq!( + consumer.fetch_watermarks(&topic_name, 2, timeout).unwrap(), + (0, 12) + ); + + let mut assignment = TopicPartitionList::new(); + assignment + .add_partition_offset(&topic_name, 0, Offset::Stored) + .unwrap(); + assignment + .add_partition_offset(&topic_name, 1, Offset::Stored) + .unwrap(); + assignment + .add_partition_offset(&topic_name, 2, Offset::Stored) + .unwrap(); + assert_eq!(assignment, consumer.assignment().unwrap()); + + let mut committed = TopicPartitionList::new(); + committed + .add_partition_offset(&topic_name, 0, Offset::Invalid) + .unwrap(); + committed + .add_partition_offset(&topic_name, 1, Offset::Offset(11)) + .unwrap(); + committed + .add_partition_offset(&topic_name, 2, Offset::Invalid) + .unwrap(); + assert_eq!(committed, consumer.committed(timeout).unwrap()); + + let mut position = TopicPartitionList::new(); + position + .add_partition_offset(&topic_name, 0, Offset::Offset(10)) + .unwrap(); + position + .add_partition_offset(&topic_name, 1, Offset::Offset(11)) + .unwrap(); + position + .add_partition_offset(&topic_name, 2, Offset::Offset(12)) + .unwrap(); + assert_eq!(position, consumer.position().unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_consumer_store_offset_commit() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_consumer_store_offset_commit"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topics"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + + let _ = produce_messages_to_partition(&producer, &topic_name, 10, 0).await; + let _ = produce_messages_to_partition(&producer, &topic_name, 11, 1).await; + let _ = produce_messages_to_partition(&producer, &topic_name, 12, 2).await; + + let group_name = rand_test_group(); + let consumer = utils::consumer::stream_consumer::create_stream_consumer_with_options( + &kafka_context.bootstrap_servers, + &group_name, + &[ + ("enable.auto.offset.store", "false"), + ("enable.partition.eof", "true"), + ], + ) + .await + .expect("could not create stream consumer"); + consumer.subscribe(&[topic_name.as_str()]).unwrap(); + + let _consumer_future = consumer + .stream() + .take(36) + .for_each(|message| { + match message { + Ok(m) => { + if m.partition() == 1 { + consumer.store_offset_from_message(&m).unwrap(); + } + } + Err(KafkaError::PartitionEOF(_)) => {} + Err(e) => panic!("Error receiving message: {:?}", e), + }; + future::ready(()) + }) + .await; + + // Commit the whole current state + consumer.commit_consumer_state(CommitMode::Sync).unwrap(); + + let timeout = Duration::from_secs(5); + assert_eq!( + consumer.fetch_watermarks(&topic_name, 0, timeout).unwrap(), + (0, 10) + ); + assert_eq!( + consumer.fetch_watermarks(&topic_name, 1, timeout).unwrap(), + (0, 11) + ); + assert_eq!( + consumer.fetch_watermarks(&topic_name, 2, timeout).unwrap(), + (0, 12) + ); + + let mut assignment = TopicPartitionList::new(); + assignment + .add_partition_offset(&topic_name, 0, Offset::Stored) + .unwrap(); + assignment + .add_partition_offset(&topic_name, 1, Offset::Stored) + .unwrap(); + assignment + .add_partition_offset(&topic_name, 2, Offset::Stored) + .unwrap(); + assert_eq!(assignment, consumer.assignment().unwrap()); + + let mut committed = TopicPartitionList::new(); + committed + .add_partition_offset(&topic_name, 0, Offset::Invalid) + .unwrap(); + committed + .add_partition_offset(&topic_name, 1, Offset::Offset(11)) + .unwrap(); + committed + .add_partition_offset(&topic_name, 2, Offset::Invalid) + .unwrap(); + assert_eq!(committed, consumer.committed(timeout).unwrap()); + + let mut position = TopicPartitionList::new(); + position + .add_partition_offset(&topic_name, 0, Offset::Offset(10)) + .unwrap(); + position + .add_partition_offset(&topic_name, 1, Offset::Offset(11)) + .unwrap(); + position + .add_partition_offset(&topic_name, 2, Offset::Offset(12)) + .unwrap(); + assert_eq!(position, consumer.position().unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_consumer_commit_metadata() -> Result<(), Box> { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_consumer_commit_metadata"); + let group_name = rand_test_group(); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topics"); + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + let _ = produce_messages_to_partition(&producer, &topic_name, 4, 0).await; + let _ = produce_messages_to_partition(&producer, &topic_name, 4, 1).await; + let _ = produce_messages_to_partition(&producer, &topic_name, 4, 2).await; + + let create_consumer = || async { + let consumer = utils::consumer::stream_consumer::create_stream_consumer_with_options( + &kafka_context.bootstrap_servers, + &group_name, + &[], + ) + .await + .context("failed to create stream consumer")?; + + consumer + .subscribe(&[topic_name.as_str()]) + .context("failed to subscribe to topic")?; + let _ = consumer.stream().next().await; + + Ok::<_, Box>(consumer) + }; + + // Create a topic partition list where each element has some associated + // metadata. + let tpl = { + let mut tpl = TopicPartitionList::new(); + let mut tpl1 = tpl.add_partition(&topic_name, 0); + tpl1.set_offset(Offset::Offset(1))?; + tpl1.set_metadata("one"); + let mut tpl2 = tpl.add_partition(&topic_name, 1); + tpl2.set_offset(Offset::Offset(1))?; + tpl2.set_metadata("two"); + let mut tpl3 = tpl.add_partition(&topic_name, 2); + tpl3.set_offset(Offset::Offset(1))?; + tpl3.set_metadata("three"); + tpl + }; + + // Ensure that the commit state immediately includes the metadata. + { + let consumer = create_consumer().await?; + consumer.commit(&tpl, CommitMode::Sync)?; + assert_eq!(consumer.committed(None)?, tpl); + } + + // Ensure that the commit state on a new consumer in the same group + // can see the same metadata. + { + let consumer = create_consumer().await?; + assert_eq!(consumer.committed(None)?, tpl); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn test_consume_partition_order() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_consume_partition_order"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &new_topic_vec(&topic_name, Some(3)), + &AdminOptions::default(), + ) + .await + .expect("could not create topics"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + let _ = produce_messages_to_partition(&producer, &topic_name, 4, 0).await; + let _ = produce_messages_to_partition(&producer, &topic_name, 4, 1).await; + let _ = produce_messages_to_partition(&producer, &topic_name, 4, 2).await; + + // Using partition queues should allow us to consume the partitions + // in a round-robin fashion. + { + let consumer = Arc::new( + utils::consumer::stream_consumer::create_stream_consumer_with_options( + &kafka_context.bootstrap_servers, + &rand_test_group(), + &[], + ) + .await + .expect("could not create stream consumer"), + ); + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) + .unwrap(); + tpl.add_partition_offset(&topic_name, 1, Offset::Beginning) + .unwrap(); + tpl.add_partition_offset(&topic_name, 2, Offset::Beginning) + .unwrap(); + consumer.assign(&tpl).unwrap(); + + let mut partition_streams: Vec<_> = (0..3) + .map(|i| consumer.split_partition_queue(&topic_name, i).unwrap()) + .collect(); + + for _ in 0..4 { + let main_message = + time::timeout(Duration::from_millis(100), consumer.stream().next()).await; + assert!(main_message.is_err()); + + for (i, stream) in partition_streams.iter_mut().enumerate() { + let queue_message = stream.recv().await.unwrap(); + assert_eq!(queue_message.partition(), i as i32); + } + } + } + + // When not all partitions have been split into separate queues, the + // unsplit partitions should still be accessible via the main queue. + { + let consumer = Arc::new( + utils::consumer::stream_consumer::create_stream_consumer_with_options( + &kafka_context.bootstrap_servers, + &rand_test_group(), + &[], + ) + .await + .expect("could not create stream consumer"), + ); + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) + .unwrap(); + tpl.add_partition_offset(&topic_name, 1, Offset::Beginning) + .unwrap(); + tpl.add_partition_offset(&topic_name, 2, Offset::Beginning) + .unwrap(); + consumer.assign(&tpl).unwrap(); + + let partition1 = consumer.split_partition_queue(&topic_name, 1).unwrap(); + + let mut i = 0; + while i < 5 { + if let Ok(m) = time::timeout(Duration::from_millis(1000), consumer.recv()).await { + // retry on transient errors until we get a message + let m = match m { + Err(KafkaError::MessageConsumption( + RDKafkaErrorCode::BrokerTransportFailure, + )) + | Err(KafkaError::MessageConsumption(RDKafkaErrorCode::AllBrokersDown)) + | Err(KafkaError::MessageConsumption(RDKafkaErrorCode::OperationTimedOut)) => { + continue; + } + Err(err) => { + panic!("Unexpected error receiving message: {:?}", err); + } + Ok(m) => m, + }; + let partition: i32 = m.partition(); + assert!(partition == 0 || partition == 2); + i += 1; + } else { + panic!("Timeout receiving message"); + } + + if let Ok(m) = time::timeout(Duration::from_millis(1000), partition1.recv()).await { + // retry on transient errors until we get a message + let m = match m { + Err(KafkaError::MessageConsumption( + RDKafkaErrorCode::BrokerTransportFailure, + )) + | Err(KafkaError::MessageConsumption(RDKafkaErrorCode::AllBrokersDown)) + | Err(KafkaError::MessageConsumption(RDKafkaErrorCode::OperationTimedOut)) => { + continue; + } + Err(err) => { + panic!("Unexpected error receiving message: {:?}", err); + } + Ok(m) => m, + }; + assert_eq!(m.partition(), 1); + i += 1; + } else { + panic!("Timeout receiving message"); + } + } + } + + // Sending the queue to another task that is likely to outlive the + // original thread should work. This is not idiomatic, as the consumer + // should be continuously polled to serve callbacks, but it should not panic + // or result in memory unsafety, etc. + { + let consumer = Arc::new( + utils::consumer::stream_consumer::create_stream_consumer_with_options( + &kafka_context.bootstrap_servers, + &rand_test_group(), + &[], + ) + .await + .expect("could not create stream consumer"), + ); + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) + .unwrap(); + consumer.assign(&tpl).unwrap(); + let stream = consumer.split_partition_queue(&topic_name, 0).unwrap(); + + let worker = tokio::spawn({ + async move { + for _ in 0..4 { + let stream_message = stream.recv().await.unwrap(); + assert_eq!(stream_message.partition(), 0); + } + } + }); + + let main_message = + time::timeout(Duration::from_millis(100), consumer.stream().next()).await; + assert!(main_message.is_err()); + + drop(consumer); + worker.await.unwrap(); + } +} + +// `test_produce_consume_base_incremental_assign_and_unassign` exercises the +// `incremental_assign`/`incremental_unassign` API on a manually-assigned +// consumer (no group join, so `rebalance_protocol` stays `None`). This test +// joins a group with `partition.assignment.strategy=cooperative-sticky`, drives +// the consumer until the initial assignment lands, and asserts that +// `rebalance_protocol()` reports `Cooperative`. A binding regression in the +// `rebalance_protocol` accessor (or that ignored the cooperative-sticky +// configuration) would fail this check. +#[tokio::test(flavor = "multi_thread")] +async fn test_consumer_cooperative_sticky_rebalance_protocol() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_consumer_cooperative_sticky"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(2)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("could not create future producer"); + for partition in 0..2 { + producer + .send( + FutureRecord::to(&topic_name) + .partition(partition) + .key("k") + .payload("p"), + Duration::from_secs(10), + ) + .await + .unwrap_or_else(|(e, _)| panic!("delivery failed: {}", e)); + } + + let consumer = utils::consumer::stream_consumer::create_stream_consumer_with_options( + &kafka_context.bootstrap_servers, + &rand_test_group(), + &[("partition.assignment.strategy", "cooperative-sticky")], + ) + .await + .expect("could not create stream consumer"); + consumer.subscribe(&[topic_name.as_str()]).unwrap(); + + assert!( + matches!(consumer.rebalance_protocol(), RebalanceProtocol::None), + "rebalance_protocol should be None before the first group join", + ); + + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while std::time::Instant::now() < deadline { + let _ = time::timeout(Duration::from_secs(2), consumer.stream().next()).await; + if consumer.assignment().unwrap().count() == 2 { + break; + } + } + let assignment = consumer.assignment().unwrap(); + assert_eq!( + assignment.count(), + 2, + "consumer should have been assigned both partitions, got {:?}", + assignment + ); + assert!( + matches!( + consumer.rebalance_protocol(), + RebalanceProtocol::Cooperative + ), + "rebalance_protocol should report Cooperative after a cooperative-sticky join", + ); +} + +// librdkafka treats subscription strings beginning with `^` as a regex +// pattern and resolves them against the broker's topic metadata on every +// metadata refresh. This test creates two topics that match a unique +// `^.*` pattern and one topic that does not, subscribes the consumer +// to the regex, drives the consumer until its assignment stabilizes, and +// asserts only the matching topics are present. A binding regression that +// passed the subscription string through unmodified (so `^` was lost) or +// that crossed up topic ownership would fail the topic-set comparison. +#[tokio::test(flavor = "multi_thread")] +async fn test_consumer_regex_subscription_matches_only_prefixed() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let prefix = rand_test_topic("regex_match"); + let match1 = format!("{}_a", prefix); + let match2 = format!("{}_b", prefix); + let nonmatch = format!("other_{}", prefix); + + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + for topic in [&match1, &match2, &nonmatch] { + admin_client + .create_topics( + &admin::new_topic_vec(topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + } + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("could not create future producer"); + for topic in [&match1, &match2, &nonmatch] { + producer + .send( + FutureRecord::to(topic.as_str()) + .partition(0) + .key("k") + .payload("p"), + Duration::from_secs(10), + ) + .await + .unwrap_or_else(|(e, _)| panic!("delivery failed: {}", e)); + } + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + let pattern = format!("^{}.*", prefix); + consumer.subscribe(&[pattern.as_str()]).unwrap(); + + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let expected: std::collections::HashSet = + [match1.clone(), match2.clone()].into_iter().collect(); + let mut observed: std::collections::HashSet = std::collections::HashSet::new(); + while std::time::Instant::now() < deadline { + match time::timeout(Duration::from_secs(2), consumer.stream().next()).await { + Ok(Some(Ok(m))) => { + observed.insert(m.topic().to_string()); + } + Ok(Some(Err(e))) => panic!("stream error: {:?}", e), + Ok(None) => panic!("stream ended"), + Err(_) => {} + } + let assignment = consumer.assignment().unwrap(); + let assigned: std::collections::HashSet = assignment + .elements() + .iter() + .map(|e| e.topic().to_string()) + .collect(); + if assigned == expected { + break; + } + } + let assignment = consumer.assignment().unwrap(); + let assigned: std::collections::HashSet = assignment + .elements() + .iter() + .map(|e| e.topic().to_string()) + .collect(); + assert_eq!( + assigned, expected, + "regex subscription should converge to the two matching topics", + ); + assert!( + !observed.contains(&nonmatch), + "non-matching topic {} should not be delivered", + nonmatch + ); +} + +// `test_produce_consume_with_timestamp` already calls `offsets_for_timestamp`, +// but only with two distinct timestamp values. This test produces a strictly +// monotonic sequence of distinct timestamps and queries at a midpoint, then +// verifies the returned offset is the first message at-or-after the query +// timestamp. A binding regression in `offsets_for_times` (or its `tpl` +// serialization) that returned the nearest neighbour, or the first ever +// offset, would fail this assertion. +#[tokio::test(flavor = "multi_thread")] +async fn test_consumer_offsets_for_times_first_at_or_after() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_consumer_offsets_for_times_first_at_or_after"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("could not create future producer"); + + const N: i64 = 20; + const BASE_TS: i64 = 1_700_000_000_000; + const STEP: i64 = 100; + for i in 0..N { + let ts = BASE_TS + i * STEP; + let payload = format!("ts-{}", ts); + producer + .send( + FutureRecord::to(&topic_name) + .partition(0) + .key("k") + .payload(&payload) + .timestamp(ts), + Duration::from_secs(10), + ) + .await + .unwrap_or_else(|(e, _)| panic!("delivery failed: {}", e)); + } + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + let mut tpl = TopicPartitionList::new(); + tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) + .unwrap(); + consumer.assign(&tpl).unwrap(); + + let exact_query_ts = BASE_TS + 7 * STEP; + let exact_tpl = consumer + .offsets_for_timestamp(exact_query_ts, Duration::from_secs(10)) + .expect("offsets_for_timestamp failed for exact timestamp"); + let exact_offset = exact_tpl + .find_partition(&topic_name, 0) + .expect("missing partition entry") + .offset(); + assert_eq!( + exact_offset, + Offset::Offset(7), + "exact-timestamp query should return the matching offset", + ); + + let midpoint_query_ts = BASE_TS + 7 * STEP + STEP / 2; + let midpoint_tpl = consumer + .offsets_for_timestamp(midpoint_query_ts, Duration::from_secs(10)) + .expect("offsets_for_timestamp failed for midpoint timestamp"); + let midpoint_offset = midpoint_tpl + .find_partition(&topic_name, 0) + .expect("missing partition entry") + .offset(); + assert_eq!( + midpoint_offset, + Offset::Offset(8), + "midpoint-timestamp query should return the first offset at-or-after the query", + ); + + let after_query_ts = BASE_TS + (N + 10) * STEP; + let after_tpl = consumer + .offsets_for_timestamp(after_query_ts, Duration::from_secs(10)) + .expect("offsets_for_timestamp failed for after-end timestamp"); + let after_offset = after_tpl + .find_partition(&topic_name, 0) + .expect("missing partition entry") + .offset(); + assert_eq!( + after_offset, + Offset::End, + "timestamp past the high watermark should return Offset::End", + ); +} + +// `test_base_producer_headers` already covers the produce side via the +// delivery callback. This test covers the consume side: produce a message +// with a mixed set of headers (str-valued, byte-valued, empty, and explicitly +// null), consume it through a StreamConsumer, and verify +// `BorrowedMessage::headers` exposes each header in order with the correct +// key, value type, and value bytes. A binding regression in the headers +// FFI conversion path would surface here. +#[tokio::test(flavor = "multi_thread")] +async fn test_consumer_reads_message_headers() { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic_name = rand_test_topic("test_consumer_reads_message_headers"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic_name, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let producer = producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("could not create future producer"); + + let headers = OwnedHeaders::new() + .insert(Header { + key: "h-bytes", + value: Some(&[0u8, 1, 2, 3][..]), + }) + .insert(Header { + key: "h-str", + value: Some("v-str"), + }) + .insert(Header { + key: "h-empty", + value: Some(&[][..]), + }) + .insert::>(Header { + key: "h-null", + value: None, + }); + + producer + .send( + FutureRecord::to(&topic_name) + .partition(0) + .key("k") + .payload("p") + .headers(headers), + Duration::from_secs(10), + ) + .await + .unwrap_or_else(|(e, _)| panic!("delivery failed: {}", e)); + + let consumer = utils::consumer::stream_consumer::create_stream_consumer( + &kafka_context.bootstrap_servers, + Some(&rand_test_group()), + ) + .await + .expect("could not create stream consumer"); + consumer.subscribe(&[topic_name.as_str()]).unwrap(); + + let message = time::timeout(Duration::from_secs(15), consumer.stream().next()) + .await + .expect("timed out waiting for consumed message") + .expect("stream ended unexpectedly") + .expect("error receiving message"); + + let received = message + .headers() + .expect("consumed message should expose headers"); + assert_eq!(received.count(), 4); + assert_eq!( + received.get(0), + Header { + key: "h-bytes", + value: Some(&[0u8, 1, 2, 3][..]), + } + ); + assert_eq!( + received.get_as::(1), + Ok(Header { + key: "h-str", + value: Some("v-str"), + }) + ); + assert_eq!( + received.get_as::<[u8]>(2), + Ok(Header { + key: "h-empty", + value: Some(&[][..]), + }) + ); + assert_eq!( + received.get_as::<[u8]>(3), + Ok(Header { + key: "h-null", + value: None, + }) + ); + let collected: Vec<_> = received.iter().collect(); + assert_eq!( + collected, + vec![ + Header { + key: "h-bytes", + value: Some(&[0u8, 1, 2, 3][..]), + }, + Header { + key: "h-str", + value: Some(b"v-str" as &[u8]), + }, + Header { + key: "h-empty", + value: Some(&[][..]), + }, + Header { + key: "h-null", + value: None, + }, + ], + ); +} diff --git a/tests/test_admin.rs b/tests/test_admin.rs deleted file mode 100644 index 87f258a9e..000000000 --- a/tests/test_admin.rs +++ /dev/null @@ -1,630 +0,0 @@ -//! Test administrative commands using the admin API. - -use std::time::Duration; - -use backon::{BlockingRetryable, ExponentialBuilder}; - -use rdkafka::admin::{ - AdminClient, AdminOptions, AlterConfig, ConfigEntry, ConfigSource, GroupResult, NewPartitions, - NewTopic, OwnedResourceSpecifier, ResourceSpecifier, TopicReplication, -}; -use rdkafka::client::DefaultClientContext; -use rdkafka::consumer::{BaseConsumer, CommitMode, Consumer, DefaultConsumerContext}; -use rdkafka::error::{KafkaError, RDKafkaErrorCode}; -use rdkafka::metadata::Metadata; -use rdkafka::producer::{FutureProducer, FutureRecord, Producer}; -use rdkafka::{ClientConfig, Offset, TopicPartitionList}; - -use crate::utils::*; - -mod utils; - -fn create_config() -> ClientConfig { - let mut config = ClientConfig::new(); - config.set("bootstrap.servers", get_bootstrap_server().as_str()); - config -} - -fn create_admin_client() -> AdminClient { - configure_logging_for_tests(); - create_config() - .create() - .expect("admin client creation failed") -} - -async fn create_consumer_group(consumer_group_name: &str) { - let admin_client = create_admin_client(); - let topic_name = &rand_test_topic(consumer_group_name); - let consumer: BaseConsumer = create_config() - .set("group.id", consumer_group_name) - .create() - .expect("create consumer failed"); - - admin_client - .create_topics( - &[NewTopic { - name: topic_name, - num_partitions: 1, - replication: TopicReplication::Fixed(1), - config: vec![], - }], - &AdminOptions::default(), - ) - .await - .expect("topic creation failed"); - let topic_partition_list = { - let mut lst = TopicPartitionList::new(); - lst.add_partition(topic_name, 0); - lst - }; - consumer - .assign(&topic_partition_list) - .expect("assign topic partition list failed"); - consumer - .fetch_metadata(None, Duration::from_secs(3)) - .expect("unable to fetch metadata"); - (|| consumer.store_offset(topic_name, 0, -1)) - .retry(ExponentialBuilder::default().with_max_delay(Duration::from_secs(5))) - .call() - .expect("store offset failed"); - consumer - .commit_consumer_state(CommitMode::Sync) - .expect("commit the consumer state failed"); -} - -fn fetch_metadata(topic: &str) -> Metadata { - let consumer: BaseConsumer = - create_config().create().expect("consumer creation failed"); - let timeout = Some(Duration::from_secs(1)); - - (|| { - let metadata = consumer - .fetch_metadata(Some(topic), timeout) - .map_err(|e| e.to_string())?; - if metadata.topics().is_empty() { - Err("metadata fetch returned no topics".to_string())? - } - let topic = &metadata.topics()[0]; - if topic.partitions().is_empty() { - Err("metadata fetch returned a topic with no partitions".to_string())? - } - Ok::<_, String>(metadata) - }) - .retry(ExponentialBuilder::default().with_max_delay(Duration::from_secs(5))) - .call() - .unwrap() -} - -fn verify_delete(topic: &str) { - let consumer: BaseConsumer = - create_config().create().expect("consumer creation failed"); - let timeout = Some(Duration::from_secs(1)); - - (|| { - // Asking about the topic specifically will recreate it (under the - // default Kafka configuration, at least) so we have to ask for the list - // of all topics and search through it. - let metadata = consumer - .fetch_metadata(None, timeout) - .map_err(|e| e.to_string())?; - if metadata.topics().iter().any(|t| t.name() == topic) { - Err(format!("topic {} still exists", topic))? - } - Ok::<(), String>(()) - }) - .retry(ExponentialBuilder::default().with_max_delay(Duration::from_secs(5))) - .call() - .unwrap() -} - -#[tokio::test] -async fn test_topics() { - let admin_client = create_admin_client(); - let opts = AdminOptions::new().operation_timeout(Some(Duration::from_secs(30))); - - // Verify that topics are created as specified, and that they can later - // be deleted. - { - let name1 = rand_test_topic("test_topics"); - let name2 = rand_test_topic("test_topics"); - - // Test both the builder API and the literal construction. - let topic1 = - NewTopic::new(&name1, 1, TopicReplication::Fixed(1)).set("max.message.bytes", "1234"); - let topic2 = NewTopic { - name: &name2, - num_partitions: 3, - replication: TopicReplication::Variable(&[&[0], &[0], &[0]]), - config: Vec::new(), - }; - - let res = admin_client - .create_topics(&[topic1, topic2], &opts) - .await - .expect("topic creation failed"); - assert_eq!(res, &[Ok(name1.clone()), Ok(name2.clone())]); - - let metadata1 = fetch_metadata(&name1); - let metadata2 = fetch_metadata(&name2); - assert_eq!(1, metadata1.topics().len()); - assert_eq!(1, metadata2.topics().len()); - let metadata_topic1 = &metadata1.topics()[0]; - let metadata_topic2 = &metadata2.topics()[0]; - assert_eq!(&name1, metadata_topic1.name()); - assert_eq!(&name2, metadata_topic2.name()); - assert_eq!(1, metadata_topic1.partitions().len()); - assert_eq!(3, metadata_topic2.partitions().len()); - - let res = admin_client - .describe_configs( - &[ - ResourceSpecifier::Topic(&name1), - ResourceSpecifier::Topic(&name2), - ], - &opts, - ) - .await - .expect("describe configs failed"); - let config1 = &res[0].as_ref().expect("describe configs failed on topic 1"); - let config2 = &res[1].as_ref().expect("describe configs failed on topic 2"); - let mut expected_entry1 = ConfigEntry { - name: "max.message.bytes".into(), - value: Some("1234".into()), - source: ConfigSource::DynamicTopic, - is_read_only: false, - is_default: false, - is_sensitive: false, - }; - let default_max_msg_bytes = if get_broker_version() <= KafkaVersion(2, 3, 0, 0) { - "1000012" - } else { - "1048588" - }; - let expected_entry2 = ConfigEntry { - name: "max.message.bytes".into(), - value: Some(default_max_msg_bytes.into()), - source: ConfigSource::Default, - is_read_only: false, - is_default: true, - is_sensitive: false, - }; - if get_broker_version() < KafkaVersion(1, 1, 0, 0) { - expected_entry1.source = ConfigSource::Unknown; - } - assert_eq!(Some(&expected_entry1), config1.get("max.message.bytes")); - assert_eq!(Some(&expected_entry2), config2.get("max.message.bytes")); - let config_entries1 = config1.entry_map(); - let config_entries2 = config2.entry_map(); - assert_eq!(config1.entries.len(), config_entries1.len()); - assert_eq!(config2.entries.len(), config_entries2.len()); - assert_eq!( - Some(&&expected_entry1), - config_entries1.get("max.message.bytes") - ); - assert_eq!( - Some(&&expected_entry2), - config_entries2.get("max.message.bytes") - ); - - let partitions1 = NewPartitions::new(&name1, 5); - let res = admin_client - .create_partitions(&[partitions1], &opts) - .await - .expect("partition creation failed"); - assert_eq!(res, &[Ok(name1.clone())]); - - let mut tries = 0; - loop { - let metadata = fetch_metadata(&name1); - let topic = &metadata.topics()[0]; - let n = topic.partitions().len(); - if n == 5 { - break; - } else if tries >= 5 { - panic!("topic has {} partitions, but expected {}", n, 5); - } else { - tries += 1; - tokio::time::sleep(Duration::from_secs(1)).await; - } - } - - let res = admin_client - .delete_topics(&[&name1, &name2], &opts) - .await - .expect("topic deletion failed"); - assert_eq!(res, &[Ok(name1.clone()), Ok(name2.clone())]); - verify_delete(&name1); - verify_delete(&name2); - } - - // Verify that incorrect replication configurations are ignored when - // creating topics. - { - let topic = NewTopic::new("ignored", 1, TopicReplication::Variable(&[&[0], &[0]])); - let res = admin_client.create_topics(&[topic], &opts).await; - assert_eq!( - Err(KafkaError::AdminOpCreation( - "replication configuration for topic 'ignored' assigns 2 partition(s), \ - which does not match the specified number of partitions (1)" - .into() - )), - res, - ) - } - - // Verify that incorrect replication configurations are ignored when - // creating partitions. - { - let name = rand_test_topic("test_topics"); - let topic = NewTopic::new(&name, 1, TopicReplication::Fixed(1)); - - let res = admin_client - .create_topics(vec![&topic], &opts) - .await - .expect("topic creation failed"); - assert_eq!(res, &[Ok(name.clone())]); - let _ = fetch_metadata(&name); - - // This partition specification is obviously garbage, and so trips - // a client-side error. - let partitions = NewPartitions::new(&name, 2).assign(&[&[0], &[0], &[0]]); - let res = admin_client.create_partitions(&[partitions], &opts).await; - assert_eq!( - res, - Err(KafkaError::AdminOpCreation(format!( - "partition assignment for topic '{}' assigns 3 partition(s), \ - which is more than the requested total number of partitions (2)", - name - ))) - ); - - // Only the server knows that this partition specification is garbage. - let partitions = NewPartitions::new(&name, 2).assign(&[&[0], &[0]]); - let res = admin_client - .create_partitions(&[partitions], &opts) - .await - .expect("partition creation failed"); - assert_eq!( - res, - &[Err((name, RDKafkaErrorCode::InvalidReplicaAssignment))], - ); - } - - // Verify that deleting a non-existent topic fails. - { - let name = rand_test_topic("test_topics"); - let res = admin_client - .delete_topics(&[&name], &opts) - .await - .expect("delete topics failed"); - assert_eq!( - res, - &[Err((name, RDKafkaErrorCode::UnknownTopicOrPartition))] - ); - } - - // Verify that mixed-success operations properly report the successful and - // failing operators. - { - let name1 = rand_test_topic("test_topics"); - let name2 = rand_test_topic("test_topics"); - - let topic1 = NewTopic::new(&name1, 1, TopicReplication::Fixed(1)); - let topic2 = NewTopic::new(&name2, 1, TopicReplication::Fixed(1)); - - let res = admin_client - .create_topics(vec![&topic1], &opts) - .await - .expect("topic creation failed"); - assert_eq!(res, &[Ok(name1.clone())]); - let _ = fetch_metadata(&name1); - - let res = admin_client - .create_topics(vec![&topic1, &topic2], &opts) - .await - .expect("topic creation failed"); - assert_eq!( - res, - &[ - Err((name1.clone(), RDKafkaErrorCode::TopicAlreadyExists)), - Ok(name2.clone()) - ] - ); - let _ = fetch_metadata(&name2); - - let res = admin_client - .delete_topics(&[&name1], &opts) - .await - .expect("topic deletion failed"); - assert_eq!(res, &[Ok(name1.clone())]); - verify_delete(&name1); - - let res = admin_client - .delete_topics(&[&name2, &name1], &opts) - .await - .expect("topic deletion failed"); - assert_eq!( - res, - &[ - Ok(name2.clone()), - Err((name1.clone(), RDKafkaErrorCode::UnknownTopicOrPartition)) - ] - ); - } -} - -/// Test the admin client's delete records functionality. -#[tokio::test] -async fn test_delete_records() { - let producer = create_config().create::>().unwrap(); - let admin_client = create_admin_client(); - let timeout = Some(Duration::from_secs(1)); - let opts = AdminOptions::new().operation_timeout(timeout); - let topic = rand_test_topic("test_delete_records"); - let make_record = || FutureRecord::::to(&topic).payload("data"); - - // Create a topic with a single partition. - admin_client - .create_topics( - &[NewTopic::new(&topic, 1, TopicReplication::Fixed(1))], - &opts, - ) - .await - .expect("topic creation failed"); - - // Ensure that the topic begins with low and high water marks of 0. - let (lo, hi) = (|| producer.client().fetch_watermarks(&topic, 0, timeout)) - .retry(ExponentialBuilder::default().with_max_delay(Duration::from_secs(5))) - .call() - .unwrap(); - assert_eq!(lo, 0); - assert_eq!(hi, 0); - - // Produce five messages to the topic. - for _ in 0..5 { - producer.send(make_record(), timeout).await.unwrap(); - } - - // Ensure that the high water mark has advanced to 5. - let (lo, hi) = producer - .client() - .fetch_watermarks(&topic, 0, timeout) - .unwrap(); - assert_eq!(lo, 0); - assert_eq!(hi, 5); - - // Delete the record at offset 0. - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic, 0, Offset::Offset(1)) - .unwrap(); - let res_tpl = admin_client.delete_records(&tpl, &opts).await.unwrap(); - assert_eq!(res_tpl.count(), 1); - assert_eq!(res_tpl.elements()[0].topic(), topic); - assert_eq!(res_tpl.elements()[0].partition(), 0); - assert_eq!(res_tpl.elements()[0].offset(), Offset::Offset(1)); - assert_eq!(res_tpl.elements()[0].error(), Ok(())); - - // Ensure that the low water mark has advanced to 1. - let (lo, hi) = producer - .client() - .fetch_watermarks(&topic, 0, timeout) - .unwrap(); - assert_eq!(lo, 1); - assert_eq!(hi, 5); - - // Delete the record at offset 1 and also include an invalid partition in - // the request. The invalid partition should not cause the request to fail, - // but we should be able to see the per-partition error in the returned - // topic partition list. - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic, 0, Offset::Offset(2)) - .unwrap(); - tpl.add_partition_offset(&topic, 1, Offset::Offset(1)) - .unwrap(); - let res_tpl = admin_client.delete_records(&tpl, &opts).await.unwrap(); - assert_eq!(res_tpl.count(), 2); - assert_eq!(res_tpl.elements()[0].topic(), topic); - assert_eq!(res_tpl.elements()[0].partition(), 0); - assert_eq!(res_tpl.elements()[0].offset(), Offset::Offset(2)); - assert_eq!(res_tpl.elements()[0].error(), Ok(())); - assert_eq!(res_tpl.elements()[1].topic(), topic); - assert_eq!(res_tpl.elements()[1].partition(), 1); - assert_eq!( - res_tpl.elements()[1].error(), - Err(KafkaError::OffsetFetch(RDKafkaErrorCode::UnknownPartition)) - ); - - // Ensure that the low water mark has advanced to 2. - let (lo, hi) = producer - .client() - .fetch_watermarks(&topic, 0, timeout) - .unwrap(); - assert_eq!(lo, 2); - assert_eq!(hi, 5); - - // Delete all records up to offset 5. - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic, 0, Offset::End).unwrap(); - let res_tpl = admin_client.delete_records(&tpl, &opts).await.unwrap(); - assert_eq!(res_tpl.count(), 1); - assert_eq!(res_tpl.elements()[0].topic(), topic); - assert_eq!(res_tpl.elements()[0].partition(), 0); - assert_eq!(res_tpl.elements()[0].offset(), Offset::Offset(5)); - assert_eq!(res_tpl.elements()[0].error(), Ok(())); - - // Ensure that the low water mark has advanced to 5. - let (lo, hi) = producer - .client() - .fetch_watermarks(&topic, 0, timeout) - .unwrap(); - assert_eq!(lo, 5); - assert_eq!(hi, 5); -} - -#[tokio::test] -async fn test_configs() { - let admin_client = create_admin_client(); - let opts = AdminOptions::new(); - let broker = ResourceSpecifier::Broker(0); - - let res = admin_client - .describe_configs(&[broker], &opts) - .await - .expect("describe configs failed"); - let config = &res[0].as_ref().expect("describe configs failed"); - let orig_val = config - .get("log.flush.interval.messages") - .expect("original config entry missing") - .value - .as_ref() - .expect("original value missing"); - - let config = AlterConfig::new(broker).set("log.flush.interval.messages", "1234"); - let res = admin_client - .alter_configs(&[config], &opts) - .await - .expect("alter configs failed"); - assert_eq!(res, &[Ok(OwnedResourceSpecifier::Broker(0))]); - - let mut tries = 0; - loop { - let res = admin_client - .describe_configs(&[broker], &opts) - .await - .expect("describe configs failed"); - let config = &res[0].as_ref().expect("describe configs failed"); - let entry = config.get("log.flush.interval.messages"); - let expected_entry = if get_broker_version() < KafkaVersion(1, 1, 0, 0) { - // Pre-1.1, the AlterConfig operation will silently fail, and the - // config will remain unchanged, which I guess is worth testing. - ConfigEntry { - name: "log.flush.interval.messages".into(), - value: Some(orig_val.clone()), - source: ConfigSource::Default, - is_read_only: true, - is_default: true, - is_sensitive: false, - } - } else { - ConfigEntry { - name: "log.flush.interval.messages".into(), - value: Some("1234".into()), - source: ConfigSource::DynamicBroker, - is_read_only: false, - is_default: false, - is_sensitive: false, - } - }; - if entry == Some(&expected_entry) { - break; - } else if tries >= 5 { - panic!("{:?} != {:?}", entry, Some(&expected_entry)); - } else { - tries += 1; - tokio::time::sleep(Duration::from_secs(1)).await; - } - } - - let config = AlterConfig::new(broker).set("log.flush.interval.ms", orig_val); - let res = admin_client - .alter_configs(&[config], &opts) - .await - .expect("alter configs failed"); - assert_eq!(res, &[Ok(OwnedResourceSpecifier::Broker(0))]); -} - -#[tokio::test] -async fn test_groups() { - let admin_client = create_admin_client(); - - // Verify that a valid group can be deleted. - { - let group_name = rand_test_group(); - create_consumer_group(&group_name).await; - let res = admin_client - .delete_groups(&[&group_name], &AdminOptions::default()) - .await; - assert_eq!(res, Ok(vec![Ok(group_name.to_string())])); - } - - // Verify that attempting to delete an unknown group returns a "group not - // found" error. - { - let unknown_group_name = rand_test_group(); - let res = admin_client - .delete_groups(&[&unknown_group_name], &AdminOptions::default()) - .await; - let expected: GroupResult = Err((unknown_group_name, RDKafkaErrorCode::GroupIdNotFound)); - assert_eq!(res, Ok(vec![expected])); - } - - // Verify that deleting a valid and invalid group results in a mixed result - // set. - { - let group_name = rand_test_group(); - let unknown_group_name = rand_test_group(); - create_consumer_group(&group_name).await; - let res = admin_client - .delete_groups( - &[&group_name, &unknown_group_name], - &AdminOptions::default(), - ) - .await; - assert_eq!( - res, - Ok(vec![ - Ok(group_name.to_string()), - Err(( - unknown_group_name.to_string(), - RDKafkaErrorCode::GroupIdNotFound - )) - ]) - ); - } -} - -// Tests whether each admin operation properly reports an error if the entire -// request fails. The original implementations failed to check this, resulting -// in confusing situations where a failed admin request would return Ok([]). -#[tokio::test] -async fn test_event_errors() { - // Configure an admin client to target a Kafka server that doesn't exist, - // then set an impossible timeout. This will ensure that every request fails - // with an OperationTimedOut error, assuming, of course, that the request - // passes client-side validation. - let admin_client = ClientConfig::new() - .set("bootstrap.servers", "noexist") - .create::>() - .expect("admin client creation failed"); - let opts = AdminOptions::new().request_timeout(Some(Duration::from_nanos(1))); - - let res = admin_client.create_topics(&[], &opts).await; - assert_eq!( - res, - Err(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) - ); - - let res = admin_client.create_partitions(&[], &opts).await; - assert_eq!( - res, - Err(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) - ); - - let res = admin_client.delete_topics(&[], &opts).await; - assert_eq!( - res, - Err(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) - ); - - let res = admin_client.describe_configs(&[], &opts).await; - assert_eq!( - res.err(), - Some(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) - ); - - let res = admin_client.alter_configs(&[], &opts).await; - assert_eq!( - res, - Err(KafkaError::AdminOp(RDKafkaErrorCode::OperationTimedOut)) - ); -} diff --git a/tests/test_high_consumers.rs b/tests/test_high_consumers.rs deleted file mode 100644 index b22dc0b2b..000000000 --- a/tests/test_high_consumers.rs +++ /dev/null @@ -1,625 +0,0 @@ -//! Test data consumption using high level consumers. - -use std::collections::HashMap; -use std::error::Error; -use std::sync::Arc; - -use futures::future; -use futures::stream::StreamExt; -use maplit::hashmap; -use rdkafka_sys::RDKafkaErrorCode; -use tokio::time::{self, Duration}; - -use rdkafka::consumer::{CommitMode, Consumer, ConsumerContext, StreamConsumer}; -use rdkafka::error::KafkaError; -use rdkafka::topic_partition_list::{Offset, TopicPartitionList}; -use rdkafka::util::current_time_millis; -use rdkafka::{Message, Timestamp}; -use rdkafka_sys::types::RDKafkaConfRes; - -use crate::utils::*; - -mod utils; - -// Create stream consumer for tests -fn create_stream_consumer( - group_id: &str, - config_overrides: Option>, -) -> StreamConsumer { - let cons_context = ConsumerTestContext { _n: 64 }; - create_stream_consumer_with_context(group_id, config_overrides, cons_context) -} - -fn create_stream_consumer_with_context( - group_id: &str, - config_overrides: Option>, - context: C, -) -> StreamConsumer -where - C: ConsumerContext + 'static, -{ - consumer_config(group_id, config_overrides) - .create_with_context(context) - .expect("Consumer creation failed") -} - -#[tokio::test] -async fn test_invalid_max_poll_interval() { - let res: Result = consumer_config( - &rand_test_group(), - Some(hashmap! { "max.poll.interval.ms" => "-1" }), - ) - .create(); - match res { - Err(KafkaError::ClientConfig(RDKafkaConfRes::RD_KAFKA_CONF_INVALID, desc, key, value)) => { - assert_eq!( - desc, - "Configuration property \"max.poll.interval.ms\" value -1 is outside allowed range 1..86400000\n" - ); - assert_eq!(key, "max.poll.interval.ms"); - assert_eq!(value, "-1"); - } - Ok(_) => panic!("invalid max poll interval configuration accepted"), - Err(e) => panic!( - "incorrect error returned for invalid max poll interval: {:?}", - e - ), - } -} - -// All produced messages should be consumed. -#[tokio::test(flavor = "multi_thread")] -async fn test_produce_consume_base() { - let _r = env_logger::try_init(); - - let start_time = current_time_millis(); - let topic_name = rand_test_topic("test_produce_consume_base"); - let message_map = populate_topic(&topic_name, 100, &value_fn, &key_fn, None, None).await; - let consumer = create_stream_consumer(&rand_test_group(), None); - consumer.subscribe(&[topic_name.as_str()]).unwrap(); - - let _consumer_future = consumer - .stream() - .take(100) - .for_each(|message| { - match message { - Ok(m) => { - let id = message_map[&(m.partition(), m.offset())]; - match m.timestamp() { - Timestamp::CreateTime(timestamp) => assert!(timestamp >= start_time), - _ => panic!("Expected createtime for message timestamp"), - }; - assert_eq!(m.payload_view::().unwrap().unwrap(), value_fn(id)); - assert_eq!(m.key_view::().unwrap().unwrap(), key_fn(id)); - assert_eq!(m.topic(), topic_name.as_str()); - } - Err(e) => panic!("Error receiving message: {:?}", e), - }; - future::ready(()) - }) - .await; -} - -/// Test that multiple message streams from the same consumer all receive -/// messages. In a previous version of rust-rdkafka, the `StreamConsumerContext` -/// could only manage one waker, so each `MessageStream` would compete for the -/// waker slot. -#[tokio::test(flavor = "multi_thread")] -async fn test_produce_consume_base_concurrent() { - let _r = env_logger::try_init(); - - let topic_name = rand_test_topic("test_produce_consume_base_concurrent"); - populate_topic(&topic_name, 100, &value_fn, &key_fn, None, None).await; - - let consumer = Arc::new(create_stream_consumer(&rand_test_group(), None)); - consumer.subscribe(&[topic_name.as_str()]).unwrap(); - - let mk_task = || { - let consumer = consumer.clone(); - tokio::spawn(async move { - consumer - .stream() - .take(20) - .for_each(|message| match message { - Ok(_) => future::ready(()), - Err(e) => panic!("Error receiving message: {:?}", e), - }) - .await; - }) - }; - - for res in future::join_all((0..5).map(|_| mk_task())).await { - res.unwrap(); - } -} - -// All produced messages should be consumed. -#[tokio::test(flavor = "multi_thread")] -async fn test_produce_consume_base_assign() { - let _r = env_logger::try_init(); - - let topic_name = rand_test_topic("test_produce_consume_base_assign"); - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(2), None).await; - let consumer = create_stream_consumer(&rand_test_group(), None); - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) - .unwrap(); - tpl.add_partition_offset(&topic_name, 1, Offset::Offset(2)) - .unwrap(); - tpl.add_partition_offset(&topic_name, 2, Offset::Offset(9)) - .unwrap(); - consumer.assign(&tpl).unwrap(); - - let mut partition_count = vec![0, 0, 0]; - - let _consumer_future = consumer - .stream() - .take(19) - .for_each(|message| { - match message { - Ok(m) => partition_count[m.partition() as usize] += 1, - Err(e) => panic!("Error receiving message: {:?}", e), - }; - future::ready(()) - }) - .await; - - assert_eq!(partition_count, vec![10, 8, 1]); -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_produce_consume_base_unassign() { - let _r = env_logger::try_init(); - - let topic_name = rand_test_topic("test_produce_consume_base_unassign"); - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(2), None).await; - let consumer = create_stream_consumer(&rand_test_group(), None); - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) - .unwrap(); - tpl.add_partition_offset(&topic_name, 1, Offset::Offset(2)) - .unwrap(); - tpl.add_partition_offset(&topic_name, 2, Offset::Offset(9)) - .unwrap(); - consumer.assign(&tpl).unwrap(); - let mut assignments = consumer.assignment().unwrap(); - assert_eq!(assignments.count(), 3); - - consumer.unassign().unwrap(); - assignments = consumer.assignment().unwrap(); - assert_eq!(assignments.count(), 0); -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_produce_consume_base_incremental_assign_and_unassign() { - let _r = env_logger::try_init(); - - let topic_name = rand_test_topic("test_produce_consume_base_incremental_assign_and_unassign"); - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(2), None).await; - let consumer = create_stream_consumer(&rand_test_group(), None); - - // Adding a simple partition - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) - .unwrap(); - consumer.incremental_assign(&tpl).unwrap(); - let mut assignments = consumer.assignment().unwrap(); - assert_eq!(assignments.count(), 1); - - // Adding another partition - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic_name, 1, Offset::Beginning) - .unwrap(); - consumer.incremental_assign(&tpl).unwrap(); - assignments = consumer.assignment().unwrap(); - assert_eq!(assignments.count(), 2); - - // Removing one partition - consumer.incremental_unassign(&tpl).unwrap(); - assignments = consumer.assignment().unwrap(); - assert_eq!(assignments.count(), 1); - - // unassigning an non assigned partition should fail - let err = consumer.incremental_unassign(&tpl); - - assert_eq!( - err, - Err(KafkaError::Subscription("_INVALID_ARG".to_string())) - ) -} - -// All produced messages should be consumed. -#[tokio::test(flavor = "multi_thread")] -async fn test_produce_consume_with_timestamp() { - let _r = env_logger::try_init(); - - let topic_name = rand_test_topic("test_produce_consume_with_timestamp"); - let message_map = - populate_topic(&topic_name, 100, &value_fn, &key_fn, Some(0), Some(1111)).await; - let consumer = create_stream_consumer(&rand_test_group(), None); - consumer.subscribe(&[topic_name.as_str()]).unwrap(); - - let _consumer_future = consumer - .stream() - .take(100) - .for_each(|message| { - match message { - Ok(m) => { - let id = message_map[&(m.partition(), m.offset())]; - assert_eq!(m.timestamp(), Timestamp::CreateTime(1111)); - assert_eq!(m.payload_view::().unwrap().unwrap(), value_fn(id)); - assert_eq!(m.key_view::().unwrap().unwrap(), key_fn(id)); - } - Err(e) => panic!("Error receiving message: {:?}", e), - }; - future::ready(()) - }) - .await; - - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(0), Some(999_999)).await; - - // Lookup the offsets - let tpl = consumer - .offsets_for_timestamp(999_999, Duration::from_secs(10)) - .unwrap(); - let tp = tpl.find_partition(&topic_name, 0).unwrap(); - assert_eq!(tp.topic(), topic_name); - assert_eq!(tp.offset(), Offset::Offset(100)); - assert_eq!(tp.partition(), 0); - assert_eq!(tp.error(), Ok(())); -} - -// TODO: add check that commit cb gets called correctly -#[tokio::test(flavor = "multi_thread")] -async fn test_consumer_commit_message() { - let _r = env_logger::try_init(); - - let topic_name = rand_test_topic("test_consumer_commit_message"); - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 11, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 12, &value_fn, &key_fn, Some(2), None).await; - let consumer = create_stream_consumer(&rand_test_group(), None); - consumer.subscribe(&[topic_name.as_str()]).unwrap(); - - let _consumer_future = consumer - .stream() - .take(33) - .for_each(|message| { - match message { - Ok(m) => { - if m.partition() == 1 { - consumer.commit_message(&m, CommitMode::Async).unwrap(); - } - } - Err(e) => panic!("error receiving message: {:?}", e), - }; - future::ready(()) - }) - .await; - - let timeout = Duration::from_secs(5); - assert_eq!( - consumer.fetch_watermarks(&topic_name, 0, timeout).unwrap(), - (0, 10) - ); - assert_eq!( - consumer.fetch_watermarks(&topic_name, 1, timeout).unwrap(), - (0, 11) - ); - assert_eq!( - consumer.fetch_watermarks(&topic_name, 2, timeout).unwrap(), - (0, 12) - ); - - let mut assignment = TopicPartitionList::new(); - assignment - .add_partition_offset(&topic_name, 0, Offset::Stored) - .unwrap(); - assignment - .add_partition_offset(&topic_name, 1, Offset::Stored) - .unwrap(); - assignment - .add_partition_offset(&topic_name, 2, Offset::Stored) - .unwrap(); - assert_eq!(assignment, consumer.assignment().unwrap()); - - let mut committed = TopicPartitionList::new(); - committed - .add_partition_offset(&topic_name, 0, Offset::Invalid) - .unwrap(); - committed - .add_partition_offset(&topic_name, 1, Offset::Offset(11)) - .unwrap(); - committed - .add_partition_offset(&topic_name, 2, Offset::Invalid) - .unwrap(); - assert_eq!(committed, consumer.committed(timeout).unwrap()); - - let mut position = TopicPartitionList::new(); - position - .add_partition_offset(&topic_name, 0, Offset::Offset(10)) - .unwrap(); - position - .add_partition_offset(&topic_name, 1, Offset::Offset(11)) - .unwrap(); - position - .add_partition_offset(&topic_name, 2, Offset::Offset(12)) - .unwrap(); - assert_eq!(position, consumer.position().unwrap()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_consumer_store_offset_commit() { - let _r = env_logger::try_init(); - - let topic_name = rand_test_topic("test_consumer_store_offset_commit"); - populate_topic(&topic_name, 10, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 11, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 12, &value_fn, &key_fn, Some(2), None).await; - let mut config = HashMap::new(); - config.insert("enable.auto.offset.store", "false"); - config.insert("enable.partition.eof", "true"); - let consumer = create_stream_consumer(&rand_test_group(), Some(config)); - consumer.subscribe(&[topic_name.as_str()]).unwrap(); - - let _consumer_future = consumer - .stream() - .take(36) - .for_each(|message| { - match message { - Ok(m) => { - if m.partition() == 1 { - consumer.store_offset_from_message(&m).unwrap(); - } - } - Err(KafkaError::PartitionEOF(_)) => {} - Err(e) => panic!("Error receiving message: {:?}", e), - }; - future::ready(()) - }) - .await; - - // Commit the whole current state - consumer.commit_consumer_state(CommitMode::Sync).unwrap(); - - let timeout = Duration::from_secs(5); - assert_eq!( - consumer.fetch_watermarks(&topic_name, 0, timeout).unwrap(), - (0, 10) - ); - assert_eq!( - consumer.fetch_watermarks(&topic_name, 1, timeout).unwrap(), - (0, 11) - ); - assert_eq!( - consumer.fetch_watermarks(&topic_name, 2, timeout).unwrap(), - (0, 12) - ); - - let mut assignment = TopicPartitionList::new(); - assignment - .add_partition_offset(&topic_name, 0, Offset::Stored) - .unwrap(); - assignment - .add_partition_offset(&topic_name, 1, Offset::Stored) - .unwrap(); - assignment - .add_partition_offset(&topic_name, 2, Offset::Stored) - .unwrap(); - assert_eq!(assignment, consumer.assignment().unwrap()); - - let mut committed = TopicPartitionList::new(); - committed - .add_partition_offset(&topic_name, 0, Offset::Invalid) - .unwrap(); - committed - .add_partition_offset(&topic_name, 1, Offset::Offset(11)) - .unwrap(); - committed - .add_partition_offset(&topic_name, 2, Offset::Invalid) - .unwrap(); - assert_eq!(committed, consumer.committed(timeout).unwrap()); - - let mut position = TopicPartitionList::new(); - position - .add_partition_offset(&topic_name, 0, Offset::Offset(10)) - .unwrap(); - position - .add_partition_offset(&topic_name, 1, Offset::Offset(11)) - .unwrap(); - position - .add_partition_offset(&topic_name, 2, Offset::Offset(12)) - .unwrap(); - assert_eq!(position, consumer.position().unwrap()); -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_consumer_commit_metadata() -> Result<(), Box> { - let _ = env_logger::try_init(); - - let topic_name = rand_test_topic("test_consumer_commit_metadata"); - let group_name = rand_test_group(); - populate_topic(&topic_name, 10, &value_fn, &key_fn, None, None).await; - - let create_consumer = || async { - // Disable auto-commit so we can manually drive the commits. - let mut config = HashMap::new(); - config.insert("enable.auto.commit", "false"); - let consumer = create_stream_consumer(&group_name, Some(config)); - - // Subscribe to the topic and wait for at least one message, which - // ensures that the consumer group has been joined and such. - consumer.subscribe(&[topic_name.as_str()])?; - let _ = consumer.stream().next().await; - - Ok::<_, Box>(consumer) - }; - - // Create a topic partition list where each element has some associated - // metadata. - let tpl = { - let mut tpl = TopicPartitionList::new(); - let mut tpl1 = tpl.add_partition(&topic_name, 0); - tpl1.set_offset(Offset::Offset(1))?; - tpl1.set_metadata("one"); - let mut tpl2 = tpl.add_partition(&topic_name, 1); - tpl2.set_offset(Offset::Offset(1))?; - tpl2.set_metadata("two"); - let mut tpl3 = tpl.add_partition(&topic_name, 2); - tpl3.set_offset(Offset::Offset(1))?; - tpl3.set_metadata("three"); - tpl - }; - - // Ensure that the commit state immediately includes the metadata. - { - let consumer = create_consumer().await?; - consumer.commit(&tpl, CommitMode::Sync)?; - assert_eq!(consumer.committed(None)?, tpl); - } - - // Ensure that the commit state on a new consumer in the same group - // can see the same metadata. - { - let consumer = create_consumer().await?; - assert_eq!(consumer.committed(None)?, tpl); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_consume_partition_order() { - let _r = env_logger::try_init(); - - let topic_name = rand_test_topic("test_consume_partition_order"); - populate_topic(&topic_name, 4, &value_fn, &key_fn, Some(0), None).await; - populate_topic(&topic_name, 4, &value_fn, &key_fn, Some(1), None).await; - populate_topic(&topic_name, 4, &value_fn, &key_fn, Some(2), None).await; - - // Using partition queues should allow us to consume the partitions - // in a round-robin fashion. - { - let consumer = Arc::new(create_stream_consumer(&rand_test_group(), None)); - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) - .unwrap(); - tpl.add_partition_offset(&topic_name, 1, Offset::Beginning) - .unwrap(); - tpl.add_partition_offset(&topic_name, 2, Offset::Beginning) - .unwrap(); - consumer.assign(&tpl).unwrap(); - - let mut partition_streams: Vec<_> = (0..3) - .map(|i| consumer.split_partition_queue(&topic_name, i).unwrap()) - .collect(); - - for _ in 0..4 { - let main_message = - time::timeout(Duration::from_millis(100), consumer.stream().next()).await; - assert!(main_message.is_err()); - - for (i, stream) in partition_streams.iter_mut().enumerate() { - let queue_message = stream.recv().await.unwrap(); - assert_eq!(queue_message.partition(), i as i32); - } - } - } - - // When not all partitions have been split into separate queues, the - // unsplit partitions should still be accessible via the main queue. - { - let consumer = Arc::new(create_stream_consumer(&rand_test_group(), None)); - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) - .unwrap(); - tpl.add_partition_offset(&topic_name, 1, Offset::Beginning) - .unwrap(); - tpl.add_partition_offset(&topic_name, 2, Offset::Beginning) - .unwrap(); - consumer.assign(&tpl).unwrap(); - - let partition1 = consumer.split_partition_queue(&topic_name, 1).unwrap(); - - let mut i = 0; - while i < 5 { - if let Ok(m) = time::timeout(Duration::from_millis(1000), consumer.recv()).await { - // retry on transient errors until we get a message - let m = match m { - Err(KafkaError::MessageConsumption( - RDKafkaErrorCode::BrokerTransportFailure, - )) - | Err(KafkaError::MessageConsumption(RDKafkaErrorCode::AllBrokersDown)) - | Err(KafkaError::MessageConsumption(RDKafkaErrorCode::OperationTimedOut)) => { - continue; - } - Err(err) => { - panic!("Unexpected error receiving message: {:?}", err); - } - Ok(m) => m, - }; - let partition: i32 = m.partition(); - assert!(partition == 0 || partition == 2); - i += 1; - } else { - panic!("Timeout receiving message"); - } - - if let Ok(m) = time::timeout(Duration::from_millis(1000), partition1.recv()).await { - // retry on transient errors until we get a message - let m = match m { - Err(KafkaError::MessageConsumption( - RDKafkaErrorCode::BrokerTransportFailure, - )) - | Err(KafkaError::MessageConsumption(RDKafkaErrorCode::AllBrokersDown)) - | Err(KafkaError::MessageConsumption(RDKafkaErrorCode::OperationTimedOut)) => { - continue; - } - Err(err) => { - panic!("Unexpected error receiving message: {:?}", err); - } - Ok(m) => m, - }; - assert_eq!(m.partition(), 1); - i += 1; - } else { - panic!("Timeout receiving message"); - } - } - } - - // Sending the queue to another task that is likely to outlive the - // original thread should work. This is not idiomatic, as the consumer - // should be continuously polled to serve callbacks, but it should not panic - // or result in memory unsafety, etc. - { - let consumer = Arc::new(create_stream_consumer(&rand_test_group(), None)); - let mut tpl = TopicPartitionList::new(); - tpl.add_partition_offset(&topic_name, 0, Offset::Beginning) - .unwrap(); - consumer.assign(&tpl).unwrap(); - let stream = consumer.split_partition_queue(&topic_name, 0).unwrap(); - - let worker = tokio::spawn({ - async move { - for _ in 0..4 { - let stream_message = stream.recv().await.unwrap(); - assert_eq!(stream_message.partition(), 0); - } - } - }); - - let main_message = - time::timeout(Duration::from_millis(100), consumer.stream().next()).await; - assert!(main_message.is_err()); - - drop(consumer); - worker.await.unwrap(); - } -} diff --git a/tests/test_high_producers.rs b/tests/test_high_producers.rs deleted file mode 100644 index 9a71c9981..000000000 --- a/tests/test_high_producers.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Test data production using high level producers. - -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use futures::stream::{FuturesUnordered, StreamExt}; - -use rdkafka::client::DefaultClientContext; -use rdkafka::config::ClientConfig; -use rdkafka::error::{KafkaError, RDKafkaErrorCode}; -use rdkafka::message::{Header, Headers, Message, OwnedHeaders}; -use rdkafka::producer::{FutureProducer, FutureRecord, Producer}; -use rdkafka::util::Timeout; -use rdkafka::Timestamp; - -use crate::utils::*; - -mod utils; - -fn future_producer(config_overrides: HashMap<&str, &str>) -> FutureProducer { - let mut config = ClientConfig::new(); - config - .set("bootstrap.servers", "localhost") - .set("message.timeout.ms", "5000"); - for (key, value) in config_overrides { - config.set(key, value); - } - config.create().expect("Failed to create producer") -} - -#[tokio::test] -async fn test_future_producer_send() { - let producer = future_producer(HashMap::new()); - let topic_name = rand_test_topic("test_future_producer_send"); - - let results: FuturesUnordered<_> = (0..10) - .map(|_| { - producer.send( - FutureRecord::to(&topic_name).payload("A").key("B"), - Duration::from_secs(0), - ) - }) - .collect(); - - let results: Vec<_> = results.collect().await; - assert!(results.len() == 10); - for (i, result) in results.into_iter().enumerate() { - let delivered = result.unwrap(); - assert_eq!(delivered.partition, 1); - assert_eq!(delivered.offset, i as i64); - assert!(delivered.timestamp < Timestamp::now()); - } -} - -#[tokio::test] -async fn test_future_producer_send_full() { - // Connect to a nonexistent Kafka broker with a long message timeout and a - // tiny producer queue, so we can fill up the queue for a while by sending a - // single message. - let mut config = HashMap::new(); - config.insert("bootstrap.servers", ""); - config.insert("message.timeout.ms", "5000"); - config.insert("queue.buffering.max.messages", "1"); - let producer = &future_producer(config); - let topic_name = &rand_test_topic("test_future_producer_send_full"); - - // Fill up the queue. - producer - .send_result(FutureRecord::to(topic_name).payload("A").key("B")) - .unwrap(); - - let send_message = |timeout| async move { - let start = Instant::now(); - let res = producer - .send(FutureRecord::to(topic_name).payload("A").key("B"), timeout) - .await; - match res { - Ok(_) => panic!("send unexpectedly succeeded"), - Err((KafkaError::MessageProduction(RDKafkaErrorCode::QueueFull), _)) => start.elapsed(), - Err((e, _)) => panic!("got incorrect error: {}", e), - } - }; - - // Sending a message with no timeout should return a `QueueFull` error - // approximately immediately. - let elapsed = send_message(Duration::from_secs(0)).await; - assert!(elapsed < Duration::from_millis(20)); - - // Sending a message with a 1s timeout should return a `QueueFull` error - // in about 1s. - let elapsed = send_message(Duration::from_secs(1)).await; - assert!(elapsed > Duration::from_millis(800)); - assert!(elapsed < Duration::from_millis(1200)); - - producer.flush(Timeout::Never).unwrap(); -} - -#[tokio::test] -async fn test_future_producer_send_fail() { - let producer = future_producer(HashMap::new()); - - let future = producer.send( - FutureRecord::to("topic") - .payload("payload") - .key("key") - .partition(100) // Fail - .headers( - OwnedHeaders::new() - .insert(Header { - key: "0", - value: Some("A"), - }) - .insert(Header { - key: "1", - value: Some("B"), - }) - .insert(Header { - key: "2", - value: Some("C"), - }), - ), - Duration::from_secs(10), - ); - - match future.await { - Err((kafka_error, owned_message)) => { - assert_eq!( - kafka_error.to_string(), - "Message production error: UnknownPartition (Local: Unknown partition)" - ); - assert_eq!(owned_message.topic(), "topic"); - let headers = owned_message.headers().unwrap(); - assert_eq!(headers.count(), 3); - assert_eq!( - headers.get_as::(0), - Ok(Header { - key: "0", - value: Some("A") - }) - ); - assert_eq!( - headers.get_as::(1), - Ok(Header { - key: "1", - value: Some("B") - }) - ); - assert_eq!( - headers.get_as::(2), - Ok(Header { - key: "2", - value: Some("C") - }) - ); - } - e => { - panic!("Unexpected return value: {:?}", e); - } - } -} - -#[tokio::test] -async fn test_future_undelivered() { - let delivery_future = { - let mut config = ClientConfig::new(); - // There's no server running there - config - .set("bootstrap.servers", "localhost:47021") - .set("message.timeout.ms", "1"); - let producer: FutureProducer = config.create().expect("Failed to create producer"); - - producer - .send_result( - FutureRecord::to("topic") - .payload("payload") - .key("key") - .partition(100), - ) - .expect("Failed to queue message") - - // drop producer. This should resolve the future as per purge API (couldn't be delivered) - }; - - match delivery_future.await { - Ok(Err((kafka_error, owned_message))) => { - assert_eq!( - kafka_error.to_string(), - "Message production error: PurgeQueue (Local: Purged in queue)" - ); - assert_eq!(owned_message.topic(), "topic"); - assert_eq!(owned_message.key(), Some(b"key" as _)); - assert_eq!(owned_message.payload(), Some(b"payload" as _)); - } - v => { - panic!("Unexpected return value: {:?}", v); - } - } -} diff --git a/tests/test_low_producers.rs b/tests/test_low_producers.rs deleted file mode 100644 index ac12b8e04..000000000 --- a/tests/test_low_producers.rs +++ /dev/null @@ -1,562 +0,0 @@ -//! Test data production using low level producers. - -use std::collections::{HashMap, HashSet}; -use std::error::Error; -use std::ffi::CString; -use std::sync::Arc; -use std::sync::Mutex; -use std::thread; -use std::time::Duration; - -use maplit::hashmap; - -use rdkafka::config::ClientConfig; -use rdkafka::error::{KafkaError, RDKafkaErrorCode}; -use rdkafka::message::{Header, Headers, Message, OwnedHeaders, OwnedMessage}; -use rdkafka::producer::{ - BaseProducer, BaseRecord, DeliveryResult, NoCustomPartitioner, Partitioner, Producer, - ProducerContext, ThreadedProducer, -}; -use rdkafka::types::RDKafkaRespErr; -use rdkafka::util::current_time_millis; -use rdkafka::{ClientContext, Statistics}; - -use crate::utils::*; - -mod utils; - -struct PrintingContext { - _n: i64, // Add data for memory access validation -} - -impl ClientContext for PrintingContext { - // Access and use all stats. - fn stats(&self, stats: Statistics) { - let stats_str = format!("{:?}", stats); - println!("Stats received: {} bytes", stats_str.len()); - } -} - -impl ProducerContext for PrintingContext { - type DeliveryOpaque = usize; - - fn delivery(&self, delivery_result: &DeliveryResult, delivery_opaque: Self::DeliveryOpaque) { - println!("Delivery: {:?} {:?}", delivery_result, delivery_opaque); - } -} - -type TestProducerDeliveryResult = (OwnedMessage, Option, usize); - -#[derive(Clone)] -struct CollectingContext { - stats: Arc>>, - results: Arc>>, - partitioner: Option, -} - -impl CollectingContext { - fn new() -> CollectingContext { - CollectingContext { - stats: Arc::new(Mutex::new(Vec::new())), - results: Arc::new(Mutex::new(Vec::new())), - partitioner: None, - } - } -} - -impl CollectingContext { - fn new_with_custom_partitioner(partitioner: Part) -> CollectingContext { - CollectingContext { - stats: Arc::new(Mutex::new(Vec::new())), - results: Arc::new(Mutex::new(Vec::new())), - partitioner: Some(partitioner), - } - } -} - -impl ClientContext for CollectingContext { - // Access and use all stats. - fn stats(&self, stats: Statistics) { - let mut stats_vec = self.stats.lock().unwrap(); - (*stats_vec).push(stats); - } -} - -impl ProducerContext for CollectingContext { - type DeliveryOpaque = usize; - - fn delivery(&self, delivery_result: &DeliveryResult, delivery_opaque: Self::DeliveryOpaque) { - let mut results = self.results.lock().unwrap(); - match *delivery_result { - Ok(ref message) => (*results).push((message.detach(), None, delivery_opaque)), - Err((ref err, ref message)) => { - (*results).push((message.detach(), Some(err.clone()), delivery_opaque)) - } - } - } - - fn get_custom_partitioner(&self) -> Option<&Part> { - match &self.partitioner { - None => None, - Some(p) => Some(p), - } - } -} - -// Partitioner sending all messages to single, defined partition. -#[derive(Clone)] -pub struct FixedPartitioner { - partition: i32, -} - -impl FixedPartitioner { - fn new(partition: i32) -> Self { - Self { partition } - } -} - -impl Partitioner for FixedPartitioner { - fn partition( - &self, - _topic_name: &str, - _key: Option<&[u8]>, - _partition_cnt: i32, - _is_paritition_available: impl Fn(i32) -> bool, - ) -> i32 { - self.partition - } -} - -#[derive(Clone)] -pub struct PanicPartitioner {} - -impl Partitioner for PanicPartitioner { - fn partition( - &self, - _topic_name: &str, - _key: Option<&[u8]>, - _partition_cnt: i32, - _is_paritition_available: impl Fn(i32) -> bool, - ) -> i32 { - panic!("partition() panic"); - } -} - -fn default_config(config_overrides: HashMap<&str, &str>) -> ClientConfig { - let mut config = ClientConfig::new(); - config - .set("bootstrap.servers", get_bootstrap_server()) - .set("message.timeout.ms", "5000"); - - for (key, value) in config_overrides { - config.set(key, value); - } - config -} - -fn base_producer(config_overrides: HashMap<&str, &str>) -> BaseProducer { - base_producer_with_context(PrintingContext { _n: 123 }, config_overrides) -} - -fn base_producer_with_context>( - context: C, - config_overrides: HashMap<&str, &str>, -) -> BaseProducer { - configure_logging_for_tests(); - default_config(config_overrides) - .create_with_context::>(context) - .unwrap() -} - -#[allow(dead_code)] -fn threaded_producer( - config_overrides: HashMap<&str, &str>, -) -> ThreadedProducer { - threaded_producer_with_context(PrintingContext { _n: 123 }, config_overrides) -} - -fn threaded_producer_with_context( - context: C, - config_overrides: HashMap<&str, &str>, -) -> ThreadedProducer -where - Part: Partitioner + Send + Sync + 'static, - C: ProducerContext, -{ - configure_logging_for_tests(); - default_config(config_overrides) - .create_with_context::>(context) - .unwrap() -} - -// TESTS - -#[test] -fn test_base_producer_queue_full() { - let producer = base_producer(hashmap! { "queue.buffering.max.messages" => "10" }); - let topic_name = rand_test_topic("test_base_producer_queue_full"); - - let results = (0..30) - .map(|id| { - producer.send( - BaseRecord::with_opaque_to(&topic_name, id) - .payload("payload") - .key("key") - .timestamp(current_time_millis()), - ) - }) - .collect::>(); - while producer.in_flight_count() > 0 { - producer.poll(Duration::from_millis(100)); - } - - let errors = results - .iter() - .filter(|&e| { - matches!( - e, - &Err(( - KafkaError::MessageProduction(RDKafkaErrorCode::QueueFull), - _ - )) - ) - }) - .count(); - - let success = results.iter().filter(|&r| r.is_ok()).count(); - - assert_eq!(results.len(), 30); - assert_eq!(success, 10); - assert_eq!(errors, 20); -} - -#[test] -fn test_base_producer_timeout() { - let context = CollectingContext::new(); - let bootstrap_server = get_bootstrap_server(); - let producer = base_producer_with_context( - context.clone(), - hashmap! { - "message.timeout.ms" => "100", - "bootstrap.servers" => &bootstrap_server, - }, - ); - let topic_name = rand_test_topic("test_base_producer_timeout"); - - let results_count = (0..10) - .map(|id| { - producer.send( - BaseRecord::with_opaque_to(&topic_name, id) - .payload("A") - .key("B"), - ) - }) - .filter(|r| r.is_ok()) - .count(); - assert_eq!(results_count, 10); - - thread::sleep(Duration::from_secs(5)); // Make sure messages expire - producer.flush(Duration::from_secs(10)).unwrap(); - - let delivery_results = context.results.lock().unwrap(); - let mut ids = HashSet::new(); - for &(ref message, ref error, id) in &(*delivery_results) { - assert_eq!(message.payload_view::(), Some(Ok("A"))); - assert_eq!(message.key_view::(), Some(Ok("B"))); - assert_eq!( - error, - &Some(KafkaError::MessageProduction( - RDKafkaErrorCode::MessageTimedOut - )) - ); - ids.insert(id); - } - assert_eq!(ids.len(), 10); -} - -struct HeaderCheckContext { - ids: Arc>>, -} - -impl ClientContext for HeaderCheckContext {} - -impl ProducerContext for HeaderCheckContext { - type DeliveryOpaque = usize; - - fn delivery(&self, delivery_result: &DeliveryResult, message_id: usize) { - let message = delivery_result.as_ref().unwrap(); - if message_id % 2 == 0 { - let headers = message.headers().unwrap(); - assert_eq!(headers.count(), 4); - assert_eq!( - headers.get(0), - Header { - key: "header1", - value: Some(&[1, 2, 3, 4][..]) - } - ); - assert_eq!( - headers.get_as::(1), - Ok(Header { - key: "header2", - value: Some("value2") - }) - ); - assert_eq!( - headers.get_as::<[u8]>(2), - Ok(Header { - key: "header3", - value: Some(&[][..]) - }) - ); - assert_eq!( - headers.get_as::<[u8]>(3), - Ok(Header { - key: "header4", - value: None - }) - ); - let headers: Vec<_> = headers.iter().collect(); - assert_eq!( - headers, - &[ - Header { - key: "header1", - value: Some(&[1, 2, 3, 4][..]), - }, - Header { - key: "header2", - value: Some(b"value2"), - }, - Header { - key: "header3", - value: Some(&[][..]), - }, - Header { - key: "header4", - value: None, - }, - ], - ) - } else { - assert!(message.headers().is_none()); - } - (*self.ids.lock().unwrap()).insert(message_id); - } -} - -#[test] -fn test_base_producer_headers() { - let ids_set = Arc::new(Mutex::new(HashSet::new())); - let context = HeaderCheckContext { - ids: ids_set.clone(), - }; - let producer = base_producer_with_context(context, HashMap::new()); - let topic_name = rand_test_topic("test_base_producer_headers"); - - let results_count = (0..10) - .map(|id| { - let mut record = BaseRecord::with_opaque_to(&topic_name, id).payload("A"); - if id % 2 == 0 { - record = record.headers( - OwnedHeaders::new() - .insert(Header { - key: "header1", - value: Some(&[1, 2, 3, 4]), - }) - .insert(Header { - key: "header2", - value: Some("value2"), - }) - .insert(Header { - key: "header3", - value: Some(&[]), - }) - .insert::>(Header { - key: "header4", - value: None, - }), - ); - } - producer.send::(record) - }) - .filter(|r| r.is_ok()) - .count(); - - producer.flush(Duration::from_secs(10)).unwrap(); - - assert_eq!(results_count, 10); - assert_eq!((*ids_set.lock().unwrap()).len(), 10); -} - -#[test] -fn test_threaded_producer_send() { - let context = CollectingContext::new(); - let producer = threaded_producer_with_context(context.clone(), HashMap::new()); - let topic_name = rand_test_topic("test_threaded_producer_send"); - - let results_count = (0..10) - .map(|id| { - producer.send( - BaseRecord::with_opaque_to(&topic_name, id) - .payload("A") - .key("B"), - ) - }) - .filter(|r| r.is_ok()) - .count(); - - assert_eq!(results_count, 10); - producer.flush(Duration::from_secs(10)).unwrap(); - - let delivery_results = context.results.lock().unwrap(); - let mut ids = HashSet::new(); - for &(ref message, ref error, id) in &(*delivery_results) { - assert_eq!(message.payload_view::(), Some(Ok("A"))); - assert_eq!(message.key_view::(), Some(Ok("B"))); - assert_eq!(error, &None); - ids.insert(id); - } -} - -#[test] -fn test_base_producer_opaque_arc() -> Result<(), Box> { - struct OpaqueArcContext {} - impl ClientContext for OpaqueArcContext {} - impl ProducerContext for OpaqueArcContext { - type DeliveryOpaque = Arc>; - - fn delivery(&self, _: &DeliveryResult, opaque: Self::DeliveryOpaque) { - let mut shared_count = opaque.lock().unwrap(); - *shared_count += 1; - } - } - - let shared_count = Arc::new(Mutex::new(0)); - let context = OpaqueArcContext {}; - let producer = base_producer_with_context(context, HashMap::new()); - let topic_name = rand_test_topic("test_base_producer_opaque_arc"); - - let results_count = (0..10) - .map(|_| { - let record = BaseRecord::with_opaque_to(&topic_name, shared_count.clone()).payload("A"); - producer.send::(record) - }) - .filter(|r| r.is_ok()) - .count(); - - producer.flush(Duration::from_secs(10)).unwrap(); - - let shared_count = Arc::try_unwrap(shared_count).unwrap().into_inner()?; - assert_eq!(results_count, shared_count); - Ok(()) -} - -#[test] -fn test_fatal_errors() { - let producer = base_producer(HashMap::new()); - - assert_eq!(producer.client().fatal_error(), None); - - let msg = CString::new("fake error").unwrap(); - unsafe { - rdkafka_sys::rd_kafka_test_fatal_error( - producer.client().native_ptr(), - RDKafkaRespErr::RD_KAFKA_RESP_ERR_OUT_OF_ORDER_SEQUENCE_NUMBER, - msg.as_ptr(), - ); - } - - assert_eq!( - producer.client().fatal_error(), - Some(( - RDKafkaErrorCode::OutOfOrderSequenceNumber, - "test_fatal_error: fake error".into() - )) - ) -} - -#[test] -fn test_register_custom_partitioner_linger_non_zero_key_null() { - // Custom partitioner is not used when sticky.partitioning.linger.ms > 0 and key is null. - // https://github.com/confluentinc/librdkafka/blob/081fd972fa97f88a1e6d9a69fc893865ffbb561a/src/rdkafka_msg.c#L1192-L1196 - let context = CollectingContext::new_with_custom_partitioner(PanicPartitioner {}); - let mut config_overrides = HashMap::new(); - config_overrides.insert("sticky.partitioning.linger.ms", "10"); - let producer = base_producer_with_context(context.clone(), config_overrides); - - producer - .send( - BaseRecord::<(), str, usize>::with_opaque_to( - &rand_test_topic("test_register_custom_partitioner_linger_non_zero_key_null"), - 0, - ) - .payload(""), - ) - .unwrap(); - producer.flush(Duration::from_secs(10)).unwrap(); - - let delivery_results = context.results.lock().unwrap(); - - assert_eq!(delivery_results.len(), 1); - - for (_, error, _) in &(*delivery_results) { - assert_eq!(*error, None); - } -} - -#[test] -fn test_custom_partitioner_base_producer() { - let context = CollectingContext::new_with_custom_partitioner(FixedPartitioner::new(2)); - let producer = base_producer_with_context(context.clone(), HashMap::new()); - let topic_name = rand_test_topic("test_custom_partitioner_base_producer"); - - let results_count = (0..10) - .map(|id| { - producer.send( - BaseRecord::with_opaque_to(&topic_name, id) - .payload("") - .key(""), - ) - }) - .filter(|r| r.is_ok()) - .count(); - - assert_eq!(results_count, 10); - producer.flush(Duration::from_secs(10)).unwrap(); - - let delivery_results = context.results.lock().unwrap(); - - for (message, error, _) in &(*delivery_results) { - assert_eq!(error, &None); - assert_eq!(message.partition(), 2); - } -} - -#[test] -fn test_custom_partitioner_threaded_producer() { - let context = CollectingContext::new_with_custom_partitioner(FixedPartitioner::new(2)); - let producer = threaded_producer_with_context(context.clone(), HashMap::new()); - let topic_name = rand_test_topic("test_custom_partitioner_threaded_producer"); - - let results_count = (0..10) - .map(|id| { - producer.send( - BaseRecord::with_opaque_to(&topic_name, id) - .payload("") - .key(""), - ) - }) - .filter(|r| r.is_ok()) - .count(); - - assert_eq!(results_count, 10); - producer.flush(Duration::from_secs(10)).unwrap(); - - let delivery_results = context.results.lock().unwrap(); - - for (message, error, _) in &(*delivery_results) { - assert_eq!(error, &None); - assert_eq!(message.partition(), 2); - } -} diff --git a/tests/test_transactions.rs b/tests/test_transactions.rs deleted file mode 100644 index 014c4e000..000000000 --- a/tests/test_transactions.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Test transactions using the base consumer and producer. - -use std::collections::HashMap; -use std::error::Error; -use std::time::Duration; - -use log::info; -use maplit::hashmap; - -use rdkafka::config::ClientConfig; -use rdkafka::config::RDKafkaLogLevel; -use rdkafka::consumer::{BaseConsumer, CommitMode, Consumer}; -use rdkafka::error::KafkaError; -use rdkafka::producer::{BaseProducer, BaseRecord, Producer}; -use rdkafka::topic_partition_list::{Offset, TopicPartitionList}; -use rdkafka::util::Timeout; - -use utils::*; - -mod utils; - -fn create_consumer( - config_overrides: Option>, -) -> Result { - configure_logging_for_tests(); - consumer_config(&rand_test_group(), config_overrides).create() -} - -fn create_producer() -> Result { - configure_logging_for_tests(); - let mut config = ClientConfig::new(); - config - .set("bootstrap.servers", get_bootstrap_server()) - .set("message.timeout.ms", "5000") - .set("enable.idempotence", "true") - .set("transactional.id", rand_test_transactional_id()) - .set("debug", "eos"); - config.set_log_level(RDKafkaLogLevel::Debug); - config.create() -} - -enum IsolationLevel { - ReadUncommitted, - ReadCommitted, -} - -fn count_records(topic: &str, iso: IsolationLevel) -> Result { - let consumer = create_consumer(Some(hashmap! { - "isolation.level" => match iso { - IsolationLevel::ReadUncommitted => "read_uncommitted", - IsolationLevel::ReadCommitted => "read_committed", - }, - "enable.partition.eof" => "true" - }))?; - let mut tpl = TopicPartitionList::new(); - tpl.add_partition(topic, 0); - consumer.assign(&tpl)?; - let mut count = 0; - for message in consumer.iter() { - match message { - Ok(_) => count += 1, - Err(KafkaError::PartitionEOF(_)) => break, - Err(e) => return Err(e), - } - } - Ok(count) -} - -#[tokio::test] -async fn test_transaction_abort() -> Result<(), Box> { - let consume_topic = rand_test_topic("test_transaction_abort"); - let produce_topic = rand_test_topic("test_transaction_abort"); - - populate_topic(&consume_topic, 30, &value_fn, &key_fn, Some(0), None).await; - - // Create consumer and subscribe to `consume_topic`. - let consumer = create_consumer(None)?; - consumer.subscribe(&[&consume_topic])?; - consumer.poll(Timeout::Never).unwrap()?; - - // Commit the first 10 messages. - let mut commit_tpl = TopicPartitionList::new(); - commit_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(10))?; - consumer.commit(&commit_tpl, CommitMode::Sync).unwrap(); - - // Create a producer and start a transaction. - let producer = create_producer()?; - producer.init_transactions(Timeout::Never)?; - producer.begin_transaction()?; - - // Tie the commit of offset 20 to the transaction. - let cgm = consumer.group_metadata().unwrap(); - let mut txn_tpl = TopicPartitionList::new(); - txn_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(20))?; - producer.send_offsets_to_transaction(&txn_tpl, &cgm, Timeout::Never)?; - - // Produce 10 records in the transaction. - for _ in 0..10 { - producer - .send( - BaseRecord::to(&produce_topic) - .payload("A") - .key("B") - .partition(0), - ) - .unwrap(); - } - - // Abort the transaction, but only after producing all messages. - info!("BEFORE FLUSH"); - producer.flush(Duration::from_secs(20))?; - info!("AFTER FLUSH"); - producer.abort_transaction(Duration::from_secs(20))?; - info!("AFTER ABORT"); - - // Check that no records were produced in read committed mode, but that - // the records are visible in read uncommitted mode. - assert_eq!( - count_records(&produce_topic, IsolationLevel::ReadCommitted)?, - 0, - ); - assert_eq!( - count_records(&produce_topic, IsolationLevel::ReadUncommitted)?, - 10, - ); - - // Check that the consumer's committed offset is still 10. - let committed = consumer.committed(Timeout::Never)?; - assert_eq!( - committed - .find_partition(&consume_topic, 0) - .unwrap() - .offset(), - Offset::Offset(10) - ); - - Ok(()) -} - -#[tokio::test] -async fn test_transaction_commit() -> Result<(), Box> { - let consume_topic = rand_test_topic("test_transaction_commit"); - let produce_topic = rand_test_topic("test_transaction_commit"); - - populate_topic(&consume_topic, 30, &value_fn, &key_fn, Some(0), None).await; - - // Create consumer and subscribe to `consume_topic`. - let consumer = create_consumer(None)?; - consumer.subscribe(&[&consume_topic])?; - consumer.poll(Timeout::Never).unwrap()?; - - // Commit the first 10 messages. - let mut commit_tpl = TopicPartitionList::new(); - commit_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(10))?; - consumer.commit(&commit_tpl, CommitMode::Sync).unwrap(); - - // Create a producer and start a transaction. - let producer = create_producer()?; - producer.init_transactions(Timeout::Never)?; - producer.begin_transaction()?; - - // Tie the commit of offset 20 to the transaction. - let cgm = consumer.group_metadata().unwrap(); - let mut txn_tpl = TopicPartitionList::new(); - txn_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(20))?; - producer.send_offsets_to_transaction(&txn_tpl, &cgm, Timeout::Never)?; - - // Produce 10 records in the transaction. - for _ in 0..10 { - producer - .send( - BaseRecord::to(&produce_topic) - .payload("A") - .key("B") - .partition(0), - ) - .unwrap(); - } - - // Commit the transaction. - producer.commit_transaction(Timeout::Never)?; - - // Check that 10 records were produced. - assert_eq!( - count_records(&produce_topic, IsolationLevel::ReadUncommitted)?, - 10, - ); - assert_eq!( - count_records(&produce_topic, IsolationLevel::ReadCommitted)?, - 10, - ); - - // Check that the consumer's committed offset is now 20. - let committed = consumer.committed(Timeout::Never)?; - assert_eq!( - committed - .find_partition(&consume_topic, 0) - .unwrap() - .offset(), - Offset::Offset(20) - ); - - Ok(()) -} diff --git a/tests/test_topic_partition_list.rs b/tests/topic_partition_lists.rs similarity index 92% rename from tests/test_topic_partition_list.rs rename to tests/topic_partition_lists.rs index 2f0ac3511..69279b89f 100644 --- a/tests/test_topic_partition_list.rs +++ b/tests/topic_partition_lists.rs @@ -1,4 +1,4 @@ -use rdkafka::{Offset, TopicPartitionList}; +use rdkafka::topic_partition_list::{Offset, TopicPartitionList}; /// Test topic partition list API and wrappers. diff --git a/tests/transactions.rs b/tests/transactions.rs new file mode 100644 index 000000000..c1856f0ee --- /dev/null +++ b/tests/transactions.rs @@ -0,0 +1,645 @@ +//! Test transactions using the base consumer and producer. + +use std::error::Error; +use std::time::Duration; + +use log::info; + +use rdkafka::admin::AdminOptions; +use rdkafka::config::ClientConfig; +use rdkafka::config::RDKafkaLogLevel; +use rdkafka::consumer::{BaseConsumer, CommitMode, Consumer}; +use rdkafka::error::{KafkaError, RDKafkaErrorCode}; +use rdkafka::message::Message; +use rdkafka::producer::{BaseProducer, BaseRecord, Producer}; +use rdkafka::topic_partition_list::{Offset, TopicPartitionList}; +use rdkafka::util::Timeout; + +use crate::utils::admin; +use crate::utils::containers::KafkaContext; +use crate::utils::logging::init_test_logger; +use crate::utils::producer; +use crate::utils::rand::*; +use crate::utils::*; + +mod utils; + +async fn create_consumer( + kafka_context: &KafkaContext, + config_overrides: Option<&[(&str, &str)]>, +) -> Result, KafkaError> { + init_test_logger(); + let group_id = rand_test_group(); + let mut config = ClientConfig::new(); + config + .set("group.id", &group_id) + .set("enable.partition.eof", "false") + .set("client.id", "rdkafka_integration_test_client") + .set("bootstrap.servers", &kafka_context.bootstrap_servers) + .set("session.timeout.ms", "6000") + .set("debug", "all") + .set("auto.offset.reset", "earliest"); + + if let Some(overrides) = config_overrides { + for (key, value) in overrides { + config.set(*key, *value); + } + } + + config.create_with_context(ConsumerTestContext { _n: 64 }) +} + +fn create_producer(kafka_context: &KafkaContext) -> Result { + init_test_logger(); + let mut config = ClientConfig::new(); + config + .set("bootstrap.servers", &kafka_context.bootstrap_servers) + .set("message.timeout.ms", "5000") + .set("enable.idempotence", "true") + .set("transactional.id", rand_test_transactional_id()) + .set("debug", "eos"); + config.set_log_level(RDKafkaLogLevel::Debug); + config.create() +} + +enum IsolationLevel { + ReadUncommitted, + ReadCommitted, +} + +async fn count_records( + kafka_context: &KafkaContext, + topic: &str, + iso: IsolationLevel, +) -> Result { + let isolation = match iso { + IsolationLevel::ReadUncommitted => "read_uncommitted", + IsolationLevel::ReadCommitted => "read_committed", + }; + + let consumer = create_consumer( + kafka_context, + Some(&[ + ("isolation.level", isolation), + ("enable.partition.eof", "true"), + ]), + ) + .await?; + + let mut tpl = TopicPartitionList::new(); + tpl.add_partition(topic, 0); + consumer.assign(&tpl)?; + let mut count = 0; + for message in consumer.iter() { + match message { + Ok(_) => count += 1, + Err(KafkaError::PartitionEOF(_)) => break, + Err(e) => return Err(e), + } + } + Ok(count) +} + +#[tokio::test] +async fn test_transaction_abort() -> Result<(), Box> { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let consume_topic = rand_test_topic("test_transaction_abort"); + let produce_topic = rand_test_topic("test_transaction_abort"); + + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&consume_topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create consume topic"); + admin_client + .create_topics( + &admin::new_topic_vec(&produce_topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create produce topic"); + + let future_producer = + producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + let _ = produce_messages_to_partition(&future_producer, &consume_topic, 30, 0).await; + + // Create consumer and subscribe to `consume_topic`. + let consumer = create_consumer(&kafka_context, None).await?; + consumer.subscribe(&[&consume_topic])?; + consumer.poll(Timeout::Never).unwrap()?; + + // Commit the first 10 messages. + let mut commit_tpl = TopicPartitionList::new(); + commit_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(10))?; + consumer.commit(&commit_tpl, CommitMode::Sync).unwrap(); + + // Create a producer and start a transaction. + let producer = create_producer(&kafka_context)?; + producer.init_transactions(Timeout::Never)?; + producer.begin_transaction()?; + + // Tie the commit of offset 20 to the transaction. + let cgm = consumer.group_metadata().unwrap(); + let mut txn_tpl = TopicPartitionList::new(); + txn_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(20))?; + producer.send_offsets_to_transaction(&txn_tpl, &cgm, Timeout::Never)?; + + // Produce 10 records in the transaction. + for _ in 0..10 { + producer + .send( + BaseRecord::to(&produce_topic) + .payload("A") + .key("B") + .partition(0), + ) + .unwrap(); + } + + // Abort the transaction, but only after producing all messages. + info!("BEFORE FLUSH"); + producer.flush(Duration::from_secs(20))?; + info!("AFTER FLUSH"); + producer.abort_transaction(Duration::from_secs(20))?; + info!("AFTER ABORT"); + + // Check that no records were produced in read committed mode, but that + // the records are visible in read uncommitted mode. + assert_eq!( + count_records( + &kafka_context, + &produce_topic, + IsolationLevel::ReadCommitted + ) + .await?, + 0, + ); + assert_eq!( + count_records( + &kafka_context, + &produce_topic, + IsolationLevel::ReadUncommitted + ) + .await?, + 10, + ); + + // Check that the consumer's committed offset is still 10. + let committed = consumer.committed(Timeout::Never)?; + assert_eq!( + committed + .find_partition(&consume_topic, 0) + .unwrap() + .offset(), + Offset::Offset(10) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_transaction_commit() -> Result<(), Box> { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let consume_topic = rand_test_topic("test_transaction_commit"); + let produce_topic = rand_test_topic("test_transaction_commit"); + + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("Could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&consume_topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create consume topic"); + admin_client + .create_topics( + &admin::new_topic_vec(&produce_topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create produce topic"); + + let future_producer = + producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("Could not create Future producer"); + let _ = produce_messages_to_partition(&future_producer, &consume_topic, 30, 0).await; + + // Create consumer and subscribe to `consume_topic`. + let consumer = create_consumer(&kafka_context, None).await?; + consumer.subscribe(&[&consume_topic])?; + consumer.poll(Timeout::Never).unwrap()?; + + // Commit the first 10 messages. + let mut commit_tpl = TopicPartitionList::new(); + commit_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(10))?; + consumer.commit(&commit_tpl, CommitMode::Sync).unwrap(); + + // Create a producer and start a transaction. + let producer = create_producer(&kafka_context)?; + producer.init_transactions(Timeout::Never)?; + producer.begin_transaction()?; + + // Tie the commit of offset 20 to the transaction. + let cgm = consumer.group_metadata().unwrap(); + let mut txn_tpl = TopicPartitionList::new(); + txn_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(20))?; + producer.send_offsets_to_transaction(&txn_tpl, &cgm, Timeout::Never)?; + + // Produce 10 records in the transaction. + for _ in 0..10 { + producer + .send( + BaseRecord::to(&produce_topic) + .payload("A") + .key("B") + .partition(0), + ) + .unwrap(); + } + + // Commit the transaction. + producer.commit_transaction(Timeout::Never)?; + + // Check that 10 records were produced. + assert_eq!( + count_records( + &kafka_context, + &produce_topic, + IsolationLevel::ReadUncommitted + ) + .await?, + 10, + ); + assert_eq!( + count_records( + &kafka_context, + &produce_topic, + IsolationLevel::ReadCommitted + ) + .await?, + 10, + ); + + // Check that the consumer's committed offset is now 20. + let committed = consumer.committed(Timeout::Never)?; + assert_eq!( + committed + .find_partition(&consume_topic, 0) + .unwrap() + .offset(), + Offset::Offset(20) + ); + + Ok(()) +} + +fn create_producer_with_txn_id( + kafka_context: &KafkaContext, + transactional_id: &str, +) -> Result { + let mut config = ClientConfig::new(); + config + .set("bootstrap.servers", &kafka_context.bootstrap_servers) + .set("message.timeout.ms", "5000") + .set("enable.idempotence", "true") + .set("transactional.id", transactional_id); + config.create() +} + +// When two producers share a `transactional.id`, the broker fences the older +// epoch on the second producer's `init_transactions`. The fenced producer's +// next transactional API call must surface that fact rather than silently +// committing. This test sets up two producers with the same transactional +// id, drives the second through `init_transactions` (which fences the +// first), and asserts the first's `commit_transaction` returns a +// `KafkaError::Transaction` whose underlying code is one of the librdkafka +// fencing codes. A binding regression that hid the fencing error or +// pretended the commit succeeded would surface here. +#[tokio::test] +async fn test_transaction_producer_fenced_by_epoch() -> Result<(), Box> { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic = rand_test_topic("test_txn_fencing"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let txn_id = rand_test_transactional_id(); + + let first = create_producer_with_txn_id(&kafka_context, &txn_id)?; + first.init_transactions(Timeout::Never)?; + first.begin_transaction()?; + first + .send( + BaseRecord::to(&topic) + .payload("first-pre-fence") + .key("k") + .partition(0), + ) + .map_err(|(e, _)| e)?; + first.flush(Duration::from_secs(20))?; + + let second = create_producer_with_txn_id(&kafka_context, &txn_id)?; + second.init_transactions(Timeout::Never)?; + drop(second); + + let result = first.commit_transaction(Duration::from_secs(20)); + match result { + Ok(()) => panic!("commit_transaction unexpectedly succeeded after the producer was fenced"), + Err(KafkaError::Transaction(rd_err)) => { + let code = rd_err.code(); + assert!( + matches!( + code, + RDKafkaErrorCode::Fenced + | RDKafkaErrorCode::InvalidProducerEpoch + | RDKafkaErrorCode::ProducerFenced + ), + "expected a producer-fencing error code, got {:?} ({})", + code, + rd_err.string(), + ); + } + Err(other) => panic!("unexpected error variant: {:?}", other), + } + + Ok(()) +} + +// `test_transaction_abort` and `test_transaction_commit` each cover a single +// producer running a single transaction, but they never interleave committed +// and aborted records on the same topic-partition. This test mixes the two: +// one transactional producer commits 7 records, a second transactional +// producer (different `transactional.id`) aborts 5 records on the same topic. +// A `read_committed` consumer must observe exactly the 7 committed payloads +// (the broker writes a control marker that the consumer-side filter must +// honour); a `read_uncommitted` consumer must observe all 12 payloads. A +// binding regression on the `isolation.level` plumbing, or a librdkafka +// filter break, would show up as the wrong total or the wrong payload set. +#[tokio::test] +async fn test_transaction_isolation_level_filters_aborted() -> Result<(), Box> { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let topic = rand_test_topic("test_txn_isolation_filter"); + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create topic"); + + let committed_producer = create_producer(&kafka_context)?; + committed_producer.init_transactions(Timeout::Never)?; + committed_producer.begin_transaction()?; + let committed_payloads: Vec = (0..7).map(|i| format!("committed-{}", i)).collect(); + for payload in &committed_payloads { + committed_producer + .send( + BaseRecord::to(&topic) + .payload(payload.as_str()) + .key("k") + .partition(0), + ) + .map_err(|(e, _)| e)?; + } + committed_producer.flush(Duration::from_secs(20))?; + committed_producer.commit_transaction(Duration::from_secs(20))?; + + let aborted_producer = create_producer(&kafka_context)?; + aborted_producer.init_transactions(Timeout::Never)?; + aborted_producer.begin_transaction()?; + let aborted_payloads: Vec = (0..5).map(|i| format!("aborted-{}", i)).collect(); + for payload in &aborted_payloads { + aborted_producer + .send( + BaseRecord::to(&topic) + .payload(payload.as_str()) + .key("k") + .partition(0), + ) + .map_err(|(e, _)| e)?; + } + aborted_producer.flush(Duration::from_secs(20))?; + aborted_producer.abort_transaction(Duration::from_secs(20))?; + + let collect_payloads = |iso: IsolationLevel| { + let iso_str = match iso { + IsolationLevel::ReadCommitted => "read_committed", + IsolationLevel::ReadUncommitted => "read_uncommitted", + }; + let kafka_context = kafka_context.clone(); + let topic = topic.clone(); + async move { + let consumer = create_consumer( + &kafka_context, + Some(&[ + ("isolation.level", iso_str), + ("enable.partition.eof", "true"), + ]), + ) + .await?; + let mut tpl = TopicPartitionList::new(); + tpl.add_partition(&topic, 0); + consumer.assign(&tpl)?; + let mut payloads = Vec::new(); + for message in consumer.iter() { + match message { + Ok(m) => payloads.push(m.payload_view::().unwrap().unwrap().to_string()), + Err(KafkaError::PartitionEOF(_)) => break, + Err(e) => return Err(e), + } + } + Ok::<_, KafkaError>(payloads) + } + }; + + let committed_view = collect_payloads(IsolationLevel::ReadCommitted).await?; + assert_eq!( + committed_view, committed_payloads, + "read_committed should see only the committed payloads in order" + ); + + let uncommitted_view = collect_payloads(IsolationLevel::ReadUncommitted).await?; + let mut expected_uncommitted = committed_payloads.clone(); + expected_uncommitted.extend(aborted_payloads.iter().cloned()); + assert_eq!( + uncommitted_view, expected_uncommitted, + "read_uncommitted should see committed payloads followed by aborted payloads" + ); + + Ok(()) +} + +// `test_transaction_commit` already calls `send_offsets_to_transaction`, but +// it produces a fixed "A" payload regardless of what was consumed and only +// looks at offsets at the end. This test does a full consume-transform-produce +// loop in two transactions: each transaction reads a batch from the input +// topic, transforms each payload, produces the transformed payload to the +// output topic, ties the consumer's offset advance to the transaction with +// `send_offsets_to_transaction`, and commits. The assertion shape catches a +// binding regression where the transformed payloads diverge from the input or +// where `send_offsets_to_transaction` fails to advance the consumer-side +// committed offset in step with the transaction. +#[tokio::test] +async fn test_transaction_send_offsets_consume_transform_produce() -> Result<(), Box> { + init_test_logger(); + + let kafka_context = KafkaContext::shared() + .await + .expect("could not create kafka context"); + let consume_topic = rand_test_topic("test_txn_ctp_in"); + let produce_topic = rand_test_topic("test_txn_ctp_out"); + + let admin_client = admin::create_admin_client(&kafka_context.bootstrap_servers) + .await + .expect("could not create admin client"); + admin_client + .create_topics( + &admin::new_topic_vec(&consume_topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create consume topic"); + admin_client + .create_topics( + &admin::new_topic_vec(&produce_topic, Some(1)), + &AdminOptions::default(), + ) + .await + .expect("could not create produce topic"); + + let future_producer = + producer::future_producer::create_producer(&kafka_context.bootstrap_servers) + .await + .expect("could not create future producer"); + const BATCHES: usize = 2; + const PER_BATCH: usize = 5; + const TOTAL: usize = BATCHES * PER_BATCH; + let _ = produce_messages_to_partition(&future_producer, &consume_topic, TOTAL, 0).await; + + let consumer = create_consumer(&kafka_context, None).await?; + consumer.subscribe(&[&consume_topic])?; + + let producer = create_producer(&kafka_context)?; + producer.init_transactions(Timeout::Never)?; + + let mut expected_outputs: Vec = Vec::with_capacity(TOTAL); + let mut consumed = 0usize; + for batch in 0..BATCHES { + producer.begin_transaction()?; + + let mut batch_payloads: Vec = Vec::with_capacity(PER_BATCH); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while batch_payloads.len() < PER_BATCH { + if std::time::Instant::now() > deadline { + panic!( + "consumer poll timed out in batch {} after {} messages", + batch, + batch_payloads.len() + ); + } + let Some(message) = consumer.poll(Duration::from_secs(2)) else { + continue; + }; + let message = message?; + let payload = message.payload_view::().unwrap().unwrap().to_string(); + batch_payloads.push(payload); + consumed += 1; + } + + for payload in &batch_payloads { + let transformed = format!("transformed:{}", payload); + expected_outputs.push(transformed.clone()); + producer + .send( + BaseRecord::to(&produce_topic) + .payload(&transformed) + .key("k") + .partition(0), + ) + .map_err(|(e, _)| e)?; + } + + let cgm = consumer.group_metadata().unwrap(); + let mut txn_tpl = TopicPartitionList::new(); + txn_tpl.add_partition_offset(&consume_topic, 0, Offset::Offset(consumed as i64))?; + producer.send_offsets_to_transaction(&txn_tpl, &cgm, Timeout::Never)?; + + producer.flush(Duration::from_secs(20))?; + producer.commit_transaction(Duration::from_secs(20))?; + } + + let committed = consumer.committed(Timeout::Never)?; + assert_eq!( + committed + .find_partition(&consume_topic, 0) + .unwrap() + .offset(), + Offset::Offset(TOTAL as i64), + "consumer-side committed offset must equal the number of consumed messages", + ); + + let output_consumer = create_consumer( + &kafka_context, + Some(&[ + ("isolation.level", "read_committed"), + ("enable.partition.eof", "true"), + ]), + ) + .await?; + let mut tpl = TopicPartitionList::new(); + tpl.add_partition(&produce_topic, 0); + output_consumer.assign(&tpl)?; + let mut output_payloads = Vec::with_capacity(TOTAL); + for message in output_consumer.iter() { + match message { + Ok(m) => { + let payload = m.payload_view::().unwrap().unwrap().to_string(); + output_payloads.push(payload); + } + Err(KafkaError::PartitionEOF(_)) => break, + Err(e) => return Err(e.into()), + } + } + assert_eq!( + output_payloads, expected_outputs, + "output topic payloads must match the transformed inputs in order", + ); + + Ok(()) +} diff --git a/tests/utils.rs b/tests/utils.rs deleted file mode 100644 index 10ab34cf5..000000000 --- a/tests/utils.rs +++ /dev/null @@ -1,225 +0,0 @@ -#![allow(dead_code)] - -use std::collections::HashMap; -use std::env::{self, VarError}; -use std::sync::Once; -use std::time::Duration; - -use rand::distr::{Alphanumeric, SampleString}; -use regex::Regex; - -use rdkafka::admin::{AdminClient, AdminOptions, NewTopic, TopicReplication}; -use rdkafka::client::ClientContext; -use rdkafka::config::ClientConfig; -use rdkafka::consumer::ConsumerContext; -use rdkafka::error::KafkaResult; -use rdkafka::message::ToBytes; -use rdkafka::producer::{FutureProducer, FutureRecord}; -use rdkafka::statistics::Statistics; -use rdkafka::TopicPartitionList; - -pub fn rand_test_topic(test_name: &str) -> String { - let id = Alphanumeric.sample_string(&mut rand::rng(), 10); - format!("__{}_{}", test_name, id) -} - -pub fn rand_test_group() -> String { - let id = Alphanumeric.sample_string(&mut rand::rng(), 10); - format!("__test_{}", id) -} - -pub fn rand_test_transactional_id() -> String { - let id = Alphanumeric.sample_string(&mut rand::rng(), 10); - format!("__test_{}", id) -} - -pub fn get_bootstrap_server() -> String { - env::var("KAFKA_HOST").unwrap_or_else(|_| "localhost:9092".to_owned()) -} - -pub fn get_broker_version() -> KafkaVersion { - // librdkafka doesn't expose this directly, sadly. - match env::var("KAFKA_VERSION") { - Ok(v) => { - let regex = Regex::new(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?$").unwrap(); - match regex.captures(&v) { - Some(captures) => { - let extract = |i| { - captures - .get(i) - .map(|m| m.as_str().parse().unwrap()) - .unwrap_or(0) - }; - KafkaVersion(extract(1), extract(2), extract(3), extract(4)) - } - None => panic!("KAFKA_VERSION env var was not in expected [n[.n[.n[.n]]]] format"), - } - } - Err(VarError::NotUnicode(_)) => { - panic!("KAFKA_VERSION env var contained non-unicode characters") - } - // If the environment variable is unset, assume we're running the latest version. - Err(VarError::NotPresent) => KafkaVersion(u32::MAX, u32::MAX, u32::MAX, u32::MAX), - } -} - -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] -pub struct KafkaVersion(pub u32, pub u32, pub u32, pub u32); - -pub struct ProducerTestContext { - _some_data: i64, // Add some data so that valgrind can check proper allocation -} - -impl ClientContext for ProducerTestContext { - fn stats(&self, _: Statistics) {} // Don't print stats -} - -pub async fn create_topic(name: &str, partitions: i32) { - let client: AdminClient<_> = consumer_config("create_topic", None).create().unwrap(); - client - .create_topics( - &[NewTopic::new(name, partitions, TopicReplication::Fixed(1))], - &AdminOptions::new(), - ) - .await - .unwrap(); -} - -/// Produce the specified count of messages to the topic and partition specified. A map -/// of (partition, offset) -> message id will be returned. It panics if any error is encountered -/// while populating the topic. -pub async fn populate_topic( - topic_name: &str, - count: i32, - value_fn: &P, - key_fn: &K, - partition: Option, - timestamp: Option, -) -> HashMap<(i32, i64), i32> -where - P: Fn(i32) -> J, - K: Fn(i32) -> Q, - J: ToBytes, - Q: ToBytes, -{ - let prod_context = ProducerTestContext { _some_data: 1234 }; - - // Produce some messages - let producer = &ClientConfig::new() - .set("bootstrap.servers", get_bootstrap_server().as_str()) - .set("statistics.interval.ms", "500") - .set("debug", "all") - .set("message.timeout.ms", "30000") - .create_with_context::>(prod_context) - .expect("Producer creation error"); - - let futures = (0..count) - .map(|id| { - let future = async move { - producer - .send( - FutureRecord { - topic: topic_name, - payload: Some(&value_fn(id)), - key: Some(&key_fn(id)), - partition, - timestamp, - headers: None, - }, - Duration::from_secs(1), - ) - .await - }; - (id, future) - }) - .collect::>(); - - let mut message_map = HashMap::new(); - for (id, future) in futures { - match future.await { - Ok(delivered) => message_map.insert((delivered.partition, delivered.offset), id), - Err((kafka_error, _message)) => panic!("Delivery failed: {}", kafka_error), - }; - } - - message_map -} - -pub fn value_fn(id: i32) -> String { - format!("Message {}", id) -} - -pub fn key_fn(id: i32) -> String { - format!("Key {}", id) -} - -pub struct ConsumerTestContext { - pub _n: i64, // Add data for memory access validation -} - -impl ClientContext for ConsumerTestContext { - // Access stats - fn stats(&self, stats: Statistics) { - let stats_str = format!("{:?}", stats); - println!("Stats received: {} bytes", stats_str.len()); - } -} - -impl ConsumerContext for ConsumerTestContext { - fn commit_callback(&self, result: KafkaResult<()>, _offsets: &TopicPartitionList) { - println!("Committing offsets: {:?}", result); - } -} - -pub fn consumer_config( - group_id: &str, - config_overrides: Option>, -) -> ClientConfig { - let mut config = ClientConfig::new(); - - config.set("group.id", group_id); - config.set("client.id", "rdkafka_integration_test_client"); - config.set("bootstrap.servers", get_bootstrap_server().as_str()); - config.set("enable.partition.eof", "false"); - config.set("session.timeout.ms", "6000"); - config.set("enable.auto.commit", "false"); - config.set("debug", "all"); - config.set("auto.offset.reset", "earliest"); - - if let Some(overrides) = config_overrides { - for (key, value) in overrides { - config.set(key, value); - } - } - - config -} - -static INIT: Once = Once::new(); - -pub fn configure_logging_for_tests() { - INIT.call_once(|| { - env_logger::try_init().expect("Failed to initialize env_logger"); - }); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_populate_topic() { - let topic_name = rand_test_topic("test_populate_topic"); - let message_map = populate_topic(&topic_name, 100, &value_fn, &key_fn, Some(0), None).await; - - let total_messages = message_map - .iter() - .filter(|&(&(partition, _), _)| partition == 0) - .count(); - assert_eq!(total_messages, 100); - - let mut ids = message_map.values().copied().collect::>(); - ids.sort(); - assert_eq!(ids, (0..100).collect::>()); - } -} diff --git a/tests/utils/admin.rs b/tests/utils/admin.rs new file mode 100644 index 000000000..e1fad1a4c --- /dev/null +++ b/tests/utils/admin.rs @@ -0,0 +1,38 @@ +use anyhow::{bail, Context}; +use rdkafka::admin::{AdminClient, AdminOptions, NewTopic, TopicReplication}; +use rdkafka::client::DefaultClientContext; +use rdkafka::config::FromClientConfig; +use rdkafka::ClientConfig; + +pub async fn create_admin_client( + bootstrap_servers: &str, +) -> anyhow::Result> { + let mut admin_client_config = ClientConfig::default(); + admin_client_config.set("bootstrap.servers", bootstrap_servers); + AdminClient::from_config(&admin_client_config).context("error creating admin client") +} + +pub async fn create_topic( + admin_client: &AdminClient, + topic_name: &'_ str, +) -> anyhow::Result { + let topic_results = admin_client + .create_topics(&new_topic_vec(topic_name, None), &AdminOptions::default()) + .await + .context("error creating topics")?; + for topic_result in topic_results { + if let Err(err) = topic_result { + bail!("failed to create topic: {:?}", err); + }; + } + Ok(topic_name.to_string()) +} + +pub fn new_topic_vec(topic_name: &'_ str, num_partitions: Option) -> Vec> { + let new_topic = NewTopic::new( + topic_name, + num_partitions.unwrap_or(1), + TopicReplication::Fixed(1), + ); + vec![new_topic] +} diff --git a/tests/utils/consumer/mod.rs b/tests/utils/consumer/mod.rs new file mode 100644 index 000000000..e2afbb0a3 --- /dev/null +++ b/tests/utils/consumer/mod.rs @@ -0,0 +1,161 @@ +pub mod stream_consumer; + +use crate::utils::rand::rand_test_group; +use crate::utils::ConsumerTestContext; +use anyhow::{bail, Context}; +use backon::{BlockingRetryable, ExponentialBuilder}; +use rdkafka::config::FromClientConfig; +use rdkafka::consumer::{BaseConsumer, CommitMode, Consumer}; +use rdkafka::message::BorrowedMessage; +use rdkafka::metadata::Metadata; +use rdkafka::{ClientConfig, TopicPartitionList}; +use std::time::Duration; + +pub async fn create_subscribed_base_consumer( + bootstrap_servers: &str, + consumer_group_option: Option<&str>, + test_topic: &str, +) -> anyhow::Result { + let unsubscribed_base_consumer = + create_unsubscribed_base_consumer(bootstrap_servers, consumer_group_option).await?; + unsubscribed_base_consumer + .subscribe(&[test_topic]) + .context("Failed to subscribe to topic")?; + Ok(unsubscribed_base_consumer) +} + +pub async fn create_unsubscribed_base_consumer( + bootstrap_servers: &str, + consumer_group_option: Option<&str>, +) -> anyhow::Result { + let consumer_group_name = match consumer_group_option { + Some(consumer_group_name) => consumer_group_name, + None => &rand_test_group(), + }; + let mut consumer_client_config = ClientConfig::default(); + consumer_client_config.set("group.id", consumer_group_name); + consumer_client_config.set("client.id", "rdkafka_integration_test_client"); + consumer_client_config.set("bootstrap.servers", bootstrap_servers); + consumer_client_config.set("enable.partition.eof", "false"); + consumer_client_config.set("session.timeout.ms", "6000"); + consumer_client_config.set("enable.auto.commit", "false"); + consumer_client_config.set("debug", "all"); + consumer_client_config.set("auto.offset.reset", "earliest"); + + BaseConsumer::from_config(&consumer_client_config).context("Failed to create consumer") +} + +pub fn create_base_consumer( + bootstrap_servers: &str, + consumer_group: &str, + config_overrides: Option<&[(&str, &str)]>, +) -> anyhow::Result> { + let mut consumer_client_config = ClientConfig::default(); + consumer_client_config.set("group.id", consumer_group); + consumer_client_config.set("client.id", "rdkafka_integration_test_client"); + consumer_client_config.set("bootstrap.servers", bootstrap_servers); + consumer_client_config.set("enable.partition.eof", "false"); + consumer_client_config.set("session.timeout.ms", "6000"); + consumer_client_config.set("enable.auto.commit", "false"); + consumer_client_config.set("debug", "all"); + consumer_client_config.set("auto.offset.reset", "earliest"); + + if let Some(overrides) = config_overrides { + for (key, value) in overrides { + consumer_client_config.set(*key, *value); + } + } + + consumer_client_config + .create_with_context::>( + ConsumerTestContext { _n: 64 }, + ) + .context("Failed to create consumer with context") +} + +pub async fn poll_x_times_for_messages( + consumer: &BaseConsumer, + times_to_poll: i32, +) -> anyhow::Result>> { + let mut borrowed_messages: Vec = Vec::new(); + + for _ in 0..times_to_poll { + let Some(next_message_result) = consumer.poll(Duration::from_secs(2)) else { + continue; + }; + + let Ok(borrowed_next_message) = next_message_result else { + panic!( + "could not get next message from based_consumer: {}", + next_message_result.unwrap_err() + ); + }; + borrowed_messages.push(borrowed_next_message); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + Ok(borrowed_messages) +} + +pub fn fetch_consumer_metadata(consumer: &BaseConsumer, topic: &str) -> anyhow::Result { + let timeout = Some(Duration::from_secs(1)); + + (|| { + let metadata = consumer + .fetch_metadata(Some(topic), timeout) + .context("Failed to fetch metadata")?; + if metadata.topics().is_empty() { + bail!("metadata fetch returned no topics".to_string()) + } + let topic = &metadata.topics()[0]; + if topic.partitions().is_empty() { + bail!("metadata fetch returned a topic with no partitions".to_string()) + } + Ok(metadata) + }) + .retry(ExponentialBuilder::default().with_max_delay(Duration::from_secs(5))) + .call() +} + +pub fn verify_topic_deleted(consumer: &BaseConsumer, topic: &str) -> anyhow::Result<()> { + let timeout = Some(Duration::from_secs(1)); + + (|| { + // Asking about the topic specifically will recreate it (under the + // default Kafka configuration, at least) so we have to ask for the list + // of all topics and search through it. + let metadata = consumer + .fetch_metadata(None, timeout) + .context("Failed to fetch metadata")?; + if metadata.topics().iter().any(|t| t.name() == topic) { + bail!(format!("topic {} still exists", topic)) + } + Ok(()) + }) + .retry(ExponentialBuilder::default().with_max_delay(Duration::from_secs(5))) + .call() +} + +pub async fn create_consumer_group_on_topic( + consumer_client: &BaseConsumer, + topic_name: &str, +) -> anyhow::Result<()> { + let topic_partition_list = { + let mut lst = TopicPartitionList::new(); + lst.add_partition(topic_name, 0); + lst + }; + consumer_client + .assign(&topic_partition_list) + .context("assign topic partition list failed")?; + consumer_client + .fetch_metadata(None, Duration::from_secs(3)) + .context("unable to fetch metadata")?; + (|| consumer_client.store_offset(topic_name, 0, -1)) + .retry(ExponentialBuilder::default().with_max_delay(Duration::from_secs(5))) + .call() + .context("store offset failed")?; + consumer_client + .commit_consumer_state(CommitMode::Sync) + .context("commit the consumer state failed") +} diff --git a/tests/utils/consumer/stream_consumer.rs b/tests/utils/consumer/stream_consumer.rs new file mode 100644 index 000000000..9e94d3a96 --- /dev/null +++ b/tests/utils/consumer/stream_consumer.rs @@ -0,0 +1,40 @@ +use anyhow::Context; +use rdkafka::config::FromClientConfig; +use rdkafka::consumer::StreamConsumer; +use rdkafka::ClientConfig; + +pub async fn create_stream_consumer( + bootstrap_server: &str, + consumer_group_option: Option<&str>, +) -> anyhow::Result { + let mut client_config = ClientConfig::default(); + client_config.set("bootstrap.servers", bootstrap_server); + client_config.set("auto.offset.reset", "earliest"); + if let Some(group) = consumer_group_option { + client_config.set("group.id", group); + } + + let stream_consumer = + StreamConsumer::from_config(&client_config).context("failed to create stream consumer")?; + Ok(stream_consumer) +} + +pub async fn create_stream_consumer_with_options( + bootstrap_server: &str, + consumer_group: &str, + options: &[(&str, &str)], +) -> anyhow::Result { + let mut client_config = ClientConfig::default(); + client_config.set("bootstrap.servers", bootstrap_server); + client_config.set("group.id", consumer_group); + client_config.set("auto.offset.reset", "earliest"); + client_config.set("enable.auto.commit", "false"); + + for (key, value) in options { + client_config.set(*key, *value); + } + + let stream_consumer = + StreamConsumer::from_config(&client_config).context("failed to create stream consumer")?; + Ok(stream_consumer) +} diff --git a/tests/utils/containers.rs b/tests/utils/containers.rs new file mode 100644 index 000000000..76e432278 --- /dev/null +++ b/tests/utils/containers.rs @@ -0,0 +1,116 @@ +use anyhow::Context; +use std::fmt::Debug; +use std::sync::Arc; +use testcontainers_modules::kafka::apache::Kafka; +use testcontainers_modules::testcontainers::core::ContainerPort; +use testcontainers_modules::testcontainers::runners::AsyncRunner; +use testcontainers_modules::testcontainers::{ContainerAsync, ImageExt}; +use tokio::sync::OnceCell; + +type KafkaImage = testcontainers_modules::testcontainers::core::ContainerRequest; + +pub struct KafkaContext { + kafka_node: ContainerAsync, + pub bootstrap_servers: String, + pub version: String, +} + +impl Debug for KafkaContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KafkaContext").finish() + } +} + +impl KafkaContext { + pub async fn shared() -> anyhow::Result> { + static INSTANCE: OnceCell> = OnceCell::const_new(); + + INSTANCE + .get_or_try_init(init) + .await + .context("Failed to initialize Kafka shared instance") + .map(Arc::clone) + } + + pub async fn std_out(&self) -> anyhow::Result { + let std_out_byte_vec = self + .kafka_node + .stdout_to_vec() + .await + .context("Failed to get stdout")?; + Ok(String::from_utf8(std_out_byte_vec)?) + } + + pub async fn std_err(&self) -> anyhow::Result { + let std_err_byte_vec = self + .kafka_node + .stderr_to_vec() + .await + .context("Failed to get stderr")?; + Ok(String::from_utf8(std_err_byte_vec)?) + } +} + +async fn init() -> anyhow::Result> { + let kafka_tag = resolve_kafka_image_tag(); + let kafka_container: KafkaImage = Kafka::default() + // The kafka-native image (the crate default) doesn't publish 3.7.x + // tags, so use the JVM image which covers the full CI matrix range. + .with_jvm_image() + .with_tag(&kafka_tag) + // The single-broker testcontainers image needs replication and ISR + // overrides; otherwise transactions hang because __transaction_state + // can't reach its default replication factor of 3. + .with_env_var("KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR", "1") + .with_env_var("KAFKA_TRANSACTION_STATE_LOG_MIN_ISR", "1"); + + let kafka_node = kafka_container + .start() + .await + .context("Failed to start Kafka")?; + let kafka_host = kafka_node + .get_host() + .await + .context("Failed to get Kafka host")?; + let kafka_port = kafka_node + .get_host_port_ipv4(ContainerPort::Tcp(9092)) + .await?; + + Ok::, anyhow::Error>(Arc::new(KafkaContext { + kafka_node, + bootstrap_servers: format!("{}:{}", kafka_host, kafka_port), + version: kafka_tag, + })) +} + +// Map the CI matrix's short KAFKA_VERSION (e.g. "3.7") onto a specific +// apache/kafka tag so each matrix row actually exercises a different broker. +// Without this, the crate's hard-coded default tag would make every row run +// the same image. Full tag strings (e.g. "3.9.1") are passed through. +fn resolve_kafka_image_tag() -> String { + let raw = std::env::var("KAFKA_VERSION").unwrap_or_else(|_| "4.0".into()); + match raw.as_str() { + "3.7" => "3.7.2".into(), + "3.8" => "3.8.1".into(), + "3.9" => "3.9.2".into(), + "4.0" => "4.0.2".into(), + _ => raw, + } +} + +#[tokio::test] +pub async fn test_kafka_context_works() { + let kafka_context_result = KafkaContext::shared().await; + let Ok(kafka_context) = kafka_context_result else { + panic!( + "Failed to get Kafka context: {}", + kafka_context_result.unwrap_err() + ); + }; + + assert_ne!( + kafka_context.bootstrap_servers.len(), + 0, + "Bootstrap servers empty" + ); +} diff --git a/tests/utils/logging.rs b/tests/utils/logging.rs new file mode 100644 index 000000000..401db65a5 --- /dev/null +++ b/tests/utils/logging.rs @@ -0,0 +1,9 @@ +use std::sync::Once; + +static INIT: Once = Once::new(); + +pub fn init_test_logger() { + INIT.call_once(|| { + env_logger::try_init().expect("Failed to initialize env_logger"); + }); +} diff --git a/tests/utils/mod.rs b/tests/utils/mod.rs new file mode 100644 index 000000000..3b466afda --- /dev/null +++ b/tests/utils/mod.rs @@ -0,0 +1,163 @@ +#![allow(dead_code)] + +pub mod admin; +pub mod consumer; +pub mod containers; +pub mod logging; +pub mod producer; +pub mod rand; +pub mod topics; + +use std::collections::HashMap; + +use regex::Regex; + +use crate::utils::containers::KafkaContext; +use rdkafka::client::ClientContext; +use rdkafka::config::ClientConfig; +use rdkafka::consumer::ConsumerContext; +use rdkafka::error::KafkaResult; +use rdkafka::producer::{FutureProducer, FutureRecord}; +use rdkafka::statistics::Statistics; +use rdkafka::TopicPartitionList; + +pub const BROKER_ID: i32 = 1; + +pub fn get_broker_version(kafka_context: &KafkaContext) -> KafkaVersion { + let regex = Regex::new(r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:\.(\d+))?$").unwrap(); + match regex.captures(&kafka_context.version) { + Some(captures) => { + let extract = |i| { + captures + .get(i) + .map(|m| m.as_str().parse().unwrap()) + .unwrap_or(0) + }; + KafkaVersion(extract(1), extract(2), extract(3), extract(4)) + } + None => panic!("KAFKA_VERSION env var was not in expected [n[.n[.n[.n]]]] format"), + } +} + +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct KafkaVersion(pub u32, pub u32, pub u32, pub u32); + +pub async fn produce_messages_with_timestamp( + producer: &FutureProducer, + topic_name: &str, + count: usize, + partition: i32, + timestamp: i64, +) -> HashMap<(i32, i64), i32> { + produce_messages( + producer, + topic_name, + count, + Some(partition), + Some(timestamp), + ) + .await +} + +pub async fn produce_messages_to_partition( + producer: &FutureProducer, + topic_name: &str, + count: usize, + partition: i32, +) -> HashMap<(i32, i64), i32> { + produce_messages(producer, topic_name, count, Some(partition), None).await +} + +pub async fn produce_messages( + producer: &FutureProducer, + topic_name: &str, + count: usize, + partition: Option, + timestamp: Option, +) -> HashMap<(i32, i64), i32> { + let mut inflight = Vec::with_capacity(count); + + for idx in 0..count { + let id = idx as i32; + let payload = value_fn(id); + let key = key_fn(id); + let mut record = FutureRecord::to(topic_name).payload(&payload).key(&key); + if let Some(partition) = partition { + record = record.partition(partition); + } + if let Some(timestamp) = timestamp { + record = record.timestamp(timestamp); + } + let delivery_future = producer + .send_result(record) + .expect("failed to enqueue message"); + inflight.push((id, payload, key, delivery_future)); + } + + let mut message_map = HashMap::new(); + + for (id, _payload, _key, delivery_future) in inflight { + match delivery_future + .await + .expect("producer unexpectedly dropped") + { + Ok(delivery) => { + message_map.insert((delivery.partition, delivery.offset), id); + } + Err((error, _message)) => panic!("Delivery failed: {}", error), + }; + } + + message_map +} + +pub fn value_fn(id: i32) -> String { + format!("Message {}", id) +} + +pub fn key_fn(id: i32) -> String { + format!("Key {}", id) +} + +pub struct ConsumerTestContext { + pub _n: i64, // Add data for memory access validation +} + +impl ClientContext for ConsumerTestContext { + // Access stats + fn stats(&self, stats: Statistics) { + let stats_str = format!("{:?}", stats); + println!("Stats received: {} bytes", stats_str.len()); + } +} + +impl ConsumerContext for ConsumerTestContext { + fn commit_callback(&self, result: KafkaResult<()>, _offsets: &TopicPartitionList) { + println!("Committing offsets: {:?}", result); + } +} + +pub fn consumer_config( + bootstrap_servers: &str, + group_id: &str, + config_overrides: Option>, +) -> ClientConfig { + let mut config = ClientConfig::new(); + + config.set("group.id", group_id); + config.set("client.id", "rdkafka_integration_test_client"); + config.set("bootstrap.servers", bootstrap_servers); + config.set("enable.partition.eof", "false"); + config.set("session.timeout.ms", "6000"); + config.set("enable.auto.commit", "false"); + config.set("debug", "all"); + config.set("auto.offset.reset", "earliest"); + + if let Some(overrides) = config_overrides { + for (key, value) in overrides { + config.set(key, value); + } + } + + config +} diff --git a/tests/utils/producer/base_producer.rs b/tests/utils/producer/base_producer.rs new file mode 100644 index 000000000..2b3af3890 --- /dev/null +++ b/tests/utils/producer/base_producer.rs @@ -0,0 +1,97 @@ +use anyhow::{bail, Context}; +use rdkafka::config::{FromClientConfig, FromClientConfigAndContext}; +use rdkafka::producer::{ + BaseProducer, BaseRecord, Partitioner, Producer, ProducerContext, ThreadedProducer, +}; +use rdkafka::util::Timeout; +use rdkafka::ClientConfig; +use std::time::Duration; + +pub async fn create_producer(bootstrap_servers: &str) -> anyhow::Result { + let mut producer_client_config = ClientConfig::default(); + producer_client_config.set("bootstrap.servers", bootstrap_servers); + let base_producer_result = create_base_producer(&producer_client_config); + let Ok(base_producer) = base_producer_result else { + panic!( + "could not create based_producer: {}", + base_producer_result.unwrap_err() + ); + }; + Ok(base_producer) +} + +pub fn create_base_producer(config: &ClientConfig) -> anyhow::Result { + let base_producer_result = BaseProducer::from_config(config); + let Ok(base_producer) = base_producer_result else { + anyhow::bail!( + "error creating base producer: {}", + base_producer_result.unwrap_err() + ) + }; + Ok(base_producer) +} + +pub fn create_base_producer_with_context( + bootstrap_servers: &str, + context: C, + config_overrides: &[(&str, &str)], +) -> anyhow::Result> +where + C: ProducerContext, + Part: Partitioner, +{ + let mut producer_client_config = ClientConfig::default(); + producer_client_config.set("bootstrap.servers", bootstrap_servers); + producer_client_config.set("message.timeout.ms", "5000"); + + for (key, value) in config_overrides { + producer_client_config.set(*key, *value); + } + + BaseProducer::from_config_and_context(&producer_client_config, context) + .context("error creating base producer with context") +} + +pub fn create_threaded_producer_with_context( + bootstrap_servers: &str, + context: C, + config_overrides: &[(&str, &str)], +) -> anyhow::Result> +where + C: ProducerContext, + Part: Partitioner + Send + Sync + 'static, +{ + let mut producer_client_config = ClientConfig::default(); + producer_client_config.set("bootstrap.servers", bootstrap_servers); + producer_client_config.set("message.timeout.ms", "5000"); + + for (key, value) in config_overrides { + producer_client_config.set(*key, *value); + } + + ThreadedProducer::from_config_and_context(&producer_client_config, context) + .context("error creating threaded producer with context") +} + +pub async fn send_record( + producer: &BaseProducer, + record: BaseRecord<'_, [u8; 4], str>, +) -> anyhow::Result<()> { + if let Err(err) = producer.send(record) { + bail!("could not produce record: {:?}", err); + } + if poll_and_flush(producer).is_err() { + bail!("could not poll and flush base producer") + }; + + Ok(()) +} + +pub fn poll_and_flush(base_producer: &BaseProducer) -> anyhow::Result<()> { + for _ in 0..5 { + base_producer.poll(Duration::from_millis(100)); + } + base_producer + .flush(Timeout::After(Duration::from_secs(10))) + .context("flush failed") +} diff --git a/tests/utils/producer/future_producer.rs b/tests/utils/producer/future_producer.rs new file mode 100644 index 000000000..712ddb94d --- /dev/null +++ b/tests/utils/producer/future_producer.rs @@ -0,0 +1,22 @@ +use anyhow::Context; +use rdkafka::config::FromClientConfig; +use rdkafka::producer::FutureProducer; +use rdkafka::ClientConfig; + +pub async fn create_producer(bootstrap_servers: &str) -> anyhow::Result { + create_producer_with_overrides(bootstrap_servers, &[]).await +} + +pub async fn create_producer_with_overrides( + bootstrap_servers: &str, + config_overrides: &[(&str, &str)], +) -> anyhow::Result { + let mut producer_client_config = ClientConfig::default(); + producer_client_config.set("bootstrap.servers", bootstrap_servers); + for (key, value) in config_overrides { + producer_client_config.set(*key, *value); + } + let future_producer = FutureProducer::from_config(&producer_client_config) + .context("couldn't create producer client")?; + Ok(future_producer) +} diff --git a/tests/utils/producer/mod.rs b/tests/utils/producer/mod.rs new file mode 100644 index 000000000..f09b39c97 --- /dev/null +++ b/tests/utils/producer/mod.rs @@ -0,0 +1,2 @@ +pub mod base_producer; +pub mod future_producer; diff --git a/tests/utils/rand.rs b/tests/utils/rand.rs new file mode 100644 index 000000000..fabe6e668 --- /dev/null +++ b/tests/utils/rand.rs @@ -0,0 +1,16 @@ +use rand::distr::{Alphanumeric, SampleString}; + +pub fn rand_test_topic(test_name: &str) -> String { + let id = Alphanumeric.sample_string(&mut rand::rng(), 10); + format!("{}_{}", test_name, id) +} + +pub fn rand_test_group() -> String { + let id = Alphanumeric.sample_string(&mut rand::rng(), 10); + format!("__test_{}", id) +} + +pub fn rand_test_transactional_id() -> String { + let id = Alphanumeric.sample_string(&mut rand::rng(), 10); + format!("__test_{}", id) +} diff --git a/tests/utils/topics.rs b/tests/utils/topics.rs new file mode 100644 index 000000000..d6664219a --- /dev/null +++ b/tests/utils/topics.rs @@ -0,0 +1,44 @@ +use rdkafka::producer::{FutureProducer, FutureRecord}; +use std::collections::HashMap; +use std::time::Duration; + +pub type PartitionOffset = (i32, i64); +pub type MessageId = usize; + +pub async fn populate_topic_using_future_producer( + producer: &FutureProducer, + topic_name: &str, + num_messages: usize, + partition: Option, +) -> anyhow::Result> { + let message_send_futures = (0..num_messages) + .map(|id| { + let future = async move { + producer + .send( + FutureRecord { + topic: topic_name, + payload: Some(&id.to_string()), + key: Some(&id.to_string()), + partition, + timestamp: None, + headers: None, + }, + Duration::from_secs(1), + ) + .await + }; + (id, future) + }) + .collect::>(); + + let mut message_map = HashMap::::new(); + for (id, future) in message_send_futures { + match future.await { + Ok(delivered) => message_map.insert((delivered.partition, delivered.offset), id), + Err((kafka_error, _message)) => panic!("Delivery failed: {}", kafka_error), + }; + } + + Ok(message_map) +}