This is an automated email from the ASF dual-hosted git repository.
hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git
The following commit(s) were added to refs/heads/master by this push:
new 1f18a8f08 feat(connectors): add S3 sink connector (#3103)
1f18a8f08 is described below
commit 1f18a8f08ebfd5baad1592b294f7fa1b027cd040
Author: Atharva Lade <[email protected]>
AuthorDate: Mon Jun 29 09:28:56 2026 -0500
feat(connectors): add S3 sink connector (#3103)
---
.github/workflows/_build_rust_artifacts.yml | 2 +-
.github/workflows/edge-release.yml | 1 +
Cargo.lock | 349 +++++++++--
Cargo.toml | 1 +
core/connectors/README.md | 1 +
core/connectors/sinks/README.md | 1 +
core/connectors/sinks/s3_sink/Cargo.toml | 52 ++
core/connectors/sinks/s3_sink/README.md | 164 +++++
core/connectors/sinks/s3_sink/config.toml | 50 ++
core/connectors/sinks/s3_sink/src/buffer.rs | 182 ++++++
core/connectors/sinks/s3_sink/src/client.rs | 169 ++++++
core/connectors/sinks/s3_sink/src/formatter.rs | 393 ++++++++++++
core/connectors/sinks/s3_sink/src/lib.rs | 498 ++++++++++++++++
core/connectors/sinks/s3_sink/src/path.rs | 245 ++++++++
core/connectors/sinks/s3_sink/src/sink.rs | 662 +++++++++++++++++++++
core/integration/Cargo.toml | 1 +
core/integration/tests/cli/common/keyring.rs | 1 +
core/integration/tests/cli/common/mod.rs | 1 +
core/integration/tests/connectors/fixtures/mod.rs | 2 +
.../tests/connectors/fixtures/s3/fixture.rs | 308 ++++++++++
.../tests/connectors/fixtures/s3/mod.rs | 20 +
core/integration/tests/connectors/mod.rs | 1 +
core/integration/tests/connectors/s3/mod.rs | 18 +
core/integration/tests/connectors/s3/s3_sink.rs | 196 ++++++
core/integration/tests/connectors/s3/sink.toml | 20 +
.../tests/connectors/s3/sink_rotation.toml | 20 +
26 files changed, 3317 insertions(+), 41 deletions(-)
diff --git a/.github/workflows/_build_rust_artifacts.yml
b/.github/workflows/_build_rust_artifacts.yml
index db6910e26..97e628376 100644
--- a/.github/workflows/_build_rust_artifacts.yml
+++ b/.github/workflows/_build_rust_artifacts.yml
@@ -46,7 +46,7 @@ on:
connector_plugins:
type: string
required: false
- default:
"iggy_connector_elasticsearch_sink,iggy_connector_elasticsearch_source,iggy_connector_iceberg_sink,iggy_connector_postgres_sink,iggy_connector_postgres_source,iggy_connector_quickwit_sink,iggy_connector_random_source,iggy_connector_stdout_sink"
+ default:
"iggy_connector_elasticsearch_sink,iggy_connector_elasticsearch_source,iggy_connector_iceberg_sink,iggy_connector_postgres_sink,iggy_connector_postgres_source,iggy_connector_quickwit_sink,iggy_connector_random_source,iggy_connector_s3_sink,iggy_connector_stdout_sink"
description: "Comma-separated list of connector plugin crates to build
as shared libraries"
outputs:
artifact_name:
diff --git a/.github/workflows/edge-release.yml
b/.github/workflows/edge-release.yml
index 7ca84fc5d..4aaf4b234 100644
--- a/.github/workflows/edge-release.yml
+++ b/.github/workflows/edge-release.yml
@@ -108,6 +108,7 @@ jobs:
- `iggy_connector_postgres_source`
- `iggy_connector_quickwit_sink`
- `iggy_connector_random_source`
+ - `iggy_connector_s3_sink`
- `iggy_connector_stdout_sink`
## Downloads
diff --git a/Cargo.lock b/Cargo.lock
index be96205a9..d1e1c73b1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1267,6 +1267,22 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "attohttpc"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
+dependencies = [
+ "base64",
+ "http 1.4.2",
+ "log",
+ "rustls",
+ "serde",
+ "serde_json",
+ "url",
+ "webpki-roots 1.0.8",
+]
+
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -1368,6 +1384,23 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "aws-creds"
+version = "0.39.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3b85155d265df828f84e53886ed9e427aed979dd8a39f5b8b2162c77e142d7"
+dependencies = [
+ "attohttpc",
+ "home",
+ "log",
+ "quick-xml 0.38.4",
+ "rust-ini",
+ "serde",
+ "thiserror 2.0.18",
+ "time",
+ "url",
+]
+
[[package]]
name = "aws-lc-rs"
version = "1.17.0"
@@ -1390,6 +1423,15 @@ dependencies = [
"fs_extra",
]
+[[package]]
+name = "aws-region"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "838b36c8dc927b6db1b6c6b8f5d05865f2213550b9e83bf92fa99ed6525472c0"
+dependencies = [
+ "thiserror 2.0.18",
+]
+
[[package]]
name = "aws-runtime"
version = "1.7.5"
@@ -1930,7 +1972,7 @@ dependencies = [
"rand 0.10.1",
"serde",
"serde_json",
- "sysinfo",
+ "sysinfo 0.39.5",
"terminal_size",
"tracing",
"uuid",
@@ -2558,6 +2600,15 @@ dependencies = [
"thiserror 2.0.18",
]
+[[package]]
+name = "castaway"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "cbc"
version = "0.1.2"
@@ -2660,7 +2711,7 @@ dependencies = [
"num-traits",
"serde",
"wasm-bindgen",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -2816,6 +2867,19 @@ dependencies = [
"unicode-width 0.2.2",
]
+[[package]]
+name = "compact_str"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "ryu",
+ "static_assertions",
+]
+
[[package]]
name = "compio"
version = "0.19.1"
@@ -5182,8 +5246,8 @@ dependencies = [
"libc",
"log",
"rustversion",
- "windows-link",
- "windows-result",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
]
[[package]]
@@ -6031,7 +6095,7 @@ checksum =
"617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
dependencies = [
"cfg-if",
"libc",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -6292,7 +6356,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
- "windows-core",
+ "windows-core 0.62.2",
]
[[package]]
@@ -6567,7 +6631,7 @@ dependencies = [
"rand 0.10.1",
"rayon",
"serde",
- "sysinfo",
+ "sysinfo 0.39.5",
"tokio",
"tracing",
"tracing-appender",
@@ -6677,7 +6741,7 @@ dependencies = [
"serde_with",
"serde_yaml_ng",
"strum 0.28.0",
- "sysinfo",
+ "sysinfo 0.39.5",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -7019,6 +7083,27 @@ dependencies = [
"uuid",
]
+[[package]]
+name = "iggy_connector_s3_sink"
+version = "0.4.0"
+dependencies = [
+ "async-trait",
+ "base64",
+ "byte-unit",
+ "chrono",
+ "dashmap",
+ "humantime",
+ "iggy_common",
+ "iggy_connector_sdk",
+ "rust-s3",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "simd-json",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "iggy_connector_sdk"
version = "0.3.1-edge.1"
@@ -7285,6 +7370,7 @@ dependencies = [
"reqwest-middleware",
"reqwest-retry",
"rmcp",
+ "rust-s3",
"secrecy",
"serde",
"serde_json",
@@ -7292,7 +7378,7 @@ dependencies = [
"server",
"socket2 0.6.4",
"sqlx",
- "sysinfo",
+ "sysinfo 0.39.5",
"tempfile",
"test-case",
"testcontainers",
@@ -7349,7 +7435,7 @@ dependencies = [
"socket2 0.6.4",
"widestring",
"windows-registry",
- "windows-result",
+ "windows-result 0.4.1",
"windows-sys 0.61.2",
]
@@ -7405,7 +7491,7 @@ dependencies = [
"portable-atomic",
"portable-atomic-util",
"serde_core",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7464,7 +7550,7 @@ dependencies = [
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -7779,7 +7865,7 @@ source =
"registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
dependencies = [
"cfg-if",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -8122,6 +8208,17 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+[[package]]
+name = "maybe-async"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.118",
+]
+
[[package]]
name = "maybe-rayon"
version = "0.1.1"
@@ -8152,6 +8249,12 @@ dependencies = [
"digest 0.11.3",
]
+[[package]]
+name = "md5"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0"
+
[[package]]
name = "memchr"
version = "2.8.2"
@@ -8277,6 +8380,15 @@ dependencies = [
"unicase",
]
+[[package]]
+name = "minidom"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e394a0e3c7ccc2daea3dffabe82f09857b6b510cb25af87d54bf3e910ac1642d"
+dependencies = [
+ "rxml",
+]
+
[[package]]
name = "minimal-lexical"
version = "0.2.1"
@@ -9170,7 +9282,7 @@ dependencies = [
"libc",
"redox_syscall",
"smallvec",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -9297,7 +9409,7 @@ source =
"registry+https://github.com/rust-lang/crates.io-index"
checksum = "9510c76e37ee20be1edfce1819a3169a7c1c67fab98bd35a33684f8886f5cf4d"
dependencies = [
"libc",
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -10857,6 +10969,41 @@ dependencies = [
"ordered-multimap",
]
+[[package]]
+name = "rust-s3"
+version = "0.37.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aeedb13abdaa7e48d391de05b0569b37fa0a7a64a668dff6ffb2141ad0c2527e"
+dependencies = [
+ "async-trait",
+ "aws-creds",
+ "aws-region",
+ "base64",
+ "bytes",
+ "cfg-if",
+ "futures-util",
+ "hex",
+ "hmac 0.12.1",
+ "http 1.4.2",
+ "log",
+ "maybe-async",
+ "md5",
+ "minidom",
+ "percent-encoding",
+ "quick-xml 0.38.4",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "sha2 0.10.9",
+ "sysinfo 0.37.2",
+ "thiserror 2.0.18",
+ "time",
+ "tokio",
+ "tokio-stream",
+ "url",
+]
+
[[package]]
name = "rust_decimal"
version = "1.42.1"
@@ -11065,6 +11212,25 @@ dependencies = [
"unicode-script",
]
+[[package]]
+name = "rxml"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65bc94b580d0f5a6b7a2d604e597513d3c673154b52ddeccd1d5c32360d945ee"
+dependencies = [
+ "bytes",
+ "rxml_validation",
+]
+
+[[package]]
+name = "rxml_validation"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "826e80413b9a35e9d33217b3dcac04cf95f6559d15944b93887a08be5496c4a4"
+dependencies = [
+ "compact_str",
+]
+
[[package]]
name = "ryu"
version = "1.0.23"
@@ -11547,7 +11713,7 @@ dependencies = [
"slab",
"socket2 0.6.4",
"strum 0.28.0",
- "sysinfo",
+ "sysinfo 0.39.5",
"tempfile",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
@@ -11628,7 +11794,7 @@ dependencies = [
"slab",
"socket2 0.6.4",
"strum 0.28.0",
- "sysinfo",
+ "sysinfo 0.39.5",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -12492,6 +12658,20 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "sysinfo"
+version = "0.37.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f"
+dependencies = [
+ "libc",
+ "memchr",
+ "ntapi",
+ "objc2-core-foundation",
+ "objc2-io-kit",
+ "windows 0.61.3",
+]
+
[[package]]
name = "sysinfo"
version = "0.39.5"
@@ -12504,7 +12684,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-io-kit",
"objc2-open-directory",
- "windows",
+ "windows 0.62.2",
]
[[package]]
@@ -13790,7 +13970,7 @@ dependencies = [
"bon",
"rustc_version",
"rustversion",
- "sysinfo",
+ "sysinfo 0.39.5",
"time",
"vergen-lib",
]
@@ -14096,16 +14276,38 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+[[package]]
+name = "windows"
+version = "0.61.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
+dependencies = [
+ "windows-collections 0.2.0",
+ "windows-core 0.61.2",
+ "windows-future 0.2.1",
+ "windows-link 0.1.3",
+ "windows-numerics 0.2.0",
+]
+
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
- "windows-collections",
- "windows-core",
- "windows-future",
- "windows-numerics",
+ "windows-collections 0.3.2",
+ "windows-core 0.62.2",
+ "windows-future 0.3.2",
+ "windows-numerics 0.3.1",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
+dependencies = [
+ "windows-core 0.61.2",
]
[[package]]
@@ -14114,7 +14316,20 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
- "windows-core",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link 0.1.3",
+ "windows-result 0.3.4",
+ "windows-strings 0.4.2",
]
[[package]]
@@ -14125,9 +14340,20 @@ checksum =
"b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
- "windows-link",
- "windows-result",
- "windows-strings",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+ "windows-threading 0.1.0",
]
[[package]]
@@ -14136,9 +14362,9 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
- "windows-core",
- "windows-link",
- "windows-threading",
+ "windows-core 0.62.2",
+ "windows-link 0.2.1",
+ "windows-threading 0.2.1",
]
[[package]]
@@ -14163,6 +14389,12 @@ dependencies = [
"syn 2.0.118",
]
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -14182,14 +14414,24 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "windows-numerics"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
+dependencies = [
+ "windows-core 0.61.2",
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
- "windows-core",
- "windows-link",
+ "windows-core 0.62.2",
+ "windows-link 0.2.1",
]
[[package]]
@@ -14198,9 +14440,18 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
- "windows-link",
- "windows-result",
- "windows-strings",
+ "windows-link 0.2.1",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link 0.1.3",
]
[[package]]
@@ -14209,7 +14460,16 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link 0.1.3",
]
[[package]]
@@ -14218,7 +14478,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -14263,7 +14523,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
@@ -14303,7 +14563,7 @@ version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1",
@@ -14314,13 +14574,22 @@ dependencies = [
"windows_x86_64_msvc 0.53.1",
]
+[[package]]
+name = "windows-threading"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
+dependencies = [
+ "windows-link 0.1.3",
+]
+
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
- "windows-link",
+ "windows-link 0.2.1",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index 4d196d963..027209cea 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -42,6 +42,7 @@ members = [
"core/connectors/sinks/mongodb_sink",
"core/connectors/sinks/postgres_sink",
"core/connectors/sinks/quickwit_sink",
+ "core/connectors/sinks/s3_sink",
"core/connectors/sinks/stdout_sink",
"core/connectors/sources/elasticsearch_source",
"core/connectors/sources/influxdb_source",
diff --git a/core/connectors/README.md b/core/connectors/README.md
index b7d24cfba..2d26715fa 100644
--- a/core/connectors/README.md
+++ b/core/connectors/README.md
@@ -85,6 +85,7 @@ Each sink should have its own, custom configuration, which is
passed along with
- **Iceberg Sink** - writes data to Apache Iceberg tables via REST catalog
- **PostgreSQL Sink** - stores messages in PostgreSQL database tables
- **Quickwit Sink** - indexes messages in Quickwit search engine
+- **S3 Sink** - writes messages to Amazon S3 and S3-compatible stores (MinIO,
R2, B2, DO Spaces)
- **Stdout Sink** - prints messages to standard output (useful for
debugging/development)
## Source
diff --git a/core/connectors/sinks/README.md b/core/connectors/sinks/README.md
index 367a22028..497617f22 100644
--- a/core/connectors/sinks/README.md
+++ b/core/connectors/sinks/README.md
@@ -14,6 +14,7 @@ Sink connectors are responsible for writing data from Iggy
streams to external s
| **influxdb_sink** | Writes messages to InfluxDB as line-protocol points;
supports both V2 (org/bucket, Flux) and V3 (db, SQL) |
| **postgres_sink** | Stores messages in PostgreSQL database tables with
configurable schemas |
| **quickwit_sink** | Indexes messages in Quickwit search engine for log
analytics |
+| **s3_sink** | Writes messages to Amazon S3 and S3-compatible stores (MinIO,
R2, B2, DO Spaces) |
| **stdout_sink** | Prints messages to standard output (useful for debugging
and development) |
The sink is represented by the single `Sink` trait, which defines the basic
interface for all sink connectors. It provides methods for initializing the
sink, writing data to external destination, and closing the sink.
diff --git a/core/connectors/sinks/s3_sink/Cargo.toml
b/core/connectors/sinks/s3_sink/Cargo.toml
new file mode 100644
index 000000000..b70647413
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/Cargo.toml
@@ -0,0 +1,52 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[package]
+name = "iggy_connector_s3_sink"
+version = "0.4.0"
+description = "Iggy S3 sink connector for writing stream messages to Amazon S3
and S3-compatible stores"
+edition = "2024"
+license = "Apache-2.0"
+keywords = ["iggy", "messaging", "streaming", "s3", "sink"]
+categories = ["command-line-utilities", "database", "network-programming"]
+homepage = "https://iggy.apache.org"
+documentation = "https://iggy.apache.org/docs"
+repository = "https://github.com/apache/iggy"
+readme = "../../README.md"
+publish = false
+
+[lib]
+crate-type = ["cdylib", "lib"]
+
+[dependencies]
+async-trait = { workspace = true }
+base64 = { workspace = true }
+byte-unit = { workspace = true }
+chrono = { workspace = true }
+dashmap = { workspace = true }
+humantime = { workspace = true }
+iggy_common = { workspace = true }
+iggy_connector_sdk = { workspace = true }
+rust-s3 = { workspace = true }
+secrecy = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+simd-json = { workspace = true }
diff --git a/core/connectors/sinks/s3_sink/README.md
b/core/connectors/sinks/s3_sink/README.md
new file mode 100644
index 000000000..6ee55368c
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/README.md
@@ -0,0 +1,164 @@
+# Apache Iggy S3 Sink Connector
+
+Writes messages from Iggy streams to Amazon S3 and S3-compatible object stores
(MinIO, Cloudflare R2, DigitalOcean Spaces, Backblaze B2).
+
+## Features
+
+- Buffered uploads with configurable file rotation (by size or message count)
+- Multiple output formats: JSON Lines, JSON Array, Raw
+- Configurable path templates with variables for stream, topic, date, hour,
partition
+- S3 keys include offset ranges for human-readable object naming
+- Optional metadata and header inclusion in output
+- Support for custom endpoints (MinIO, R2) and path-style addressing
+- Retry with exponential backoff and jitter on transient upload failures
+
+## Configuration
+
+### Connector Runtime Config
+
+```toml
+type = "sink"
+key = "s3"
+enabled = true
+version = 0
+name = "S3 sink"
+path = "../../target/release/libiggy_connector_s3_sink"
+verbose = false
+
+[[streams]]
+stream = "application_logs"
+topics = ["api_requests", "errors"]
+schema = "json"
+batch_length = 1000
+poll_interval = "100ms"
+consumer_group = "s3_sink"
+```
+
+### Plugin Configuration
+
+```toml
+[plugin_config]
+bucket = "my-data-lake"
+prefix = "iggy/raw"
+region = "us-east-1"
+# endpoint = "http://localhost:9000" # for MinIO / S3-compatible stores
+# access_key_id = "AKIA..." # omit to use env vars / instance
profile
+# secret_access_key = "..." # omit to use env vars / instance
profile
+path_template = "{stream}/{topic}/{date}/{hour}"
+file_rotation = "size"
+max_file_size = "8MiB"
+output_format = "json_lines"
+include_metadata = true
+include_headers = true
+max_attempts = 3
+retry_delay = "1s"
+```
+
+### Options Reference
+
+| Option | Type | Default | Description |
+| ------ | ---- | ------- | ----------- |
+| `bucket` | String | **required** | S3 bucket name |
+| `region` | String | **required** | AWS region (e.g. `us-east-1`) |
+| `prefix` | String | `None` | Key prefix prepended to all objects |
+| `endpoint` | String | `None` | Custom S3 endpoint for MinIO, R2, etc. |
+| `access_key_id` | String | `None` | AWS access key; omit for env/instance
profile |
+| `secret_access_key` | String | `None` | AWS secret key; omit for
env/instance profile |
+| `path_template` | String | `{stream}/{topic}/{date}/{hour}` | Template for
S3 key directory structure |
+| `file_rotation` | String | `size` | Rotation strategy: `size` or `messages` |
+| `max_file_size` | String | `8MiB` | Max file size before rotation (when
`file_rotation = "size"`) |
+| `max_messages_per_file` | Integer | `None` | Max messages per file (required
when `file_rotation = "messages"`) |
+| `output_format` | String | `json_lines` | Output format: `json_lines`,
`json_array`, or `raw` |
+| `include_metadata` | Boolean | `true` | Include
stream/topic/partition/offset in output |
+| `include_headers` | Boolean | `false` | Include message headers in output |
+| `max_attempts` | Integer | `3` | Max total upload attempts per file (also
accepts `max_retries` as alias) |
+| `retry_delay` | String | `1s` | Base delay for exponential backoff
(humantime format) |
+| `path_style` | Boolean | auto | Force path-style S3 addressing; auto-enabled
when `endpoint` is set |
+
+### Path Template Variables
+
+| Variable | Description | Example |
+| -------- | ----------- | ------- |
+| `{stream}` | Iggy stream name | `application_logs` |
+| `{topic}` | Iggy topic name | `api_requests` |
+| `{partition}` | Partition ID | `1` |
+| `{date}` | UTC date from first message in buffer | `2026-03-16` |
+| `{hour}` | UTC hour from first message in buffer | `14` |
+| `{timestamp}` | Epoch millis derived from first message timestamp in buffer
(deterministic) | `1710597600000` |
+
+**Note:** `{timestamp}`, `{date}`, and `{hour}` are all derived from the first
message timestamp in each buffer. They are deterministic across retries within
the same process. However, a process restart resets in-memory buffers, so batch
boundaries (and therefore timestamps in the key) may differ after recovery.
+
+### Credentials
+
+Credentials can be provided in three ways (in order of precedence):
+
+1. **Explicit config**: Set both `access_key_id` and `secret_access_key`
+2. **Environment variables**: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`,
`AWS_SESSION_TOKEN`
+3. **Instance profile / IAM role**: Automatic when running on EC2/ECS/EKS
+
+Both `access_key_id` and `secret_access_key` must be provided together or both
omitted.
+
+## Output Example
+
+With `output_format = "json_lines"` and `include_metadata = true`, writing
`api_requests` messages produces:
+
+```text
+s3://my-data-lake/iggy/raw/application_logs/api_requests/2026-03-16/14/000000-000999.jsonl
+```
+
+Each line:
+
+```json
+{"offset":42,"timestamp":"2026-03-16T14:02:31Z","stream":"application_logs","topic":"api_requests","partition_id":1,"payload":{"method":"GET","path":"/api/users","status":200}}
+```
+
+## S3-Compatible Stores
+
+### MinIO
+
+```toml
+[plugin_config]
+bucket = "my-bucket"
+region = "us-east-1"
+endpoint = "http://localhost:9000"
+access_key_id = "minioadmin"
+secret_access_key = "minioadmin"
+```
+
+### Cloudflare R2
+
+```toml
+[plugin_config]
+bucket = "my-bucket"
+region = "auto"
+endpoint = "https://<account-id>.r2.cloudflarestorage.com"
+access_key_id = "..."
+secret_access_key = "..."
+```
+
+## Delivery Semantics
+
+All retry logic lives inside `consume()`. The connector runtime invokes
`consume()` via an FFI callback that returns an `i32` status code. The runtime
does not inspect this return value (see `process_messages()` in
`runtime/src/sink.rs`), so errors logged by the sink are not propagated to the
runtime's retry or alerting mechanisms. Additionally, consumer group offsets
are committed before processing ([runtime issue #1](#known-limitations)). This
means:
+
+- Failed messages are **not retried by the runtime** — only by the sink's
internal retry loop
+- Messages are committed **before delivery** — a crash after commit but before
delivery loses messages
+
+The effective delivery guarantee is **at-most-once** at the runtime level. The
sink's internal retries provide best-effort delivery within each `consume()`
call.
+
+## Known Limitations
+
+1. **Runtime ignores `consume()` status**: The connector runtime invokes
`consume()` via an FFI callback returning `i32`. The `process_messages()`
function in `runtime/src/sink.rs` does not inspect the return value. Errors are
logged internally by the sink but do not trigger runtime-level retry or
alerting. ([#2927](https://github.com/apache/iggy/issues/2927))
+
+2. **Offsets committed before processing**: The `PollingMessages` auto-commit
strategy commits consumer group offsets before `consume()` is called. Combined
with limitation 1, at-least-once delivery is not achievable.
([#2928](https://github.com/apache/iggy/issues/2928))
+
+3. **In-memory buffering only**: There is no write-ahead log. A process crash
loses all in-memory buffered messages that have not yet been flushed to S3.
+
+4. **No dead letter queue**: Failed messages are logged at `error!` level but
not persisted to a DLQ. DLQ support would be a runtime-level feature.
+
+## Building
+
+```bash
+cargo build --release -p iggy_connector_s3_sink
+```
+
+The compiled plugin will be at
`target/release/libiggy_connector_s3_sink.{so,dylib,dll}`.
diff --git a/core/connectors/sinks/s3_sink/config.toml
b/core/connectors/sinks/s3_sink/config.toml
new file mode 100644
index 000000000..ed71fc900
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/config.toml
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+type = "sink"
+key = "s3"
+enabled = true
+version = 0
+name = "S3 sink"
+path = "../../target/release/libiggy_connector_s3_sink"
+verbose = false
+
+[[streams]]
+stream = "application_logs"
+topics = ["api_requests", "errors"]
+schema = "json"
+batch_length = 1000
+poll_interval = "100ms"
+consumer_group = "s3_sink"
+
+[plugin_config]
+bucket = "my-data-lake"
+prefix = "iggy/raw"
+region = "us-east-1"
+# endpoint = "http://localhost:9000" # uncomment for MinIO /
S3-compatible stores
+# access_key_id = "minioadmin" # omit to use env vars / instance
profile
+# secret_access_key = "minioadmin" # omit to use env vars / instance
profile
+path_template = "{stream}/{topic}/{date}/{hour}"
+file_rotation = "size"
+max_file_size = "8MiB"
+# max_messages_per_file = 10000 # used when file_rotation =
"messages"
+output_format = "json_lines"
+include_metadata = true
+include_headers = true
+max_attempts = 3
+retry_delay = "1s"
+# path_style = true # auto-enabled when endpoint is set
(for MinIO)
diff --git a/core/connectors/sinks/s3_sink/src/buffer.rs
b/core/connectors/sinks/s3_sink/src/buffer.rs
new file mode 100644
index 000000000..ad49e2651
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/src/buffer.rs
@@ -0,0 +1,182 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::FileRotation;
+
+#[derive(Debug)]
+pub(crate) struct FileBuffer {
+ data: Vec<u8>,
+ boundaries: Vec<usize>,
+ message_count: u64,
+ first_offset: Option<u64>,
+ last_offset: Option<u64>,
+ first_timestamp_micros: u64,
+}
+
+impl FileBuffer {
+ pub fn new() -> Self {
+ FileBuffer {
+ data: Vec::new(),
+ boundaries: Vec::new(),
+ message_count: 0,
+ first_offset: None,
+ last_offset: None,
+ first_timestamp_micros: 0,
+ }
+ }
+
+ pub fn append(&mut self, entry: &[u8], offset: u64, timestamp_micros: u64)
{
+ self.data.extend_from_slice(entry);
+ self.boundaries.push(self.data.len());
+ self.message_count += 1;
+
+ if self.first_offset.is_none() {
+ self.first_offset = Some(offset);
+ self.first_timestamp_micros = timestamp_micros;
+ }
+ self.last_offset = Some(offset);
+ }
+
+ pub fn should_rotate(&self, rotation: FileRotation, max_size: u64,
max_messages: u64) -> bool {
+ match rotation {
+ FileRotation::Size => self.data.len() as u64 >= max_size,
+ FileRotation::Messages => self.message_count >= max_messages,
+ }
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.boundaries.is_empty()
+ }
+
+ /// Returns an iterator over individual entry slices without copying.
+ pub fn entries(&self) -> impl Iterator<Item = &[u8]> {
+ let mut start = 0;
+ self.boundaries.iter().map(move |&end| {
+ let slice = &self.data[start..end];
+ start = end;
+ slice
+ })
+ }
+
+ pub fn first_offset(&self) -> u64 {
+ self.first_offset.unwrap_or(0)
+ }
+
+ pub fn last_offset(&self) -> u64 {
+ self.last_offset.unwrap_or(0)
+ }
+
+ pub fn first_timestamp_micros(&self) -> u64 {
+ self.first_timestamp_micros
+ }
+
+ pub fn message_count(&self) -> u64 {
+ self.message_count
+ }
+
+ pub fn reset(&mut self) {
+ self.data.clear();
+ self.boundaries.clear();
+ self.message_count = 0;
+ self.first_offset = None;
+ self.last_offset = None;
+ self.first_timestamp_micros = 0;
+ }
+}
+
+impl Default for FileBuffer {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn new_buffer_is_empty() {
+ let buf = FileBuffer::new();
+ assert!(buf.is_empty());
+ assert_eq!(buf.message_count(), 0);
+ assert_eq!(buf.first_offset(), 0);
+ assert_eq!(buf.last_offset(), 0);
+ }
+
+ #[test]
+ fn append_tracks_offsets() {
+ let mut buf = FileBuffer::new();
+ buf.append(&[1, 2, 3], 10, 1000);
+ buf.append(&[4, 5], 11, 1001);
+ buf.append(&[6], 12, 1002);
+
+ assert!(!buf.is_empty());
+ assert_eq!(buf.message_count(), 3);
+ assert_eq!(buf.first_offset(), 10);
+ assert_eq!(buf.last_offset(), 12);
+ assert_eq!(buf.first_timestamp_micros(), 1000);
+ assert_eq!(buf.entries().count(), 3);
+ }
+
+ #[test]
+ fn rotation_by_size() {
+ let mut buf = FileBuffer::new();
+ buf.append(&[0; 500], 0, 100);
+ assert!(!buf.should_rotate(FileRotation::Size, 1000, 0));
+
+ buf.append(&[0; 500], 1, 200);
+ assert!(buf.should_rotate(FileRotation::Size, 1000, 0));
+
+ buf.append(&[0; 100], 2, 300);
+ assert!(buf.should_rotate(FileRotation::Size, 1000, 0));
+ }
+
+ #[test]
+ fn rotation_by_messages() {
+ let mut buf = FileBuffer::new();
+ buf.append(&[1], 0, 100);
+ buf.append(&[2], 1, 200);
+ assert!(!buf.should_rotate(FileRotation::Messages, 0, 3));
+
+ buf.append(&[3], 2, 300);
+ assert!(buf.should_rotate(FileRotation::Messages, 0, 3));
+ }
+
+ #[test]
+ fn reset_clears_state() {
+ let mut buf = FileBuffer::new();
+ buf.append(&[1, 2, 3], 5, 1000);
+ buf.append(&[4, 5, 6], 6, 2000);
+
+ buf.reset();
+
+ assert!(buf.is_empty());
+ assert_eq!(buf.message_count(), 0);
+ assert_eq!(buf.first_offset(), 0);
+ assert_eq!(buf.last_offset(), 0);
+ }
+
+ #[test]
+ fn contiguous_entries() {
+ let mut buf = FileBuffer::new();
+ buf.append(b"hello", 0, 100);
+ buf.append(b"world", 1, 200);
+
+ let entries: Vec<&[u8]> = buf.entries().collect();
+ assert_eq!(entries, vec![b"hello".as_slice(), b"world".as_slice()]);
+ }
+}
diff --git a/core/connectors/sinks/s3_sink/src/client.rs
b/core/connectors/sinks/s3_sink/src/client.rs
new file mode 100644
index 000000000..3ec489f1b
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/src/client.rs
@@ -0,0 +1,169 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::S3SinkConfig;
+use iggy_connector_sdk::Error;
+use s3::creds::Credentials;
+use s3::{Bucket, Region};
+use secrecy::ExposeSecret;
+use tracing::info;
+
+pub(crate) async fn create_bucket(config: &S3SinkConfig) ->
Result<Box<Bucket>, Error> {
+ if config.access_key_id.is_some() != config.secret_access_key.is_some() {
+ return Err(Error::InvalidConfigValue(
+ "Partially configured credentials. You must provide both
access_key_id \
+ and secret_access_key, or omit both."
+ .to_owned(),
+ ));
+ }
+
+ let credentials = match (&config.access_key_id, &config.secret_access_key)
{
+ (Some(key), Some(secret)) => {
+ let key_len = key.expose_secret().len();
+ info!("Using explicit S3 credentials (key length: {key_len}
chars)");
+ Credentials::new(
+ Some(key.expose_secret()),
+ Some(secret.expose_secret()),
+ None,
+ None,
+ None,
+ )
+ .map_err(|e| Error::InitError(format!("Failed to create S3
credentials: {e}")))?
+ }
+ _ => {
+ info!(
+ "No explicit credentials provided, using default credential
chain (env vars / instance profile)"
+ );
+ Credentials::default().map_err(|e| {
+ Error::InitError(format!("Failed to load default S3
credentials: {e}"))
+ })?
+ }
+ };
+
+ let region = match &config.endpoint {
+ Some(endpoint) => {
+ info!("Using custom S3 endpoint: {endpoint}");
+ Region::Custom {
+ region: config.region.clone(),
+ endpoint: endpoint.clone(),
+ }
+ }
+ None => config.region.parse::<Region>().map_err(|e| {
+ Error::InvalidConfigValue(format!("Invalid S3 region '{}': {e}",
config.region))
+ })?,
+ };
+
+ let mut bucket = Bucket::new(&config.bucket, region, credentials)
+ .map_err(|e| Error::InitError(format!("Failed to create S3 bucket
handle: {e}")))?;
+
+ let use_path_style =
config.path_style.unwrap_or(config.endpoint.is_some());
+ if use_path_style {
+ bucket.set_path_style();
+ }
+
+ Ok(bucket)
+}
+
+/// Verify bucket connectivity with a zero-byte probe write. Only requires
+/// `s3:PutObject` permission, unlike `list_page` which needs `s3:ListBucket`
+/// and fails for write-only IAM policies.
+pub(crate) async fn verify_bucket(bucket: &Bucket) -> Result<(), Error> {
+ const PROBE_KEY: &str = ".iggy-sink-probe";
+ bucket.put_object(PROBE_KEY, &[]).await.map_err(|e| {
+ Error::InitError(format!(
+ "S3 bucket '{}' connectivity check failed: {e}",
+ bucket.name
+ ))
+ })?;
+ let _ = bucket.delete_object(PROBE_KEY).await;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ FileRotation, default_max_file_size, default_output_format,
default_path_template,
+ };
+
+ fn base_config() -> S3SinkConfig {
+ S3SinkConfig {
+ bucket: "test".to_string(),
+ region: "us-east-1".to_string(),
+ prefix: None,
+ endpoint: None,
+ access_key_id: None,
+ secret_access_key: None,
+ path_template: default_path_template(),
+ file_rotation: FileRotation::Size,
+ max_file_size: default_max_file_size(),
+ max_messages_per_file: None,
+ output_format: default_output_format(),
+ include_metadata: true,
+ include_headers: false,
+ max_attempts: None,
+ retry_delay: None,
+ path_style: None,
+ }
+ }
+
+ #[test]
+ fn validate_both_credentials_present() {
+ let config = S3SinkConfig {
+ access_key_id: Some("AKIAIOSFODNN7EXAMPLE".into()),
+ secret_access_key:
Some("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY".into()),
+ ..base_config()
+ };
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ // create_bucket will succeed on credential validation but may fail on
+ // actual region parsing in test env -- we just check it gets past
validation
+ let result = rt.block_on(create_bucket(&config));
+ assert!(result.is_ok() || !format!("{:?}", result).contains("Partially
configured"));
+ }
+
+ #[test]
+ fn validate_no_credentials() {
+ let config = base_config();
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let result = rt.block_on(create_bucket(&config));
+ assert!(result.is_ok() || !format!("{:?}", result).contains("Partially
configured"));
+ }
+
+ #[test]
+ fn validate_partial_access_key_only() {
+ let config = S3SinkConfig {
+ access_key_id: Some("AKIAIOSFODNN7EXAMPLE".into()),
+ secret_access_key: None,
+ ..base_config()
+ };
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let result = rt.block_on(create_bucket(&config));
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn validate_partial_secret_key_only() {
+ let config = S3SinkConfig {
+ access_key_id: None,
+ secret_access_key: Some("secret".into()),
+ ..base_config()
+ };
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let result = rt.block_on(create_bucket(&config));
+ assert!(result.is_err());
+ }
+}
diff --git a/core/connectors/sinks/s3_sink/src/formatter.rs
b/core/connectors/sinks/s3_sink/src/formatter.rs
new file mode 100644
index 000000000..bec194c90
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/src/formatter.rs
@@ -0,0 +1,393 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::OutputFormat;
+use chrono::{DateTime, Utc};
+use iggy_connector_sdk::{
+ ConsumedMessage, Error, MessagesMetadata, Payload, TopicMetadata,
owned_value_to_serde_json,
+};
+use serde::Serialize;
+use serde_json::Value;
+
+#[derive(Serialize)]
+struct JsonMessage<'a> {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ offset: Option<u64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ timestamp: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ stream: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ topic: Option<&'a str>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ partition_id: Option<u32>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ headers: Option<Value>,
+ payload: Value,
+}
+
+pub(crate) fn format_message(
+ message: &ConsumedMessage,
+ topic_metadata: &TopicMetadata,
+ messages_metadata: &MessagesMetadata,
+ include_metadata: bool,
+ include_headers: bool,
+ format: OutputFormat,
+) -> Result<Vec<u8>, Error> {
+ match format {
+ OutputFormat::JsonLines | OutputFormat::JsonArray =>
format_json_message(
+ message,
+ topic_metadata,
+ messages_metadata,
+ include_metadata,
+ include_headers,
+ ),
+ OutputFormat::Raw => format_raw_message(message),
+ }
+}
+
+fn format_json_message(
+ message: &ConsumedMessage,
+ topic_metadata: &TopicMetadata,
+ messages_metadata: &MessagesMetadata,
+ include_metadata: bool,
+ include_headers: bool,
+) -> Result<Vec<u8>, Error> {
+ let ts_str = if include_metadata {
+ Some(timestamp_to_rfc3339(message.timestamp))
+ } else {
+ None
+ };
+ let msg = JsonMessage {
+ offset: if include_metadata {
+ Some(message.offset)
+ } else {
+ None
+ },
+ timestamp: ts_str.as_deref(),
+ stream: if include_metadata {
+ Some(&topic_metadata.stream)
+ } else {
+ None
+ },
+ topic: if include_metadata {
+ Some(&topic_metadata.topic)
+ } else {
+ None
+ },
+ partition_id: if include_metadata {
+ Some(messages_metadata.partition_id)
+ } else {
+ None
+ },
+ headers: if include_headers {
+ message.headers.as_ref().map(serialize_headers)
+ } else {
+ None
+ },
+ payload: payload_to_json_value(&message.payload),
+ };
+
+ serde_json::to_vec(&msg).map_err(|e| {
+ Error::CannotStoreData(format!(
+ "Failed to serialize message at offset {}: {e}",
+ message.offset
+ ))
+ })
+}
+
+fn format_raw_message(message: &ConsumedMessage) -> Result<Vec<u8>, Error> {
+ message.payload.try_to_bytes().map_err(|e| {
+ Error::CannotStoreData(format!(
+ "Failed to extract raw bytes at offset {}: {e}",
+ message.offset
+ ))
+ })
+}
+
+fn serialize_headers(
+ headers: &std::collections::BTreeMap<iggy_common::HeaderKey,
iggy_common::HeaderValue>,
+) -> Value {
+ use iggy_common::HeaderKind;
+ use serde_json::Map;
+
+ let mut obj = Map::new();
+ for (key, value) in headers {
+ let key_str = key.as_str().unwrap_or("").to_string();
+ let json_value = match value.kind() {
+ HeaderKind::String => {
+
Value::String(String::from_utf8_lossy(&value.value()).into_owned())
+ }
+ HeaderKind::Raw => Value::String(base64_encode(&value.value())),
+ HeaderKind::Bool => {
+ let b = !value.value().is_empty() && value.value()[0] != 0;
+ Value::Bool(b)
+ }
+ HeaderKind::Int8 | HeaderKind::Int16 | HeaderKind::Int32 |
HeaderKind::Int64 => {
+ let s = value.to_string_value();
+ s.parse::<i64>()
+ .map(|n| Value::Number(n.into()))
+ .unwrap_or(Value::String(s))
+ }
+ HeaderKind::Uint8 | HeaderKind::Uint16 | HeaderKind::Uint32 |
HeaderKind::Uint64 => {
+ let s = value.to_string_value();
+ s.parse::<u64>()
+ .map(|n| Value::Number(n.into()))
+ .unwrap_or(Value::String(s))
+ }
+ HeaderKind::Float32 | HeaderKind::Float64 => {
+ let s = value.to_string_value();
+ s.parse::<f64>()
+ .ok()
+ .and_then(serde_json::Number::from_f64)
+ .map(Value::Number)
+ .unwrap_or(Value::String(s))
+ }
+ _ => Value::String(value.to_string_value()),
+ };
+ obj.insert(key_str, json_value);
+ }
+ Value::Object(obj)
+}
+
+fn payload_to_json_value(payload: &Payload) -> Value {
+ match payload {
+ Payload::Json(value) => owned_value_to_serde_json(value),
+ Payload::Text(text) => Value::String(text.clone()),
+ Payload::Raw(bytes) => match serde_json::from_slice(bytes) {
+ Ok(v) => v,
+ Err(_) => Value::String(base64_encode(bytes)),
+ },
+ Payload::Proto(text) => Value::String(text.clone()),
+ Payload::FlatBuffer(bytes) => Value::String(base64_encode(bytes)),
+ Payload::Avro(bytes) => Value::String(base64_encode(bytes)),
+ }
+}
+
+fn base64_encode(bytes: &[u8]) -> String {
+ use base64::Engine;
+ base64::engine::general_purpose::STANDARD.encode(bytes)
+}
+
+fn timestamp_to_rfc3339(micros: u64) -> String {
+ let secs = (micros / 1_000_000) as i64;
+ let nanos = ((micros % 1_000_000) * 1_000) as u32;
+ DateTime::<Utc>::from_timestamp(secs, nanos)
+ .map(|dt| dt.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
+ .unwrap_or_else(|| "1970-01-01T00:00:00Z".to_string())
+}
+
+/// Finalize buffer entries into the output byte format.
+pub(crate) fn finalize_buffer<'a>(
+ entries: impl Iterator<Item = &'a [u8]>,
+ format: OutputFormat,
+) -> Vec<u8> {
+ match format {
+ OutputFormat::JsonLines => {
+ let mut result = Vec::new();
+ for entry in entries {
+ result.extend_from_slice(entry);
+ result.push(b'\n');
+ }
+ result
+ }
+ OutputFormat::Raw => {
+ let mut result = Vec::new();
+ for entry in entries {
+ result.extend_from_slice(entry);
+ }
+ result
+ }
+ OutputFormat::JsonArray => {
+ let mut result = Vec::new();
+ result.push(b'[');
+ let mut first = true;
+ for entry in entries {
+ if !first {
+ result.push(b',');
+ }
+ result.extend_from_slice(entry);
+ first = false;
+ }
+ result.push(b']');
+ result
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use iggy_connector_sdk::Schema;
+ use std::collections::BTreeMap;
+
+ fn make_json_payload(json_str: &str) -> Payload {
+ let mut bytes = json_str.as_bytes().to_vec();
+ let value = simd_json::to_owned_value(&mut bytes).unwrap();
+ Payload::Json(value)
+ }
+
+ fn make_message(offset: u64, payload: Payload) -> ConsumedMessage {
+ ConsumedMessage {
+ id: 1,
+ offset,
+ checksum: 12345,
+ timestamp: 1_710_597_751_000_000,
+ origin_timestamp: 1_710_597_751_000_000,
+ headers: None,
+ payload,
+ }
+ }
+
+ fn make_topic_metadata() -> TopicMetadata {
+ TopicMetadata {
+ stream: "app_logs".to_string(),
+ topic: "api_requests".to_string(),
+ }
+ }
+
+ fn make_messages_metadata() -> MessagesMetadata {
+ MessagesMetadata {
+ partition_id: 1,
+ current_offset: 42,
+ schema: Schema::Json,
+ }
+ }
+
+ #[test]
+ fn json_lines_with_metadata() {
+ let payload = make_json_payload(r#"{"method":"GET","status":200}"#);
+ let msg = make_message(42, payload);
+ let topic = make_topic_metadata();
+ let meta = make_messages_metadata();
+
+ let bytes =
+ format_message(&msg, &topic, &meta, true, false,
OutputFormat::JsonLines).unwrap();
+ let value: Value = serde_json::from_slice(&bytes).unwrap();
+
+ assert_eq!(value["offset"], 42);
+ assert_eq!(value["stream"], "app_logs");
+ assert_eq!(value["topic"], "api_requests");
+ assert_eq!(value["partition_id"], 1);
+ assert_eq!(value["payload"]["method"], "GET");
+ assert!(value["timestamp"].is_string());
+ }
+
+ #[test]
+ fn json_lines_without_metadata() {
+ let payload = make_json_payload(r#"{"key":"value"}"#);
+ let msg = make_message(10, payload);
+ let topic = make_topic_metadata();
+ let meta = make_messages_metadata();
+
+ let bytes =
+ format_message(&msg, &topic, &meta, false, false,
OutputFormat::JsonLines).unwrap();
+ let value: Value = serde_json::from_slice(&bytes).unwrap();
+
+ assert!(value.get("offset").is_none());
+ assert!(value.get("stream").is_none());
+ assert_eq!(value["payload"]["key"], "value");
+ }
+
+ #[test]
+ fn json_lines_with_headers() {
+ let payload = make_json_payload(r#"{"data":1}"#);
+ let mut msg = make_message(5, payload);
+
+ let mut headers = BTreeMap::new();
+ let key = iggy_common::HeaderKey::try_from("content-type").unwrap();
+ let value =
iggy_common::HeaderValue::try_from("application/json").unwrap();
+ headers.insert(key, value);
+ msg.headers = Some(headers);
+
+ let topic = make_topic_metadata();
+ let meta = make_messages_metadata();
+
+ let bytes =
+ format_message(&msg, &topic, &meta, false, true,
OutputFormat::JsonLines).unwrap();
+ let value: Value = serde_json::from_slice(&bytes).unwrap();
+
+ assert!(value["headers"].is_object());
+ assert_eq!(value["headers"]["content-type"], "application/json");
+ }
+
+ #[test]
+ fn raw_format() {
+ let payload = Payload::Text("hello world".to_string());
+ let msg = make_message(1, payload);
+ let topic = make_topic_metadata();
+ let meta = make_messages_metadata();
+
+ let bytes = format_message(&msg, &topic, &meta, true, false,
OutputFormat::Raw).unwrap();
+ assert_eq!(bytes, b"hello world");
+ }
+
+ #[test]
+ fn finalize_json_lines() {
+ let data = b"{\"a\":1}{\"b\":2}";
+ let boundaries = [7usize, 14];
+ let entries = entries_from_boundaries(data, &boundaries);
+ let result = finalize_buffer(entries, OutputFormat::JsonLines);
+ assert_eq!(result, b"{\"a\":1}\n{\"b\":2}\n");
+ }
+
+ #[test]
+ fn finalize_raw_no_delimiter() {
+ let data = b"\x00\x01\x02\x0a\xff\xfe";
+ let boundaries = [4usize, 6];
+ let entries = entries_from_boundaries(data, &boundaries);
+ let result = finalize_buffer(entries, OutputFormat::Raw);
+ assert_eq!(
+ result, data,
+ "Raw must concatenate without inserting delimiters"
+ );
+ }
+
+ #[test]
+ fn finalize_json_array() {
+ let data = b"{\"a\":1}{\"b\":2}";
+ let boundaries = [7usize, 14];
+ let entries = entries_from_boundaries(data, &boundaries);
+ let result = finalize_buffer(entries, OutputFormat::JsonArray);
+ assert_eq!(result, b"[{\"a\":1},{\"b\":2}]");
+ }
+
+ #[test]
+ fn timestamp_conversion() {
+ let ts = timestamp_to_rfc3339(1_710_597_751_000_000);
+ assert!(ts.starts_with("2024-03-16T"));
+ assert!(ts.ends_with('Z'));
+ }
+
+ #[test]
+ fn timestamp_zero() {
+ let ts = timestamp_to_rfc3339(0);
+ assert_eq!(ts, "1970-01-01T00:00:00Z");
+ }
+
+ fn entries_from_boundaries<'a>(
+ data: &'a [u8],
+ boundaries: &'a [usize],
+ ) -> impl Iterator<Item = &'a [u8]> {
+ let mut start = 0;
+ boundaries.iter().map(move |&end| {
+ let s = &data[start..end];
+ start = end;
+ s
+ })
+ }
+}
diff --git a/core/connectors/sinks/s3_sink/src/lib.rs
b/core/connectors/sinks/s3_sink/src/lib.rs
new file mode 100644
index 000000000..92a3f856c
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/src/lib.rs
@@ -0,0 +1,498 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt;
+use std::str::FromStr;
+use std::sync::Arc;
+
+use iggy_connector_sdk::{Error, sink_connector};
+use secrecy::SecretString;
+use serde::{Deserialize, Serialize};
+
+mod buffer;
+mod client;
+mod formatter;
+mod path;
+mod sink;
+
+sink_connector!(S3Sink);
+
+const DEFAULT_MAX_ATTEMPTS: u32 = 3;
+const DEFAULT_RETRY_DELAY: &str = "1s";
+const DEFAULT_MAX_FILE_SIZE: &str = "8MiB";
+const DEFAULT_PATH_TEMPLATE: &str = "{stream}/{topic}/{date}/{hour}";
+const DEFAULT_OUTPUT_FORMAT: &str = "json_lines";
+const MAX_S3_SINGLE_PUT_SIZE: u64 = 5 * 1024 * 1024 * 1024; // 5 GiB
+
+#[derive(Clone, Serialize, Deserialize)]
+pub struct S3SinkConfig {
+ pub bucket: String,
+ pub region: String,
+ #[serde(default)]
+ pub prefix: Option<String>,
+ #[serde(default)]
+ pub endpoint: Option<String>,
+ #[serde(
+ default,
+ serialize_with = "iggy_common::serde_secret::serialize_optional_secret"
+ )]
+ pub access_key_id: Option<SecretString>,
+ #[serde(
+ default,
+ serialize_with = "iggy_common::serde_secret::serialize_optional_secret"
+ )]
+ pub secret_access_key: Option<SecretString>,
+ #[serde(default = "default_path_template")]
+ pub path_template: String,
+ #[serde(default = "default_file_rotation")]
+ pub file_rotation: FileRotation,
+ #[serde(default = "default_max_file_size")]
+ pub max_file_size: String,
+ #[serde(default)]
+ pub max_messages_per_file: Option<u64>,
+ #[serde(default = "default_output_format")]
+ pub output_format: String,
+ #[serde(default = "default_true")]
+ pub include_metadata: bool,
+ #[serde(default)]
+ pub include_headers: bool,
+ /// Total number of attempts (including the initial one) before giving up.
+ /// `max_attempts = 3` means 1 initial try + 2 retries. The `max_retries`
+ /// alias is accepted for convenience but follows the same "total attempts"
+ /// semantics.
+ #[serde(default, alias = "max_retries")]
+ pub max_attempts: Option<u32>,
+ #[serde(default)]
+ pub retry_delay: Option<String>,
+ #[serde(default)]
+ pub path_style: Option<bool>,
+}
+
+impl fmt::Debug for S3SinkConfig {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("S3SinkConfig")
+ .field("bucket", &self.bucket)
+ .field("region", &self.region)
+ .field("prefix", &self.prefix)
+ .field("endpoint", &self.endpoint)
+ .field("access_key_id", &"[REDACTED]")
+ .field("secret_access_key", &"[REDACTED]")
+ .field("path_template", &self.path_template)
+ .field("file_rotation", &self.file_rotation)
+ .field("max_file_size", &self.max_file_size)
+ .field("max_messages_per_file", &self.max_messages_per_file)
+ .field("output_format", &self.output_format)
+ .field("include_metadata", &self.include_metadata)
+ .field("include_headers", &self.include_headers)
+ .field("max_attempts", &self.max_attempts)
+ .field("retry_delay", &self.retry_delay)
+ .field("path_style", &self.path_style)
+ .finish()
+ }
+}
+
+fn default_path_template() -> String {
+ DEFAULT_PATH_TEMPLATE.to_string()
+}
+
+fn default_file_rotation() -> FileRotation {
+ FileRotation::Size
+}
+
+fn default_max_file_size() -> String {
+ DEFAULT_MAX_FILE_SIZE.to_string()
+}
+
+fn default_output_format() -> String {
+ DEFAULT_OUTPUT_FORMAT.to_string()
+}
+
+fn default_true() -> bool {
+ true
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum FileRotation {
+ Size,
+ Messages,
+}
+
+impl fmt::Display for FileRotation {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ FileRotation::Size => write!(f, "size"),
+ FileRotation::Messages => write!(f, "messages"),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum OutputFormat {
+ JsonLines,
+ JsonArray,
+ Raw,
+}
+
+impl TryFrom<&str> for OutputFormat {
+ type Error = Error;
+
+ fn try_from(s: &str) -> Result<Self, Self::Error> {
+ match s.to_lowercase().as_str() {
+ "json_lines" | "jsonl" | "jsonlines" =>
Ok(OutputFormat::JsonLines),
+ "json_array" => Ok(OutputFormat::JsonArray),
+ "raw" => Ok(OutputFormat::Raw),
+ other => Err(Error::InvalidConfigValue(format!(
+ "Unknown output format: '{other}'. Expected: json_lines,
json_array, or raw"
+ ))),
+ }
+ }
+}
+
+impl OutputFormat {
+ pub fn file_extension(&self) -> &'static str {
+ match self {
+ OutputFormat::JsonLines => "jsonl",
+ OutputFormat::JsonArray => "json",
+ OutputFormat::Raw => "bin",
+ }
+ }
+}
+
+/// Parsed and validated config fields, constructed only inside `open()`.
+/// Avoids accessing unresolved zero/default placeholder values before
+/// the sink is fully initialized.
+#[derive(Debug)]
+pub(crate) struct ResolvedConfig {
+ pub max_file_size_bytes: u64,
+ pub max_messages: u64,
+ pub output_format: OutputFormat,
+ pub retry_delay: std::time::Duration,
+ pub max_attempts: u32,
+}
+
+pub struct S3Sink {
+ id: u32,
+ config: S3SinkConfig,
+ bucket: Option<Box<s3::Bucket>>,
+ buffers: DashMap<BufferKey, Arc<tokio::sync::Mutex<buffer::FileBuffer>>>,
+ resolved: Option<ResolvedConfig>,
+ state: tokio::sync::Mutex<SinkState>,
+}
+
+impl fmt::Debug for S3Sink {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.debug_struct("S3Sink")
+ .field("id", &self.id)
+ .field("config", &self.config)
+ .field("bucket", &self.bucket.as_ref().map(|b| &b.name))
+ .field("buffers_count", &self.buffers.len())
+ .field("resolved", &self.resolved)
+ .finish()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) struct BufferKey {
+ pub stream: String,
+ pub topic: String,
+ pub partition_id: u32,
+}
+
+#[derive(Debug)]
+struct SinkState {
+ messages_received: u64,
+ messages_uploaded: u64,
+ messages_lost: u64,
+}
+
+impl S3Sink {
+ pub fn new(id: u32, config: S3SinkConfig) -> Self {
+ S3Sink {
+ id,
+ config,
+ bucket: None,
+ buffers: DashMap::new(),
+ resolved: None,
+ state: tokio::sync::Mutex::new(SinkState {
+ messages_received: 0,
+ messages_uploaded: 0,
+ messages_lost: 0,
+ }),
+ }
+ }
+
+ pub fn validate_and_parse_config(&mut self) -> Result<(), Error> {
+ if self.config.bucket.is_empty() {
+ return Err(Error::InvalidConfigValue(
+ "bucket must not be empty".to_owned(),
+ ));
+ }
+ if self.config.region.is_empty() {
+ return Err(Error::InvalidConfigValue(
+ "region must not be empty".to_owned(),
+ ));
+ }
+ if self.config.path_template.is_empty() {
+ return Err(Error::InvalidConfigValue(
+ "path_template must not be empty".to_owned(),
+ ));
+ }
+
+ let output_format =
OutputFormat::try_from(self.config.output_format.as_str())?;
+ let max_file_size_bytes = parse_file_size(&self.config.max_file_size)?;
+
+ if max_file_size_bytes == 0 {
+ return Err(Error::InvalidConfigValue(
+ "max_file_size must be greater than 0".to_owned(),
+ ));
+ }
+ if max_file_size_bytes > MAX_S3_SINGLE_PUT_SIZE {
+ return Err(Error::InvalidConfigValue(format!(
+ "max_file_size ({}) exceeds S3 single PutObject limit of 5
GiB",
+ self.config.max_file_size
+ )));
+ }
+
+ let delay_str = self
+ .config
+ .retry_delay
+ .as_deref()
+ .unwrap_or(DEFAULT_RETRY_DELAY);
+ let retry_delay = humantime::Duration::from_str(delay_str)
+ .map(|d| d.into())
+ .map_err(|e| {
+ Error::InvalidConfigValue(format!("Invalid retry_delay
'{delay_str}': {e}"))
+ })?;
+
+ let mut max_messages = u64::MAX;
+ if self.config.file_rotation == FileRotation::Messages {
+ match self.config.max_messages_per_file {
+ None => {
+ return Err(Error::InvalidConfigValue(
+ "file_rotation is 'messages' but max_messages_per_file
is not configured"
+ .to_owned(),
+ ));
+ }
+ Some(0) => {
+ return Err(Error::InvalidConfigValue(
+ "max_messages_per_file must be greater than
0".to_owned(),
+ ));
+ }
+ Some(n) => {
+ max_messages = n;
+ }
+ }
+ } else if let Some(n) = self.config.max_messages_per_file {
+ if n == 0 {
+ return Err(Error::InvalidConfigValue(
+ "max_messages_per_file must be greater than 0".to_owned(),
+ ));
+ }
+ max_messages = n;
+ }
+
+ let max_attempts =
self.config.max_attempts.unwrap_or(DEFAULT_MAX_ATTEMPTS);
+
+ self.resolved = Some(ResolvedConfig {
+ max_file_size_bytes,
+ max_messages,
+ output_format,
+ retry_delay,
+ max_attempts,
+ });
+
+ Ok(())
+ }
+
+ pub(crate) fn resolved(&self) -> &ResolvedConfig {
+ self.resolved
+ .as_ref()
+ .expect("BUG: resolved config accessed before open()")
+ }
+}
+
+fn parse_file_size(s: &str) -> Result<u64, Error> {
+ byte_unit::Byte::from_str(s)
+ .map(|b| b.as_u64())
+ .map_err(|e| Error::InvalidConfigValue(format!("Invalid file size
'{s}': {e}")))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parse_file_size_mib() {
+ assert_eq!(parse_file_size("8MiB").unwrap(), 8 * 1024 * 1024);
+ }
+
+ #[test]
+ fn parse_file_size_mb() {
+ assert_eq!(parse_file_size("10MB").unwrap(), 10_000_000);
+ }
+
+ #[test]
+ fn parse_file_size_invalid() {
+ assert!(parse_file_size("not_a_size").is_err());
+ }
+
+ #[test]
+ fn output_format_json_lines_variants() {
+ assert_eq!(
+ OutputFormat::try_from("json_lines").unwrap(),
+ OutputFormat::JsonLines
+ );
+ assert_eq!(
+ OutputFormat::try_from("jsonl").unwrap(),
+ OutputFormat::JsonLines
+ );
+ assert_eq!(
+ OutputFormat::try_from("JSONLINES").unwrap(),
+ OutputFormat::JsonLines
+ );
+ }
+
+ #[test]
+ fn output_format_json_array() {
+ assert_eq!(
+ OutputFormat::try_from("json_array").unwrap(),
+ OutputFormat::JsonArray
+ );
+ }
+
+ #[test]
+ fn output_format_json_alias_removed() {
+ assert!(OutputFormat::try_from("json").is_err());
+ }
+
+ #[test]
+ fn output_format_raw() {
+ assert_eq!(OutputFormat::try_from("raw").unwrap(), OutputFormat::Raw);
+ }
+
+ #[test]
+ fn output_format_invalid() {
+ assert!(OutputFormat::try_from("xml").is_err());
+ }
+
+ #[test]
+ fn file_extensions() {
+ assert_eq!(OutputFormat::JsonLines.file_extension(), "jsonl");
+ assert_eq!(OutputFormat::JsonArray.file_extension(), "json");
+ assert_eq!(OutputFormat::Raw.file_extension(), "bin");
+ }
+
+ #[test]
+ fn file_rotation_display() {
+ assert_eq!(FileRotation::Size.to_string(), "size");
+ assert_eq!(FileRotation::Messages.to_string(), "messages");
+ }
+
+ #[test]
+ fn config_deserialization_defaults() {
+ let json = r#"{"bucket":"test","region":"us-east-1"}"#;
+ let config: S3SinkConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.bucket, "test");
+ assert_eq!(config.region, "us-east-1");
+ assert_eq!(config.path_template, DEFAULT_PATH_TEMPLATE);
+ assert_eq!(config.max_file_size, DEFAULT_MAX_FILE_SIZE);
+ assert_eq!(config.output_format, DEFAULT_OUTPUT_FORMAT);
+ assert!(config.include_metadata);
+ assert!(!config.include_headers);
+ assert_eq!(config.file_rotation, FileRotation::Size);
+ assert!(config.prefix.is_none());
+ assert!(config.endpoint.is_none());
+ assert!(config.access_key_id.is_none());
+ assert!(config.secret_access_key.is_none());
+ }
+
+ #[test]
+ fn config_deserialization_full() {
+ let json = r#"{
+ "bucket": "my-bucket",
+ "region": "eu-west-1",
+ "prefix": "data/raw",
+ "endpoint": "http://localhost:9000",
+ "access_key_id": "AKIA...",
+ "secret_access_key": "secret",
+ "path_template": "{stream}/{topic}",
+ "file_rotation": "messages",
+ "max_file_size": "16MiB",
+ "max_messages_per_file": 5000,
+ "output_format": "json_array",
+ "include_metadata": false,
+ "include_headers": true,
+ "max_attempts": 5,
+ "retry_delay": "2s",
+ "path_style": true
+ }"#;
+ let config: S3SinkConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.bucket, "my-bucket");
+ assert_eq!(config.prefix.as_deref(), Some("data/raw"));
+ assert_eq!(config.endpoint.as_deref(), Some("http://localhost:9000"));
+ assert_eq!(config.file_rotation, FileRotation::Messages);
+ assert_eq!(config.max_messages_per_file, Some(5000));
+ assert!(!config.include_metadata);
+ assert!(config.include_headers);
+ assert_eq!(config.max_attempts, Some(5));
+ assert_eq!(config.path_style, Some(true));
+ }
+
+ #[test]
+ fn partial_credentials_detected() {
+ let json =
r#"{"bucket":"b","region":"us-east-1","access_key_id":"key"}"#;
+ let config: S3SinkConfig = serde_json::from_str(json).unwrap();
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let result = rt.block_on(client::create_bucket(&config));
+ assert!(result.is_err());
+ }
+
+ #[test]
+ fn debug_does_not_leak_secrets() {
+ let json =
+
r#"{"bucket":"b","region":"r","access_key_id":"AKIA","secret_access_key":"s3cr3t"}"#;
+ let config: S3SinkConfig = serde_json::from_str(json).unwrap();
+ let debug_output = format!("{:?}", config);
+ assert!(!debug_output.contains("AKIA"));
+ assert!(!debug_output.contains("s3cr3t"));
+ assert!(debug_output.contains("REDACTED"));
+ }
+
+ #[test]
+ fn s3sink_debug_does_not_leak_bucket_credentials() {
+ let json =
r#"{"bucket":"b","region":"us-east-1","access_key_id":"AKIAIOSFODNN7EXAMPLE","secret_access_key":"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"}"#;
+ let config: S3SinkConfig = serde_json::from_str(json).unwrap();
+ let sink = S3Sink::new(1, config);
+ let debug_output = format!("{:?}", sink);
+ assert!(
+ !debug_output.contains("AKIAIOSFODNN7EXAMPLE"),
+ "Debug output must not contain the access key"
+ );
+ assert!(
+ !debug_output.contains("wJalrXUtnFEMI"),
+ "Debug output must not contain the secret key"
+ );
+ }
+
+ #[test]
+ fn max_retries_alias_accepted() {
+ let json = r#"{"bucket":"b","region":"us-east-1","max_retries":5}"#;
+ let config: S3SinkConfig = serde_json::from_str(json).unwrap();
+ assert_eq!(config.max_attempts, Some(5));
+ }
+}
diff --git a/core/connectors/sinks/s3_sink/src/path.rs
b/core/connectors/sinks/s3_sink/src/path.rs
new file mode 100644
index 000000000..30654a524
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/src/path.rs
@@ -0,0 +1,245 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::OutputFormat;
+use chrono::{DateTime, Utc};
+use iggy_connector_sdk::Error;
+
+pub(crate) struct PathContext<'a> {
+ pub stream: &'a str,
+ pub topic: &'a str,
+ pub partition_id: u32,
+ pub first_timestamp_micros: u64,
+}
+
+pub(crate) fn render_s3_key(
+ prefix: Option<&str>,
+ template: &str,
+ ctx: &PathContext<'_>,
+ offset_start: u64,
+ offset_end: u64,
+ format: OutputFormat,
+) -> Result<String, Error> {
+ let rendered = render_template(template, ctx)?;
+
+ // Partition ID is always embedded in the filename to prevent
cross-partition
+ // key collisions (partitions have independent offset spaces starting at
0).
+ let filename = format!(
+ "{:05}-{:020}-{:020}.{}",
+ ctx.partition_id,
+ offset_start,
+ offset_end,
+ format.file_extension()
+ );
+
+ let key = match prefix {
+ Some(p) => {
+ let p = p.trim_matches('/');
+ if p.is_empty() {
+ format!("{rendered}/{filename}")
+ } else {
+ format!("{p}/{rendered}/{filename}")
+ }
+ }
+ None => format!("{rendered}/{filename}"),
+ };
+
+ Ok(key)
+}
+
+fn render_template(template: &str, ctx: &PathContext<'_>) -> Result<String,
Error> {
+ let dt = timestamp_to_datetime(ctx.first_timestamp_micros)?;
+ let date = dt.format("%Y-%m-%d").to_string();
+ let hour = dt.format("%H").to_string();
+ let ts_millis = (ctx.first_timestamp_micros / 1_000).to_string();
+
+ Ok(template
+ .replace("{stream}", &sanitize_key_segment(ctx.stream))
+ .replace("{topic}", &sanitize_key_segment(ctx.topic))
+ .replace("{partition}", &ctx.partition_id.to_string())
+ .replace("{date}", &date)
+ .replace("{hour}", &hour)
+ .replace("{timestamp}", &ts_millis))
+}
+
+/// Replace characters that produce ambiguous or hard-to-list S3 key segments.
+/// Keeps `[a-zA-Z0-9._-]`, replaces everything else with `_`.
+fn sanitize_key_segment(s: &str) -> String {
+ s.chars()
+ .map(|c| {
+ if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
+ c
+ } else {
+ '_'
+ }
+ })
+ .collect()
+}
+
+fn timestamp_to_datetime(micros: u64) -> Result<DateTime<Utc>, Error> {
+ let secs = (micros / 1_000_000) as i64;
+ let nanos = ((micros % 1_000_000) * 1_000) as u32;
+ DateTime::<Utc>::from_timestamp(secs, nanos).ok_or_else(|| {
+ Error::CannotStoreData(format!(
+ "Invalid message timestamp: {micros} micros is out of range"
+ ))
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn test_ctx() -> PathContext<'static> {
+ PathContext {
+ stream: "app_logs",
+ topic: "api_requests",
+ partition_id: 1,
+ first_timestamp_micros: 1_710_597_600_000_000, //
2024-03-16T14:00:00Z
+ }
+ }
+
+ #[test]
+ fn render_default_template() {
+ let ctx = test_ctx();
+ let key = render_s3_key(
+ Some("iggy/raw"),
+ "{stream}/{topic}/{date}/{hour}",
+ &ctx,
+ 0,
+ 99,
+ OutputFormat::JsonLines,
+ )
+ .unwrap();
+ assert_eq!(
+ key,
+
"iggy/raw/app_logs/api_requests/2024-03-16/14/00001-00000000000000000000-00000000000000000099.jsonl"
+ );
+ }
+
+ #[test]
+ fn render_with_partition() {
+ let ctx = test_ctx();
+ let key = render_s3_key(
+ None,
+ "{stream}/{topic}/{partition}/{date}",
+ &ctx,
+ 100,
+ 199,
+ OutputFormat::JsonArray,
+ )
+ .unwrap();
+ assert_eq!(
+ key,
+
"app_logs/api_requests/1/2024-03-16/00001-00000000000000000100-00000000000000000199.json"
+ );
+ }
+
+ #[test]
+ fn render_no_prefix() {
+ let ctx = test_ctx();
+ let key = render_s3_key(None, "{stream}/{topic}", &ctx, 0, 9,
OutputFormat::Raw).unwrap();
+ assert_eq!(
+ key,
+
"app_logs/api_requests/00001-00000000000000000000-00000000000000000009.bin"
+ );
+ }
+
+ #[test]
+ fn render_empty_prefix() {
+ let ctx = test_ctx();
+ let key = render_s3_key(Some(""), "{stream}", &ctx, 0, 0,
OutputFormat::JsonLines).unwrap();
+ assert_eq!(
+ key,
+ "app_logs/00001-00000000000000000000-00000000000000000000.jsonl"
+ );
+ }
+
+ #[test]
+ fn render_prefix_with_trailing_slash() {
+ let ctx = test_ctx();
+ let key = render_s3_key(
+ Some("data/"),
+ "{topic}",
+ &ctx,
+ 5,
+ 10,
+ OutputFormat::JsonLines,
+ )
+ .unwrap();
+ assert_eq!(
+ key,
+
"data/api_requests/00001-00000000000000000005-00000000000000000010.jsonl"
+ );
+ }
+
+ #[test]
+ fn timestamp_deterministic_from_message() {
+ let ctx = test_ctx();
+ let key1 = render_s3_key(None, "{timestamp}", &ctx, 0, 0,
OutputFormat::Raw).unwrap();
+ let key2 = render_s3_key(None, "{timestamp}", &ctx, 0, 0,
OutputFormat::Raw).unwrap();
+ assert_eq!(key1, key2);
+ }
+
+ #[test]
+ fn timestamp_to_datetime_zero() {
+ let dt = timestamp_to_datetime(0).unwrap();
+ assert_eq!(dt.format("%Y-%m-%d").to_string(), "1970-01-01");
+ }
+
+ #[test]
+ fn timestamp_to_datetime_known() {
+ let dt = timestamp_to_datetime(1_710_597_600_000_000).unwrap();
+ assert_eq!(dt.format("%Y-%m-%dT%H").to_string(), "2024-03-16T14");
+ }
+
+ #[test]
+ fn sanitize_stream_topic_names() {
+ let ctx = PathContext {
+ stream: "my//stream",
+ topic: "topic with spaces",
+ partition_id: 0,
+ first_timestamp_micros: 1_710_597_600_000_000,
+ };
+ let key = render_s3_key(None, "{stream}/{topic}", &ctx, 0, 0,
OutputFormat::Raw).unwrap();
+ assert!(
+ !key.contains("//"),
+ "Sanitized key must not contain '//' from stream name: {key}"
+ );
+ assert!(
+ !key.contains(' '),
+ "Sanitized key must not contain spaces: {key}"
+ );
+ }
+
+ #[test]
+ fn lex_sort_correct_with_large_offsets() {
+ let ctx = test_ctx();
+ let key_small =
+ render_s3_key(None, "{stream}", &ctx, 999_900, 999_999,
OutputFormat::Raw).unwrap();
+ let key_large = render_s3_key(
+ None,
+ "{stream}",
+ &ctx,
+ 1_000_000,
+ 1_001_000,
+ OutputFormat::Raw,
+ )
+ .unwrap();
+ assert!(key_small < key_large, "Lexicographic sort must be correct");
+ }
+}
diff --git a/core/connectors/sinks/s3_sink/src/sink.rs
b/core/connectors/sinks/s3_sink/src/sink.rs
new file mode 100644
index 000000000..59b1a59f8
--- /dev/null
+++ b/core/connectors/sinks/s3_sink/src/sink.rs
@@ -0,0 +1,662 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::buffer::FileBuffer;
+use crate::formatter;
+use crate::path::{PathContext, render_s3_key};
+use crate::{BufferKey, S3Sink};
+use async_trait::async_trait;
+use iggy_connector_sdk::retry::{exponential_backoff, jitter};
+use iggy_connector_sdk::{ConsumedMessage, Error, MessagesMetadata, Sink,
TopicMetadata};
+use std::sync::Arc;
+use std::time::Duration;
+use tracing::{debug, error, info, warn};
+
+const MAX_BACKOFF: Duration = Duration::from_secs(60);
+
+struct FlushPayload {
+ data: Vec<u8>,
+ s3_key: String,
+ msg_count: u64,
+ first_offset: u64,
+ last_offset: u64,
+}
+
+#[async_trait]
+impl Sink for S3Sink {
+ async fn open(&mut self) -> Result<(), Error> {
+ info!("Opening S3 sink connector with ID: {}", self.id);
+
+ self.validate_and_parse_config()?;
+
+ let bucket = crate::client::create_bucket(&self.config).await?;
+
+ crate::client::verify_bucket(&bucket).await?;
+
+ info!(
+ "S3 sink ID: {} connected to bucket '{}' in region '{}'",
+ self.id, self.config.bucket, self.config.region
+ );
+
+ self.bucket = Some(bucket);
+
+ info!(
+ "S3 sink ID: {} opened. format={}, rotation={}, max_file_size={},
template='{}'",
+ self.id,
+ self.config.output_format,
+ self.config.file_rotation,
+ self.config.max_file_size,
+ self.config.path_template,
+ );
+
+ Ok(())
+ }
+
+ async fn consume(
+ &self,
+ topic_metadata: &TopicMetadata,
+ messages_metadata: MessagesMetadata,
+ messages: Vec<ConsumedMessage>,
+ ) -> Result<(), Error> {
+ let bucket = self
+ .bucket
+ .as_ref()
+ .ok_or_else(|| Error::InitError("S3 client not
initialized".to_string()))?;
+
+ let key = BufferKey {
+ stream: topic_metadata.stream.clone(),
+ topic: topic_metadata.topic.clone(),
+ partition_id: messages_metadata.partition_id,
+ };
+
+ let batch_size = messages.len() as u64;
+
+ {
+ let mut state = self.state.lock().await;
+ state.messages_received += batch_size;
+ }
+
+ let buffer_arc = self
+ .buffers
+ .entry(key.clone())
+ .or_insert_with(||
Arc::new(tokio::sync::Mutex::new(FileBuffer::new())))
+ .clone();
+
+ let mut processed = 0u64;
+ let result = self
+ .process_messages_inner(
+ bucket,
+ &key,
+ &buffer_arc,
+ topic_metadata,
+ &messages_metadata,
+ &messages,
+ &mut processed,
+ )
+ .await;
+
+ if let Err(ref e) = result {
+ let lost = batch_size - processed;
+ if lost > 0 {
+ let mut state = self.state.lock().await;
+ state.messages_lost += lost;
+ error!(
+ "S3 sink ID: {} lost {lost} messages from batch of
{batch_size} for {}/{}/{}: {e}",
+ self.id,
+ topic_metadata.stream,
+ topic_metadata.topic,
+ messages_metadata.partition_id,
+ );
+ }
+ }
+
+ debug!(
+ "S3 sink ID: {} buffered {} messages for {}/{}/{}",
+ self.id,
+ batch_size,
+ topic_metadata.stream,
+ topic_metadata.topic,
+ messages_metadata.partition_id,
+ );
+
+ result
+ }
+
+ async fn close(&mut self) -> Result<(), Error> {
+ info!("Closing S3 sink connector with ID: {}", self.id);
+
+ if let Some(bucket) = &self.bucket {
+ for entry in self.buffers.iter() {
+ let key = entry.key().clone();
+ let buffer_arc = entry.value().clone();
+ let flush_payload = {
+ let mut buffer = buffer_arc.lock().await;
+ if buffer.is_empty() {
+ None
+ } else {
+ Some(self.extract_flush_payload(&key, &mut buffer))
+ }
+ };
+ if let Some(Ok(payload)) = flush_payload {
+ if let Err(e) = self.do_upload(bucket, payload).await {
+ error!(
+ "S3 sink ID: {} failed to flush on close for
{}/{}/{}: {e}",
+ self.id, key.stream, key.topic, key.partition_id
+ );
+ }
+ } else if let Some(Err(e)) = flush_payload {
+ error!(
+ "S3 sink ID: {} failed to prepare flush on close for
{}/{}/{}: {e}",
+ self.id, key.stream, key.topic, key.partition_id
+ );
+ }
+ }
+ } else {
+ let pending: u64 = self
+ .buffers
+ .iter()
+ .map(|e| e.value().try_lock().map(|b|
b.message_count()).unwrap_or(0))
+ .sum();
+ if pending > 0 {
+ warn!(
+ "S3 sink ID: {} closing without S3 client — {pending}
buffered messages will be lost",
+ self.id,
+ );
+ }
+ }
+
+ let state = self.state.lock().await;
+ info!(
+ "S3 sink ID: {} closed. received={}, uploaded={}, lost={}",
+ self.id, state.messages_received, state.messages_uploaded,
state.messages_lost,
+ );
+
+ Ok(())
+ }
+}
+
+impl S3Sink {
+ #[allow(clippy::too_many_arguments)]
+ async fn process_messages_inner(
+ &self,
+ bucket: &s3::Bucket,
+ key: &BufferKey,
+ buffer_arc: &Arc<tokio::sync::Mutex<FileBuffer>>,
+ topic_metadata: &TopicMetadata,
+ messages_metadata: &MessagesMetadata,
+ messages: &[ConsumedMessage],
+ processed: &mut u64,
+ ) -> Result<(), Error> {
+ for message in messages {
+ let resolved = self.resolved();
+ let formatted = formatter::format_message(
+ message,
+ topic_metadata,
+ messages_metadata,
+ self.config.include_metadata,
+ self.config.include_headers,
+ resolved.output_format,
+ )?;
+
+ let flush_payload = {
+ let mut buffer = buffer_arc.lock().await;
+ buffer.append(&formatted, message.offset, message.timestamp);
+
+ if buffer.should_rotate(
+ self.config.file_rotation,
+ resolved.max_file_size_bytes,
+ resolved.max_messages,
+ ) {
+ Some(self.extract_flush_payload(key, &mut buffer)?)
+ } else {
+ None
+ }
+ };
+
+ if let Some(payload) = flush_payload {
+ self.do_upload(bucket, payload).await?;
+ }
+
+ *processed += 1;
+ }
+ Ok(())
+ }
+
+ fn extract_flush_payload(
+ &self,
+ key: &BufferKey,
+ buffer: &mut FileBuffer,
+ ) -> Result<FlushPayload, Error> {
+ let resolved = self.resolved();
+ let data = formatter::finalize_buffer(buffer.entries(),
resolved.output_format);
+
+ let ctx = PathContext {
+ stream: &key.stream,
+ topic: &key.topic,
+ partition_id: key.partition_id,
+ first_timestamp_micros: buffer.first_timestamp_micros(),
+ };
+
+ let s3_key = render_s3_key(
+ self.config.prefix.as_deref(),
+ &self.config.path_template,
+ &ctx,
+ buffer.first_offset(),
+ buffer.last_offset(),
+ resolved.output_format,
+ )?;
+
+ let msg_count = buffer.message_count();
+ let first_offset = buffer.first_offset();
+ let last_offset = buffer.last_offset();
+
+ buffer.reset();
+
+ Ok(FlushPayload {
+ data,
+ s3_key,
+ msg_count,
+ first_offset,
+ last_offset,
+ })
+ }
+
+ async fn do_upload(&self, bucket: &s3::Bucket, payload: FlushPayload) ->
Result<(), Error> {
+ match self
+ .upload_with_retry(bucket, &payload.s3_key, &payload.data)
+ .await
+ {
+ Ok(()) => {
+ debug!(
+ "S3 sink ID: {} uploaded {} ({} messages, {} bytes)",
+ self.id,
+ payload.s3_key,
+ payload.msg_count,
+ payload.data.len(),
+ );
+ let mut state = self.state.lock().await;
+ state.messages_uploaded += payload.msg_count;
+ Ok(())
+ }
+ Err(e) => {
+ error!(
+ "S3 sink ID: {} failed to upload {} ({} messages, offsets
{}-{} lost): {e}",
+ self.id,
+ payload.s3_key,
+ payload.msg_count,
+ payload.first_offset,
+ payload.last_offset,
+ );
+ let mut state = self.state.lock().await;
+ state.messages_lost += payload.msg_count;
+
+ self.write_lost_marker(
+ bucket,
+ &payload.s3_key,
+ payload.first_offset,
+ payload.last_offset,
+ payload.msg_count,
+ &e,
+ )
+ .await;
+
+ Err(e)
+ }
+ }
+ }
+
+ async fn write_lost_marker(
+ &self,
+ bucket: &s3::Bucket,
+ s3_key: &str,
+ first_offset: u64,
+ last_offset: u64,
+ msg_count: u64,
+ error: &Error,
+ ) {
+ let marker_key = format!("{s3_key}.lost");
+ let body = format!(
+ "offset_range: {first_offset}-{last_offset}\nmessage_count:
{msg_count}\nerror: {error}\n"
+ );
+ if let Err(e) = self
+ .upload_with_retry(bucket, &marker_key, body.as_bytes())
+ .await
+ {
+ warn!(
+ "S3 sink ID: {} failed to write .lost marker at {} after
retries: {e}",
+ self.id, marker_key
+ );
+ }
+ }
+
+ async fn upload_with_retry(
+ &self,
+ bucket: &s3::Bucket,
+ s3_key: &str,
+ data: &[u8],
+ ) -> Result<(), Error> {
+ let resolved = self.resolved();
+ let max_attempts = resolved.max_attempts;
+ let base_delay = resolved.retry_delay;
+ let mut attempt = 0u32;
+
+ loop {
+ match bucket.put_object(s3_key, data).await {
+ Ok(response) => {
+ let status = response.status_code();
+ if (200..300).contains(&status) {
+ return Ok(());
+ }
+
+ if !is_retriable_status(status) {
+ return Err(Error::CannotStoreData(format!(
+ "S3 PutObject returned non-retriable status
{status} for key '{s3_key}'"
+ )));
+ }
+
+ attempt += 1;
+ if attempt >= max_attempts {
+ return Err(Error::CannotStoreData(format!(
+ "S3 PutObject returned status {status} after
{max_attempts} attempts for key '{s3_key}'"
+ )));
+ }
+ warn!(
+ "S3 sink ID: {} PutObject status {status} (attempt
{attempt}/{max_attempts}). Retrying...",
+ self.id
+ );
+ }
+ Err(e) => {
+ attempt += 1;
+ if attempt >= max_attempts {
+ return Err(Error::CannotStoreData(format!(
+ "S3 PutObject failed after {max_attempts} attempts
for key '{s3_key}': {e}"
+ )));
+ }
+ warn!(
+ "S3 sink ID: {} PutObject error (attempt
{attempt}/{max_attempts}): {e}. Retrying...",
+ self.id
+ );
+ }
+ }
+ // exponential_backoff expects a 0-based retry index
+ let retry_index = attempt - 1;
+ let delay = jitter(exponential_backoff(base_delay, retry_index,
MAX_BACKOFF));
+ tokio::time::sleep(delay).await;
+ }
+ }
+}
+
+fn is_retriable_status(status: u16) -> bool {
+ status >= 500 || status == 408 || status == 429
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{
+ DEFAULT_MAX_FILE_SIZE, DEFAULT_OUTPUT_FORMAT, DEFAULT_PATH_TEMPLATE,
FileRotation, S3Sink,
+ S3SinkConfig,
+ };
+
+ fn test_config() -> S3SinkConfig {
+ S3SinkConfig {
+ bucket: "test-bucket".to_string(),
+ region: "us-east-1".to_string(),
+ prefix: Some("data".to_string()),
+ endpoint: None,
+ access_key_id: None,
+ secret_access_key: None,
+ path_template: DEFAULT_PATH_TEMPLATE.to_string(),
+ file_rotation: FileRotation::Size,
+ max_file_size: DEFAULT_MAX_FILE_SIZE.to_string(),
+ max_messages_per_file: None,
+ output_format: DEFAULT_OUTPUT_FORMAT.to_string(),
+ include_metadata: true,
+ include_headers: false,
+ max_attempts: None,
+ retry_delay: None,
+ path_style: None,
+ }
+ }
+
+ #[test]
+ fn is_retriable_5xx() {
+ assert!(is_retriable_status(500));
+ assert!(is_retriable_status(502));
+ assert!(is_retriable_status(503));
+ assert!(is_retriable_status(599));
+ }
+
+ #[test]
+ fn is_retriable_408_429() {
+ assert!(is_retriable_status(408));
+ assert!(is_retriable_status(429));
+ }
+
+ #[test]
+ fn not_retriable_4xx() {
+ assert!(!is_retriable_status(400));
+ assert!(!is_retriable_status(403));
+ assert!(!is_retriable_status(404));
+ assert!(!is_retriable_status(405));
+ }
+
+ #[test]
+ fn not_retriable_2xx() {
+ assert!(!is_retriable_status(200));
+ assert!(!is_retriable_status(204));
+ }
+
+ #[test]
+ fn extract_flush_payload_embeds_partition_id() {
+ let config = test_config();
+ let mut sink = S3Sink::new(1, config);
+ sink.validate_and_parse_config().unwrap();
+
+ let key = BufferKey {
+ stream: "logs".to_string(),
+ topic: "events".to_string(),
+ partition_id: 7,
+ };
+
+ let mut buffer = FileBuffer::new();
+ buffer.append(b"{\"a\":1}", 0, 1_710_597_600_000_000);
+ buffer.append(b"{\"b\":2}", 1, 1_710_597_601_000_000);
+
+ let payload = sink.extract_flush_payload(&key, &mut buffer).unwrap();
+ assert!(
+ payload.s3_key.contains("00007-"),
+ "S3 key must contain partition_id: {}",
+ payload.s3_key
+ );
+ assert_eq!(payload.msg_count, 2);
+ assert_eq!(payload.first_offset, 0);
+ assert_eq!(payload.last_offset, 1);
+ }
+
+ #[test]
+ fn extract_flush_payload_offset_padding_lex_sort() {
+ let config = test_config();
+ let mut sink = S3Sink::new(1, config);
+ sink.validate_and_parse_config().unwrap();
+
+ let key = BufferKey {
+ stream: "s".to_string(),
+ topic: "t".to_string(),
+ partition_id: 0,
+ };
+
+ let mut buf1 = FileBuffer::new();
+ buf1.append(b"x", 999_999, 1_000_000);
+ let p1 = sink.extract_flush_payload(&key, &mut buf1).unwrap();
+
+ let mut buf2 = FileBuffer::new();
+ buf2.append(b"y", 1_000_000, 1_000_000);
+ let p2 = sink.extract_flush_payload(&key, &mut buf2).unwrap();
+
+ assert!(
+ p1.s3_key < p2.s3_key,
+ "Lex sort must be correct: {} < {}",
+ p1.s3_key,
+ p2.s3_key
+ );
+ }
+
+ #[test]
+ fn validate_max_messages_set_for_rotation_by_messages() {
+ let config = S3SinkConfig {
+ file_rotation: FileRotation::Messages,
+ max_messages_per_file: Some(500),
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ sink.validate_and_parse_config().unwrap();
+ assert_eq!(sink.resolved().max_messages, 500);
+ }
+
+ #[test]
+ fn validate_rejects_messages_rotation_without_max() {
+ let config = S3SinkConfig {
+ file_rotation: FileRotation::Messages,
+ max_messages_per_file: None,
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ assert!(sink.validate_and_parse_config().is_err());
+ }
+
+ #[test]
+ fn validate_rejects_zero_max_messages() {
+ let config = S3SinkConfig {
+ file_rotation: FileRotation::Messages,
+ max_messages_per_file: Some(0),
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ assert!(sink.validate_and_parse_config().is_err());
+ }
+
+ #[test]
+ fn validate_rejects_zero_max_file_size() {
+ let config = S3SinkConfig {
+ max_file_size: "0B".to_string(),
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ assert!(sink.validate_and_parse_config().is_err());
+ }
+
+ #[test]
+ fn validate_rejects_over_5gib_file_size() {
+ let config = S3SinkConfig {
+ max_file_size: "10GiB".to_string(),
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ assert!(sink.validate_and_parse_config().is_err());
+ }
+
+ #[test]
+ fn validate_rejects_empty_bucket() {
+ let config = S3SinkConfig {
+ bucket: "".to_string(),
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ assert!(sink.validate_and_parse_config().is_err());
+ }
+
+ #[test]
+ fn validate_rejects_empty_region() {
+ let config = S3SinkConfig {
+ region: "".to_string(),
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ assert!(sink.validate_and_parse_config().is_err());
+ }
+
+ #[test]
+ fn validate_rejects_empty_path_template() {
+ let config = S3SinkConfig {
+ path_template: "".to_string(),
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ assert!(sink.validate_and_parse_config().is_err());
+ }
+
+ #[test]
+ fn validate_size_rotation_max_messages_defaults_to_max() {
+ let config = test_config();
+ let mut sink = S3Sink::new(1, config);
+ sink.validate_and_parse_config().unwrap();
+ assert_eq!(sink.resolved().max_messages, u64::MAX);
+ }
+
+ #[test]
+ fn flush_payload_data_is_json_lines() {
+ let config = test_config();
+ let mut sink = S3Sink::new(1, config);
+ sink.validate_and_parse_config().unwrap();
+
+ let key = BufferKey {
+ stream: "s".to_string(),
+ topic: "t".to_string(),
+ partition_id: 0,
+ };
+
+ let mut buffer = FileBuffer::new();
+ buffer.append(b"{\"a\":1}", 0, 1_000_000);
+ buffer.append(b"{\"b\":2}", 1, 2_000_000);
+
+ let payload = sink.extract_flush_payload(&key, &mut buffer).unwrap();
+ assert_eq!(payload.data, b"{\"a\":1}\n{\"b\":2}\n");
+ }
+
+ #[test]
+ fn flush_payload_data_is_json_array() {
+ let config = S3SinkConfig {
+ output_format: "json_array".to_string(),
+ ..test_config()
+ };
+ let mut sink = S3Sink::new(1, config);
+ sink.validate_and_parse_config().unwrap();
+
+ let key = BufferKey {
+ stream: "s".to_string(),
+ topic: "t".to_string(),
+ partition_id: 0,
+ };
+
+ let mut buffer = FileBuffer::new();
+ buffer.append(b"{\"a\":1}", 0, 1_000_000);
+ buffer.append(b"{\"b\":2}", 1, 2_000_000);
+
+ let payload = sink.extract_flush_payload(&key, &mut buffer).unwrap();
+ assert_eq!(payload.data, b"[{\"a\":1},{\"b\":2}]");
+ }
+
+ #[test]
+ fn close_without_bucket_does_not_panic() {
+ let config = test_config();
+ let mut sink = S3Sink::new(1, config);
+ let rt = tokio::runtime::Runtime::new().unwrap();
+ let result = rt.block_on(sink.close());
+ assert!(result.is_ok());
+ }
+}
diff --git a/core/integration/Cargo.toml b/core/integration/Cargo.toml
index f0cddf0c0..e42d82385 100644
--- a/core/integration/Cargo.toml
+++ b/core/integration/Cargo.toml
@@ -74,6 +74,7 @@ rmcp = { workspace = true, features = [
"transport-streamable-http-client",
"transport-streamable-http-client-reqwest",
] }
+rust-s3 = { workspace = true }
secrecy = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
diff --git a/core/integration/tests/cli/common/keyring.rs
b/core/integration/tests/cli/common/keyring.rs
index 21baf946c..a6ff49543 100644
--- a/core/integration/tests/cli/common/keyring.rs
+++ b/core/integration/tests/cli/common/keyring.rs
@@ -48,4 +48,5 @@ mod backend {
pub(crate) use backend::ensure_keyring_store;
#[cfg(not(all(feature = "login-session", secret_service_keyring)))]
+#[allow(dead_code)]
pub(crate) fn ensure_keyring_store() {}
diff --git a/core/integration/tests/cli/common/mod.rs
b/core/integration/tests/cli/common/mod.rs
index 7da9f1275..b26fb1afb 100644
--- a/core/integration/tests/cli/common/mod.rs
+++ b/core/integration/tests/cli/common/mod.rs
@@ -20,6 +20,7 @@ pub(crate) mod help;
pub(crate) mod keyring;
pub(crate) use crate::cli::common::command::IggyCmdCommand;
pub(crate) use crate::cli::common::help::{CLAP_INDENT, TestHelpCmd,
USAGE_PREFIX};
+#[allow(unused_imports)]
pub(crate) use crate::cli::common::keyring::ensure_keyring_store;
use assert_cmd::assert::{Assert, OutputAssertExt};
use assert_cmd::prelude::CommandCargoExt;
diff --git a/core/integration/tests/connectors/fixtures/mod.rs
b/core/integration/tests/connectors/fixtures/mod.rs
index 0b2f264d0..369cde392 100644
--- a/core/integration/tests/connectors/fixtures/mod.rs
+++ b/core/integration/tests/connectors/fixtures/mod.rs
@@ -26,6 +26,7 @@ mod influxdb;
mod mongodb;
mod postgres;
mod quickwit;
+mod s3;
mod wiremock;
/// Prefix on every test container name so `just clean-test-containers` reaps
@@ -72,4 +73,5 @@ pub use postgres::{
PostgresSourceJsonbFixture, PostgresSourceMarkFixture, PostgresSourceOps,
};
pub use quickwit::{QuickwitFixture, QuickwitOps, QuickwitPreCreatedFixture};
+pub use s3::{S3SinkFixture, S3SinkOps, S3SinkRotationFixture};
pub use wiremock::{WireMockDirectFixture, WireMockWrappedFixture};
diff --git a/core/integration/tests/connectors/fixtures/s3/fixture.rs
b/core/integration/tests/connectors/fixtures/s3/fixture.rs
new file mode 100644
index 000000000..adeb03405
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/s3/fixture.rs
@@ -0,0 +1,308 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use async_trait::async_trait;
+use integration::harness::seeds;
+use integration::harness::{TestBinaryError, TestFixture};
+use s3::creds::Credentials;
+use s3::{Bucket, Region};
+use std::collections::HashMap;
+use testcontainers_modules::testcontainers::core::wait::HttpWaitStrategy;
+use testcontainers_modules::testcontainers::core::{IntoContainerPort, WaitFor};
+use testcontainers_modules::testcontainers::runners::AsyncRunner;
+use testcontainers_modules::testcontainers::{ContainerAsync, GenericImage,
ImageExt};
+use tracing::info;
+use uuid::Uuid;
+
+const MINIO_IMAGE: &str = "minio/minio";
+const MINIO_TAG: &str = "RELEASE.2025-09-07T16-13-09Z";
+const MINIO_PORT: u16 = 9000;
+const MINIO_CONSOLE_PORT: u16 = 9001;
+
+const MINIO_ACCESS_KEY: &str = "admin";
+const MINIO_SECRET_KEY: &str = "password";
+const MINIO_BUCKET: &str = "iggy-s3-test";
+
+const ENV_SINK_PATH: &str = "IGGY_CONNECTORS_SINK_S3_PATH";
+const ENV_SINK_STREAMS_0_STREAM: &str =
"IGGY_CONNECTORS_SINK_S3_STREAMS_0_STREAM";
+const ENV_SINK_STREAMS_0_TOPICS: &str =
"IGGY_CONNECTORS_SINK_S3_STREAMS_0_TOPICS";
+const ENV_SINK_STREAMS_0_SCHEMA: &str =
"IGGY_CONNECTORS_SINK_S3_STREAMS_0_SCHEMA";
+const ENV_SINK_PLUGIN_BUCKET: &str =
"IGGY_CONNECTORS_SINK_S3_PLUGIN_CONFIG_BUCKET";
+const ENV_SINK_PLUGIN_REGION: &str =
"IGGY_CONNECTORS_SINK_S3_PLUGIN_CONFIG_REGION";
+const ENV_SINK_PLUGIN_ENDPOINT: &str =
"IGGY_CONNECTORS_SINK_S3_PLUGIN_CONFIG_ENDPOINT";
+const ENV_SINK_PLUGIN_PREFIX: &str =
"IGGY_CONNECTORS_SINK_S3_PLUGIN_CONFIG_PREFIX";
+const ENV_SINK_PLUGIN_ACCESS_KEY: &str =
"IGGY_CONNECTORS_SINK_S3_PLUGIN_CONFIG_ACCESS_KEY_ID";
+const ENV_SINK_PLUGIN_SECRET_KEY: &str =
"IGGY_CONNECTORS_SINK_S3_PLUGIN_CONFIG_SECRET_ACCESS_KEY";
+const ENV_SINK_PLUGIN_FILE_ROTATION: &str =
"IGGY_CONNECTORS_SINK_S3_PLUGIN_CONFIG_FILE_ROTATION";
+const ENV_SINK_PLUGIN_MAX_MESSAGES: &str =
+ "IGGY_CONNECTORS_SINK_S3_PLUGIN_CONFIG_MAX_MESSAGES_PER_FILE";
+
+const DEFAULT_MAX_MESSAGES_PER_FILE: usize = 5;
+const POLL_ATTEMPTS: usize = 30;
+const POLL_INTERVAL_MS: u64 = 500;
+
+pub trait S3SinkOps: Sync {
+ fn bucket(&self) -> &Bucket;
+ #[allow(dead_code)]
+ fn endpoint(&self) -> &str;
+
+ fn list_objects(
+ &self,
+ prefix: &str,
+ ) -> impl std::future::Future<Output = Result<Vec<String>,
TestBinaryError>> + Send {
+ async move {
+ let results = self
+ .bucket()
+ .list(prefix.to_string(), None)
+ .await
+ .map_err(|e| TestBinaryError::InvalidState {
+ message: format!("Failed to list objects: {e}"),
+ })?;
+
+ let keys: Vec<String> = results
+ .iter()
+ .flat_map(|r| r.contents.iter().map(|o| o.key.clone()))
+ .collect();
+ Ok(keys)
+ }
+ }
+
+ fn get_object(
+ &self,
+ key: &str,
+ ) -> impl std::future::Future<Output = Result<Vec<u8>, TestBinaryError>> +
Send {
+ async move {
+ let response =
+ self.bucket()
+ .get_object(key)
+ .await
+ .map_err(|e| TestBinaryError::InvalidState {
+ message: format!("Failed to get object '{key}': {e}"),
+ })?;
+ Ok(response.to_vec())
+ }
+ }
+
+ fn wait_for_objects(
+ &self,
+ prefix: &str,
+ min_objects: usize,
+ ) -> impl std::future::Future<Output = Result<Vec<String>,
TestBinaryError>> + Send {
+ async move {
+ for _ in 0..POLL_ATTEMPTS {
+ let keys = self.list_objects(prefix).await?;
+ if keys.len() >= min_objects {
+ info!(
+ "Found {} objects under prefix '{}' (required: {})",
+ keys.len(),
+ prefix,
+ min_objects
+ );
+ return Ok(keys);
+ }
+
tokio::time::sleep(std::time::Duration::from_millis(POLL_INTERVAL_MS)).await;
+ }
+
+ let keys = self.list_objects(prefix).await?;
+ Err(TestBinaryError::InvalidState {
+ message: format!(
+ "Expected at least {min_objects} objects under '{prefix}',
found {} after {POLL_ATTEMPTS} attempts",
+ keys.len()
+ ),
+ })
+ }
+ }
+}
+
+pub struct S3SinkFixture {
+ #[allow(dead_code)]
+ container: ContainerAsync<GenericImage>,
+ bucket: Box<Bucket>,
+ endpoint: String,
+}
+
+impl S3SinkOps for S3SinkFixture {
+ fn bucket(&self) -> &Bucket {
+ &self.bucket
+ }
+
+ fn endpoint(&self) -> &str {
+ &self.endpoint
+ }
+}
+
+#[async_trait]
+impl TestFixture for S3SinkFixture {
+ async fn setup() -> Result<Self, TestBinaryError> {
+ let id = Uuid::new_v4();
+ let container_name = format!("minio-s3-{id}");
+
+ let container = GenericImage::new(MINIO_IMAGE, MINIO_TAG)
+ .with_exposed_port(MINIO_PORT.tcp())
+ .with_exposed_port(MINIO_CONSOLE_PORT.tcp())
+ .with_wait_for(WaitFor::http(
+ HttpWaitStrategy::new("/minio/health/live")
+ .with_port(MINIO_PORT.tcp())
+ .with_expected_status_code(200u16),
+ ))
+ .with_container_name(&container_name)
+ .with_env_var("MINIO_ROOT_USER", MINIO_ACCESS_KEY)
+ .with_env_var("MINIO_ROOT_PASSWORD", MINIO_SECRET_KEY)
+ .with_cmd(vec!["server", "/data", "--console-address", ":9001"])
+ .with_mapped_port(0, MINIO_PORT.tcp())
+ .with_mapped_port(0, MINIO_CONSOLE_PORT.tcp())
+ .start()
+ .await
+ .map_err(|error| TestBinaryError::FixtureSetup {
+ fixture_type: "S3SinkFixture".to_string(),
+ message: format!("Failed to start MinIO container: {error}"),
+ })?;
+
+ let mapped_port = container
+ .ports()
+ .await
+ .map_err(|error| TestBinaryError::FixtureSetup {
+ fixture_type: "S3SinkFixture".to_string(),
+ message: format!("Failed to get ports: {error}"),
+ })?
+ .map_to_host_port_ipv4(MINIO_PORT)
+ .ok_or_else(|| TestBinaryError::FixtureSetup {
+ fixture_type: "S3SinkFixture".to_string(),
+ message: "No mapping for MinIO port".to_string(),
+ })?;
+
+ let endpoint = format!("http://localhost:{mapped_port}");
+ info!("MinIO container for S3 sink available at {endpoint}");
+
+ let region = Region::Custom {
+ region: "us-east-1".to_string(),
+ endpoint: endpoint.clone(),
+ };
+ let credentials = Credentials::new(
+ Some(MINIO_ACCESS_KEY),
+ Some(MINIO_SECRET_KEY),
+ None,
+ None,
+ None,
+ )
+ .map_err(|e| TestBinaryError::FixtureSetup {
+ fixture_type: "S3SinkFixture".to_string(),
+ message: format!("Failed to create credentials: {e}"),
+ })?;
+
+ let config = s3::BucketConfiguration::default();
+ let response = Bucket::create_with_path_style(
+ MINIO_BUCKET,
+ region.clone(),
+ credentials.clone(),
+ config,
+ )
+ .await
+ .map_err(|e| TestBinaryError::FixtureSetup {
+ fixture_type: "S3SinkFixture".to_string(),
+ message: format!("Failed to create bucket: {e}"),
+ })?;
+ info!(
+ "S3 bucket '{}' ready (status: {})",
+ MINIO_BUCKET, response.response_code
+ );
+
+ let mut bucket = Bucket::new(MINIO_BUCKET, region,
credentials).map_err(|e| {
+ TestBinaryError::FixtureSetup {
+ fixture_type: "S3SinkFixture".to_string(),
+ message: format!("Failed to create bucket handle: {e}"),
+ }
+ })?;
+ bucket.set_path_style();
+
+ Ok(Self {
+ container,
+ bucket,
+ endpoint,
+ })
+ }
+
+ fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+ let mut envs = HashMap::new();
+ envs.insert(
+ ENV_SINK_PATH.to_string(),
+ "../../target/debug/libiggy_connector_s3_sink".to_string(),
+ );
+ envs.insert(
+ ENV_SINK_STREAMS_0_STREAM.to_string(),
+ seeds::names::STREAM.to_string(),
+ );
+ envs.insert(
+ ENV_SINK_STREAMS_0_TOPICS.to_string(),
+ format!("[{}]", seeds::names::TOPIC),
+ );
+ envs.insert(ENV_SINK_STREAMS_0_SCHEMA.to_string(), "json".to_string());
+ envs.insert(ENV_SINK_PLUGIN_BUCKET.to_string(),
MINIO_BUCKET.to_string());
+ envs.insert(ENV_SINK_PLUGIN_REGION.to_string(),
"us-east-1".to_string());
+ envs.insert(ENV_SINK_PLUGIN_ENDPOINT.to_string(),
self.endpoint.clone());
+ envs.insert(ENV_SINK_PLUGIN_PREFIX.to_string(), String::new());
+ envs.insert(
+ ENV_SINK_PLUGIN_ACCESS_KEY.to_string(),
+ MINIO_ACCESS_KEY.to_string(),
+ );
+ envs.insert(
+ ENV_SINK_PLUGIN_SECRET_KEY.to_string(),
+ MINIO_SECRET_KEY.to_string(),
+ );
+ envs.insert(
+ ENV_SINK_PLUGIN_FILE_ROTATION.to_string(),
+ "messages".to_string(),
+ );
+ envs.insert(
+ ENV_SINK_PLUGIN_MAX_MESSAGES.to_string(),
+ DEFAULT_MAX_MESSAGES_PER_FILE.to_string(),
+ );
+ envs
+ }
+}
+
+pub struct S3SinkRotationFixture {
+ inner: S3SinkFixture,
+}
+
+impl S3SinkOps for S3SinkRotationFixture {
+ fn bucket(&self) -> &Bucket {
+ self.inner.bucket()
+ }
+
+ fn endpoint(&self) -> &str {
+ self.inner.endpoint()
+ }
+}
+
+#[async_trait]
+impl TestFixture for S3SinkRotationFixture {
+ async fn setup() -> Result<Self, TestBinaryError> {
+ let inner = S3SinkFixture::setup().await?;
+ Ok(Self { inner })
+ }
+
+ fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+ let mut envs = self.inner.connectors_runtime_envs();
+ envs.insert(
+ ENV_SINK_PLUGIN_FILE_ROTATION.to_string(),
+ "messages".to_string(),
+ );
+ envs.insert(ENV_SINK_PLUGIN_MAX_MESSAGES.to_string(),
"10".to_string());
+ envs
+ }
+}
diff --git a/core/integration/tests/connectors/fixtures/s3/mod.rs
b/core/integration/tests/connectors/fixtures/s3/mod.rs
new file mode 100644
index 000000000..fd30845a1
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/s3/mod.rs
@@ -0,0 +1,20 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod fixture;
+
+pub use fixture::{S3SinkFixture, S3SinkOps, S3SinkRotationFixture};
diff --git a/core/integration/tests/connectors/mod.rs
b/core/integration/tests/connectors/mod.rs
index fc624f897..865712175 100644
--- a/core/integration/tests/connectors/mod.rs
+++ b/core/integration/tests/connectors/mod.rs
@@ -30,6 +30,7 @@ mod quickwit;
mod random;
mod random_source_liveness;
mod runtime;
+mod s3;
mod stdout;
use iggy_common::IggyTimestamp;
diff --git a/core/integration/tests/connectors/s3/mod.rs
b/core/integration/tests/connectors/s3/mod.rs
new file mode 100644
index 000000000..4f2135d8d
--- /dev/null
+++ b/core/integration/tests/connectors/s3/mod.rs
@@ -0,0 +1,18 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+mod s3_sink;
diff --git a/core/integration/tests/connectors/s3/s3_sink.rs
b/core/integration/tests/connectors/s3/s3_sink.rs
new file mode 100644
index 000000000..22258aba2
--- /dev/null
+++ b/core/integration/tests/connectors/s3/s3_sink.rs
@@ -0,0 +1,196 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use crate::connectors::create_test_messages;
+use crate::connectors::fixtures::{S3SinkFixture, S3SinkOps,
S3SinkRotationFixture};
+use bytes::Bytes;
+use iggy::prelude::{IggyMessage, Partitioning};
+use iggy_common::Identifier;
+use iggy_common::MessageClient;
+use iggy_connector_sdk::api::SinkInfoResponse;
+use integration::harness::seeds;
+use integration::iggy_harness;
+use reqwest::Client;
+
+const API_KEY: &str = "test-api-key";
+const S3_SINK_KEY: &str = "s3";
+
+#[iggy_harness(
+ server(connectors_runtime(config_path = "tests/connectors/s3/sink.toml")),
+ seed = seeds::connector_stream
+)]
+async fn s3_sink_initializes_and_runs(harness: &TestHarness, fixture:
S3SinkFixture) {
+ let api_address = harness
+ .connectors_runtime()
+ .expect("connector runtime should be available")
+ .http_url();
+ let http_client = Client::new();
+
+ let response = http_client
+ .get(format!("{}/sinks", api_address))
+ .header("api-key", API_KEY)
+ .send()
+ .await
+ .expect("Failed to get sinks");
+
+ assert_eq!(response.status(), 200);
+ let sinks: Vec<SinkInfoResponse> = response.json().await.expect("Failed to
parse sinks");
+
+ assert_eq!(sinks.len(), 1);
+ assert_eq!(sinks[0].key, S3_SINK_KEY);
+ assert!(sinks[0].enabled);
+
+ drop(fixture);
+}
+
+#[iggy_harness(
+ server(connectors_runtime(config_path = "tests/connectors/s3/sink.toml")),
+ seed = seeds::connector_stream
+)]
+async fn s3_sink_writes_jsonl_with_correct_layout(harness: &TestHarness,
fixture: S3SinkFixture) {
+ let client = harness.root_client().await.unwrap();
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+ let message_count = 5;
+ let test_messages = create_test_messages(message_count);
+ let payloads: Vec<Bytes> = test_messages
+ .iter()
+ .map(|m| Bytes::from(serde_json::to_vec(m).expect("serialize")))
+ .collect();
+
+ let mut messages: Vec<IggyMessage> = payloads
+ .iter()
+ .enumerate()
+ .map(|(i, p)| {
+ IggyMessage::builder()
+ .id((i + 1) as u128)
+ .payload(p.clone())
+ .build()
+ .expect("build message")
+ })
+ .collect();
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .expect("send messages");
+
+ let prefix = format!("{}/", seeds::names::STREAM);
+ let keys = fixture
+ .wait_for_objects(&prefix, 1)
+ .await
+ .expect("wait for S3 objects");
+
+ assert!(!keys.is_empty(), "Expected at least one S3 object");
+
+ let key = &keys[0];
+ assert!(
+ key.contains(seeds::names::STREAM),
+ "Key must contain stream name: {key}"
+ );
+ assert!(
+ key.contains(seeds::names::TOPIC),
+ "Key must contain topic name: {key}"
+ );
+ assert!(key.ends_with(".jsonl"), "Key must end with .jsonl: {key}");
+ assert!(
+ key.contains("/00000-"),
+ "Key must contain partition_id (00000): {key}"
+ );
+
+ let data = fixture.get_object(key).await.expect("get S3 object");
+ let content = String::from_utf8(data).expect("valid utf8");
+ let lines: Vec<&str> = content.trim().lines().collect();
+ assert_eq!(
+ lines.len(),
+ message_count,
+ "Expected {message_count} lines in JSONL output"
+ );
+
+ for line in &lines {
+ let value: serde_json::Value =
serde_json::from_str(line).expect("valid JSON line");
+ assert!(value.get("offset").is_some(), "Line must have offset");
+ assert!(value.get("timestamp").is_some(), "Line must have timestamp");
+ assert!(value.get("stream").is_some(), "Line must have stream");
+ assert!(value.get("payload").is_some(), "Line must have payload");
+ }
+}
+
+#[iggy_harness(
+ server(connectors_runtime(config_path =
"tests/connectors/s3/sink_rotation.toml")),
+ seed = seeds::connector_stream
+)]
+async fn s3_sink_rotates_on_message_count(harness: &TestHarness, fixture:
S3SinkRotationFixture) {
+ let client = harness.root_client().await.unwrap();
+ let stream_id: Identifier = seeds::names::STREAM.try_into().unwrap();
+ let topic_id: Identifier = seeds::names::TOPIC.try_into().unwrap();
+
+ let message_count = 25;
+ let test_messages = create_test_messages(message_count);
+ let payloads: Vec<Bytes> = test_messages
+ .iter()
+ .map(|m| Bytes::from(serde_json::to_vec(m).expect("serialize")))
+ .collect();
+
+ let mut messages: Vec<IggyMessage> = payloads
+ .iter()
+ .enumerate()
+ .map(|(i, p)| {
+ IggyMessage::builder()
+ .id((i + 1) as u128)
+ .payload(p.clone())
+ .build()
+ .expect("build message")
+ })
+ .collect();
+
+ client
+ .send_messages(
+ &stream_id,
+ &topic_id,
+ &Partitioning::partition_id(0),
+ &mut messages,
+ )
+ .await
+ .expect("send messages");
+
+ let prefix = format!("{}/", seeds::names::STREAM);
+ let keys = fixture
+ .wait_for_objects(&prefix, 2)
+ .await
+ .expect("wait for rotated S3 objects");
+
+ assert!(
+ keys.len() >= 2,
+ "Expected at least 2 S3 objects from rotation
(max_messages_per_file=10, sent 25), got {}",
+ keys.len()
+ );
+
+ for key in &keys {
+ assert!(key.ends_with(".jsonl"), "All keys must end with .jsonl");
+ assert!(
+ key.contains("/00000-"),
+ "All keys must contain partition_id"
+ );
+ }
+}
diff --git a/core/integration/tests/connectors/s3/sink.toml
b/core/integration/tests/connectors/s3/sink.toml
new file mode 100644
index 000000000..833bf6fa6
--- /dev/null
+++ b/core/integration/tests/connectors/s3/sink.toml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[connectors]
+config_type = "local"
+config_dir = "../connectors/sinks/s3_sink"
diff --git a/core/integration/tests/connectors/s3/sink_rotation.toml
b/core/integration/tests/connectors/s3/sink_rotation.toml
new file mode 100644
index 000000000..833bf6fa6
--- /dev/null
+++ b/core/integration/tests/connectors/s3/sink_rotation.toml
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[connectors]
+config_type = "local"
+config_dir = "../connectors/sinks/s3_sink"