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 fe5612964 perf(connectors): stop cloning JSON leaves when the caller 
already owns them (#4399)
fe5612964 is described below

commit fe56129640f6fae2afd56f8bcca9f9edce256eaa
Author: Matthew Patton <[email protected]>
AuthorDate: Tue Oct 13 05:21:27 2026 -0400

    perf(connectors): stop cloning JSON leaves when the caller already owns 
them (#4399)
---
 .claude/skills/connector-sdk/SKILL.md             |   4 +-
 core/connectors/sdk/src/convert.rs                | 128 ++++++++++++++++--
 core/connectors/sdk/src/encoders/avro.rs          |   4 +-
 core/connectors/sdk/src/lib.rs                    |   2 +-
 core/connectors/sinks/delta_sink/src/sink.rs      |  42 ++++--
 core/connectors/sinks/http_sink/src/lib.rs        |  58 +-------
 core/connectors/sinks/meilisearch_sink/src/lib.rs |  28 +---
 core/connectors/sinks/s3_sink/src/formatter.rs    |  95 +++++++++----
 core/connectors/sinks/s3_sink/src/sink.rs         | 158 +++++++++++++++++++++-
 core/connectors/sinks/surrealdb_sink/src/lib.rs   |  15 +-
 10 files changed, 389 insertions(+), 145 deletions(-)

diff --git a/.claude/skills/connector-sdk/SKILL.md 
b/.claude/skills/connector-sdk/SKILL.md
index 440e9a1ec..36f7b3247 100644
--- a/.claude/skills/connector-sdk/SKILL.md
+++ b/.claude/skills/connector-sdk/SKILL.md
@@ -45,7 +45,7 @@ sdk/src/
 ├── sink.rs             SinkContainer + sink_connector! macro (FFI plumbing).
 ├── source.rs           SourceContainer + source_connector! macro (FFI 
plumbing).
 ├── api.rs              ConnectorStatus, ConnectorStats (feature = "api").
-├── convert.rs          owned_value_to_serde_json (simd_json ⇄ serde_json 
bridge).
+├── convert.rs          owned_value_into_serde_json / 
owned_value_to_serde_json (simd_json ⇄ serde_json bridge).
 ├── log.rs              CallbackLayer for tracing across FFI.
 ├── retry.rs            retry_async + RetryPolicy, CircuitBreaker, 
HttpRetryMiddleware.
 ├── decoders/           One per schema: json, raw, text, proto, flatbuffer, 
avro.
@@ -60,7 +60,7 @@ sdk/src/
 2. **`Send + Sync`** on every public trait (FFI runs across thread pools).
 3. **`#[repr(C)]`** on every type that crosses FFI (`Schema`, `TopicMetadata`, 
`MessagesMetadata`, `RawMessage`, `ProducedMessages`, `ConsumedMessage`, 
`DecodedMessage`, etc. in `lib.rs`). Adding fields requires bumping the SDK 
version - existing plugins built against the old layout will misalign on 
`postcard::from_bytes`.
 4. **postcard** for FFI message serialization (handled by 
`SinkContainer::consume`, `SourceContainer`'s `handle_messages`). **MessagePack 
(`rmp_serde`)** for `ConnectorState`. **JSON** (`serde_json`) only for 
human-editable config that crosses FFI. Don't mix.
-5. **`simd_json::OwnedValue`** for JSON payloads, not `serde_json::Value`. Use 
`convert::owned_value_to_serde_json` as a bridge when interop is required.
+5. **`simd_json::OwnedValue`** for JSON payloads, not `serde_json::Value`. Use 
`convert::owned_value_into_serde_json` as a bridge when you own the value (the 
common case - it moves strings instead of copying them); fall back to the 
borrowing `convert::owned_value_to_serde_json` only when you still need the 
`OwnedValue` afterward.
 6. **`BTreeMap`** for headers - deterministic ordering. Never `HashMap` on the 
wire.
 7. **No breaking changes** to 
`Sink`/`Source`/`StreamDecoder`/`StreamEncoder`/`Transform` trait signatures 
without coordinating with all in-tree plugins in the same PR.
 8. **A `Schema` variant that changes meaning is as breaking as a new 
variant.** The discriminant crosses FFI unchanged, so a plugin built against 
the old SDK keeps decoding it the old way and misreads every batch with no 
error anywhere. Same process as a trait change: bump the SDK minor version, 
update every in-tree plugin in the same PR, and state in the PR description 
that plugins built against the previous SDK must be rebuilt.
diff --git a/core/connectors/sdk/src/convert.rs 
b/core/connectors/sdk/src/convert.rs
index 993e052b4..09896fb0c 100644
--- a/core/connectors/sdk/src/convert.rs
+++ b/core/connectors/sdk/src/convert.rs
@@ -23,20 +23,12 @@
 use crate::Payload;
 
 /// Convert `simd_json::OwnedValue` to `serde_json::Value` via direct 
structural mapping.
+/// NaN/Infinity f64 values are mapped to `null`.
 ///
-/// NaN/Infinity f64 values are mapped to `null` since JSON has no 
representation
-/// for these IEEE 754 special values.
+/// Prefer [`owned_value_into_serde_json`] when you own the value.
 pub fn owned_value_to_serde_json(value: &simd_json::OwnedValue) -> 
serde_json::Value {
     match value {
-        simd_json::OwnedValue::Static(s) => match s {
-            simd_json::StaticNode::Null => serde_json::Value::Null,
-            simd_json::StaticNode::Bool(b) => serde_json::Value::Bool(*b),
-            simd_json::StaticNode::I64(n) => 
serde_json::Value::Number((*n).into()),
-            simd_json::StaticNode::U64(n) => 
serde_json::Value::Number((*n).into()),
-            simd_json::StaticNode::F64(n) => serde_json::Number::from_f64(*n)
-                .map(serde_json::Value::Number)
-                .unwrap_or(serde_json::Value::Null),
-        },
+        simd_json::OwnedValue::Static(s) => static_node_to_serde_json(*s),
         simd_json::OwnedValue::String(s) => 
serde_json::Value::String(s.to_string()),
         simd_json::OwnedValue::Array(arr) => {
             
serde_json::Value::Array(arr.iter().map(owned_value_to_serde_json).collect())
@@ -51,6 +43,40 @@ pub fn owned_value_to_serde_json(value: 
&simd_json::OwnedValue) -> serde_json::V
     }
 }
 
+/// Convert an owned `simd_json::OwnedValue` to `serde_json::Value` via direct 
structural mapping.
+/// NaN/Infinity f64 values are mapped to `null`.
+///
+/// Strings and object keys move into the result instead of being copied.
+pub fn owned_value_into_serde_json(value: simd_json::OwnedValue) -> 
serde_json::Value {
+    match value {
+        simd_json::OwnedValue::Static(s) => static_node_to_serde_json(s),
+        simd_json::OwnedValue::String(s) => serde_json::Value::String(s),
+        simd_json::OwnedValue::Array(arr) => {
+            
serde_json::Value::Array(arr.into_iter().map(owned_value_into_serde_json).collect())
+        }
+        simd_json::OwnedValue::Object(obj) => {
+            let map: serde_json::Map<String, serde_json::Value> = obj
+                .into_iter()
+                .map(|(k, v)| (k, owned_value_into_serde_json(v)))
+                .collect();
+            serde_json::Value::Object(map)
+        }
+    }
+}
+
+/// Converts a `StaticNode` leaf. Unlike `simd_json::OwnedValue`'s own 
`TryInto<serde_json::Value>`, NaN/Infinity map to `null` here instead of 
erroring.
+fn static_node_to_serde_json(node: simd_json::StaticNode) -> serde_json::Value 
{
+    match node {
+        simd_json::StaticNode::Null => serde_json::Value::Null,
+        simd_json::StaticNode::Bool(b) => serde_json::Value::Bool(b),
+        simd_json::StaticNode::I64(n) => serde_json::Value::Number(n.into()),
+        simd_json::StaticNode::U64(n) => serde_json::Value::Number(n.into()),
+        simd_json::StaticNode::F64(n) => serde_json::Number::from_f64(n)
+            .map(serde_json::Value::Number)
+            .unwrap_or(serde_json::Value::Null),
+    }
+}
+
 /// Renames each key of a `Payload::Json` object via `rename`; other payloads 
pass through. Collisions keep the later entry while the map is Vec-backed, 
unspecified once hash-backed.
 pub(crate) fn apply_field_mappings(
     payload: Payload,
@@ -191,6 +217,38 @@ mod tests {
         );
     }
 
+    #[test]
+    fn test_into_matches_to_for_every_variant() {
+        let mut populated_obj = simd_json::owned::Object::new();
+        populated_obj.insert("k".into(), OwnedValue::String("v".into()));
+        let cases = [
+            OwnedValue::Static(StaticNode::Null),
+            OwnedValue::Static(StaticNode::Bool(true)),
+            OwnedValue::Static(StaticNode::Bool(false)),
+            OwnedValue::Static(StaticNode::I64(-42)),
+            OwnedValue::Static(StaticNode::U64(42)),
+            OwnedValue::Static(StaticNode::F64(3.54)),
+            OwnedValue::Static(StaticNode::F64(f64::NAN)),
+            OwnedValue::Static(StaticNode::F64(f64::INFINITY)),
+            OwnedValue::Static(StaticNode::F64(f64::NEG_INFINITY)),
+            OwnedValue::String("hello".into()),
+            OwnedValue::Array(Box::default()),
+            OwnedValue::Object(Box::new(simd_json::owned::Object::new())),
+            OwnedValue::Array(Box::new(vec![
+                OwnedValue::String("x".into()),
+                OwnedValue::Static(StaticNode::I64(1)),
+            ])),
+            OwnedValue::Object(Box::new(populated_obj)),
+        ];
+        for case in cases {
+            assert_eq!(
+                owned_value_into_serde_json(case.clone()),
+                owned_value_to_serde_json(&case),
+                "owned_value_into_serde_json must match 
owned_value_to_serde_json for {case:?}"
+            );
+        }
+    }
+
     #[test]
     fn apply_field_mappings_should_rename_mapped_key_and_keep_unmapped_key() {
         let mappings = HashMap::from([("old_field".to_string(), 
"new_field".to_string())]);
@@ -215,6 +273,54 @@ mod tests {
         }
     }
 
+    #[test]
+    fn test_into_moves_string_leaf_and_object_key_without_reallocating() {
+        let leaf = String::from("leaf-value-not-interned");
+        let leaf_ptr = leaf.as_ptr();
+        let key = String::from("object-key-not-interned");
+        let key_ptr = key.as_ptr();
+
+        let mut obj = simd_json::owned::Object::new();
+        obj.insert(key, OwnedValue::String(leaf));
+        let input = OwnedValue::Object(Box::new(obj));
+
+        let serde_json::Value::Object(map) = 
owned_value_into_serde_json(input) else {
+            panic!("expected an object");
+        };
+        let (result_key, result_value) = map.into_iter().next().expect("one 
entry");
+        let serde_json::Value::String(result_leaf) = result_value else {
+            panic!("expected a string value");
+        };
+
+        assert_eq!(
+            result_key.as_ptr(),
+            key_ptr,
+            "object key must move into the result, not reallocate"
+        );
+        assert_eq!(
+            result_leaf.as_ptr(),
+            leaf_ptr,
+            "string leaf must move into the result, not reallocate"
+        );
+    }
+
+    #[test]
+    fn test_into_nested_object_converts_recursively() {
+        let mut inner = simd_json::owned::Object::new();
+        inner.insert("b".into(), OwnedValue::String("v".into()));
+        inner.insert(
+            "arr".into(),
+            
OwnedValue::Array(Box::new(vec![OwnedValue::Static(StaticNode::I64(-1))])),
+        );
+        let mut outer = simd_json::owned::Object::new();
+        outer.insert("a".into(), OwnedValue::Object(Box::new(inner)));
+        let input = OwnedValue::Object(Box::new(outer));
+        assert_eq!(
+            owned_value_into_serde_json(input),
+            serde_json::json!({"a": {"b": "v", "arr": [-1]}})
+        );
+    }
+
     #[test]
     fn 
apply_field_mappings_should_swap_two_fields_without_losing_either_value() {
         let mappings = HashMap::from([
diff --git a/core/connectors/sdk/src/encoders/avro.rs 
b/core/connectors/sdk/src/encoders/avro.rs
index 588207fc9..67bfdd00e 100644
--- a/core/connectors/sdk/src/encoders/avro.rs
+++ b/core/connectors/sdk/src/encoders/avro.rs
@@ -17,7 +17,7 @@
 
 use crate::{
     Error, Payload, Schema, StreamEncoder,
-    convert::{apply_field_mappings, owned_value_to_serde_json},
+    convert::{apply_field_mappings, owned_value_into_serde_json},
 };
 use apache_avro::Schema as AvroSchema;
 use apache_avro::writer::datum::GenericDatumWriter;
@@ -135,7 +135,7 @@ impl AvroStreamEncoder {
             Error::InvalidConfigValue("Avro schema is required for 
encoding".to_string())
         })?;
 
-        let serde_value = owned_value_to_serde_json(&json_value);
+        let serde_value = owned_value_into_serde_json(json_value);
         let avro_value = Self::serde_json_to_avro_value(serde_value, schema)?;
 
         GenericDatumWriter::builder(schema)
diff --git a/core/connectors/sdk/src/lib.rs b/core/connectors/sdk/src/lib.rs
index cde3797db..a5aba1c1d 100644
--- a/core/connectors/sdk/src/lib.rs
+++ b/core/connectors/sdk/src/lib.rs
@@ -46,7 +46,7 @@ pub mod sink;
 pub mod source;
 pub mod transforms;
 
-pub use convert::owned_value_to_serde_json;
+pub use convert::{owned_value_into_serde_json, owned_value_to_serde_json};
 pub use log::LogCallback;
 pub use transforms::Transform;
 
diff --git a/core/connectors/sinks/delta_sink/src/sink.rs 
b/core/connectors/sinks/delta_sink/src/sink.rs
index 36ffbe358..9afff9913 100644
--- a/core/connectors/sinks/delta_sink/src/sink.rs
+++ b/core/connectors/sinks/delta_sink/src/sink.rs
@@ -22,7 +22,8 @@ use crate::storage::build_storage_options;
 use async_trait::async_trait;
 use deltalake::writer::{DeltaWriter, JsonWriter};
 use iggy_connector_sdk::{
-    ConsumedMessage, Error, MessagesMetadata, Sink, TopicMetadata, 
owned_value_to_serde_json,
+    ConsumedMessage, Error, MessagesMetadata, Payload, Sink, TopicMetadata,
+    owned_value_into_serde_json,
 };
 use tracing::{debug, error, info};
 
@@ -131,7 +132,7 @@ impl Sink for DeltaSink {
             messages_metadata.current_offset,
         );
 
-        let mut json_values = json_values(&messages)?;
+        let mut json_values = json_values(messages)?;
 
         if json_values.is_empty() {
             debug!("No JSON values to write");
@@ -199,17 +200,18 @@ impl Sink for DeltaSink {
 /// payload that has none. Proto text holding JSON is the descriptor-less
 /// `proto_convert` fallback and is written as the document it holds; proto
 /// text that is not JSON fails the batch like any other non-JSON payload.
-fn json_values(messages: &[ConsumedMessage]) -> Result<Vec<serde_json::Value>, 
Error> {
+fn json_values(messages: Vec<ConsumedMessage>) -> 
Result<Vec<serde_json::Value>, Error> {
     let mut json_values = Vec::with_capacity(messages.len());
     for message in messages {
-        let Some(document) = message.payload.json_document() else {
-            error!(
-                "Unsupported payload type: {}. Delta sink only supports JSON 
payloads.",
-                message.payload
-            );
-            return Err(Error::InvalidPayloadType);
-        };
-        json_values.push(owned_value_to_serde_json(document.as_ref()));
+        match message.payload.into_json_document() {
+            Payload::Json(document) => 
json_values.push(owned_value_into_serde_json(document)),
+            payload => {
+                error!(
+                    "Unsupported payload type: {payload}. Delta sink only 
supports JSON payloads."
+                );
+                return Err(Error::InvalidPayloadType);
+            }
+        }
     }
     Ok(json_values)
 }
@@ -238,7 +240,7 @@ mod tests {
             message(Payload::Proto(r#"{"id": 2}"#.to_owned())),
         ];
 
-        let values = json_values(&messages).expect("proto text holding JSON is 
a document");
+        let values = json_values(messages).expect("proto text holding JSON is 
a document");
 
         assert_eq!(
             values,
@@ -251,7 +253,21 @@ mod tests {
         let messages = vec![message(Payload::Proto("id: 2".to_owned()))];
 
         assert_eq!(
-            json_values(&messages).expect_err("proto text that is not JSON has 
no document"),
+            json_values(messages).expect_err("proto text that is not JSON has 
no document"),
+            Error::InvalidPayloadType
+        );
+    }
+
+    #[test]
+    fn 
json_values_rejects_a_batch_with_one_unsupported_payload_among_valid_ones() {
+        let messages = vec![
+            message(Payload::Json(simd_json::json!({"id": 1}))),
+            message(Payload::Raw(b"not json".to_vec())),
+            message(Payload::Json(simd_json::json!({"id": 3}))),
+        ];
+
+        assert_eq!(
+            json_values(messages).expect_err("one unsupported payload must 
fail the whole batch"),
             Error::InvalidPayloadType
         );
     }
diff --git a/core/connectors/sinks/http_sink/src/lib.rs 
b/core/connectors/sinks/http_sink/src/lib.rs
index 7a1c226db..371daae7f 100644
--- a/core/connectors/sinks/http_sink/src/lib.rs
+++ b/core/connectors/sinks/http_sink/src/lib.rs
@@ -22,7 +22,7 @@ use bytes::Bytes;
 use humantime::Duration as HumanDuration;
 use iggy_connector_sdk::{
     ConsumedMessage, Error, MessagesMetadata, Payload, Sink, TopicMetadata,
-    convert::owned_value_to_serde_json, sink_connector,
+    convert::owned_value_into_serde_json, sink_connector,
 };
 use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
 use reqwest_retry::{
@@ -380,11 +380,7 @@ impl HttpSink {
     fn payload_to_json(&self, payload: Payload) -> Result<serde_json::Value, 
Error> {
         let payload = payload.into_json_document();
         match payload {
-            Payload::Json(value) => {
-                // Direct structural conversion (not serialization roundtrip).
-                // Follows the Elasticsearch sink pattern. NaN/Infinity f64 → 
null.
-                Ok(owned_value_to_serde_json(&value))
-            }
+            Payload::Json(value) => Ok(owned_value_into_serde_json(value)),
             Payload::Text(text) | Payload::Proto(text) => 
Ok(serde_json::Value::String(text)),
             Payload::Raw(bytes) | Payload::FlatBuffer(bytes) => {
                 let encoded = EncodedPayload {
@@ -1706,56 +1702,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn given_null_value_should_convert_to_null() {
-        let v = simd_json::OwnedValue::Static(simd_json::StaticNode::Null);
-        assert_eq!(owned_value_to_serde_json(&v), serde_json::Value::Null);
-    }
-
-    #[test]
-    fn given_bool_value_should_convert_correctly() {
-        let v = 
simd_json::OwnedValue::Static(simd_json::StaticNode::Bool(true));
-        assert_eq!(owned_value_to_serde_json(&v), 
serde_json::Value::Bool(true));
-    }
-
-    #[test]
-    fn given_integer_values_should_convert_correctly() {
-        let i64_val = 
simd_json::OwnedValue::Static(simd_json::StaticNode::I64(-42));
-        assert_eq!(owned_value_to_serde_json(&i64_val), 
serde_json::json!(-42));
-
-        let u64_val = 
simd_json::OwnedValue::Static(simd_json::StaticNode::U64(42));
-        assert_eq!(owned_value_to_serde_json(&u64_val), serde_json::json!(42));
-    }
-
-    #[test]
-    fn given_f64_value_should_convert_correctly() {
-        let v = 
simd_json::OwnedValue::Static(simd_json::StaticNode::F64(3.54));
-        let result = owned_value_to_serde_json(&v);
-        assert_eq!(result.as_f64().unwrap(), 3.54);
-    }
-
-    #[test]
-    fn given_nan_f64_should_convert_to_null() {
-        let v = 
simd_json::OwnedValue::Static(simd_json::StaticNode::F64(f64::NAN));
-        assert_eq!(owned_value_to_serde_json(&v), serde_json::Value::Null);
-    }
-
-    #[test]
-    fn given_infinity_f64_should_convert_to_null() {
-        let v = 
simd_json::OwnedValue::Static(simd_json::StaticNode::F64(f64::INFINITY));
-        assert_eq!(owned_value_to_serde_json(&v), serde_json::Value::Null);
-    }
-
-    #[test]
-    fn given_nested_object_should_convert_recursively() {
-        let v = simd_json_from_str(r#"{"nested":{"key":"val"},"arr":[1,2]}"#);
-
-        let result = owned_value_to_serde_json(&v);
-        assert_eq!(result["nested"]["key"], "val");
-        assert_eq!(result["arr"][0], 1);
-        assert_eq!(result["arr"][1], 2);
-    }
-
     #[test]
     fn given_minimal_toml_config_should_deserialize() {
         let toml_str = r#"url = "https://example.com""#;
diff --git a/core/connectors/sinks/meilisearch_sink/src/lib.rs 
b/core/connectors/sinks/meilisearch_sink/src/lib.rs
index 3f56cea57..b6fb1c7e1 100644
--- a/core/connectors/sinks/meilisearch_sink/src/lib.rs
+++ b/core/connectors/sinks/meilisearch_sink/src/lib.rs
@@ -20,6 +20,7 @@ use base64::{Engine as _, engine::general_purpose};
 use iggy_common::IggyTimestamp;
 use iggy_connector_sdk::{
     ConsumedMessage, Error, MessagesMetadata, Payload, Sink, TopicMetadata,
+    convert::owned_value_into_serde_json,
     retry::{parse_duration, retry_backoff},
     sink_connector,
 };
@@ -936,33 +937,6 @@ fn upsert_metadata_field(object: &mut Map<String, Value>, 
field: &str, value: Va
     }
 }
 
-fn owned_value_into_serde_json(value: simd_json::OwnedValue) -> Value {
-    match value {
-        simd_json::OwnedValue::Static(node) => match node {
-            simd_json::StaticNode::Null => Value::Null,
-            simd_json::StaticNode::Bool(value) => Value::Bool(value),
-            simd_json::StaticNode::I64(value) => Value::Number(value.into()),
-            simd_json::StaticNode::U64(value) => Value::Number(value.into()),
-            simd_json::StaticNode::F64(value) => 
serde_json::Number::from_f64(value)
-                .map(Value::Number)
-                .unwrap_or(Value::Null),
-        },
-        simd_json::OwnedValue::String(value) => Value::String(value),
-        simd_json::OwnedValue::Array(values) => Value::Array(
-            values
-                .into_iter()
-                .map(owned_value_into_serde_json)
-                .collect(),
-        ),
-        simd_json::OwnedValue::Object(values) => Value::Object(
-            values
-                .into_iter()
-                .map(|(key, value)| (key, owned_value_into_serde_json(value)))
-                .collect(),
-        ),
-    }
-}
-
 fn sanitize_url_for_log(raw: &str) -> String {
     let normalized = normalize_host(raw).unwrap_or_else(|_| 
raw.trim().to_string());
     let Ok(mut url) = Url::parse(&normalized) else {
diff --git a/core/connectors/sinks/s3_sink/src/formatter.rs 
b/core/connectors/sinks/s3_sink/src/formatter.rs
index a70ecab46..5ca3cbaf2 100644
--- a/core/connectors/sinks/s3_sink/src/formatter.rs
+++ b/core/connectors/sinks/s3_sink/src/formatter.rs
@@ -19,7 +19,7 @@ use crate::OutputFormat;
 use crate::buffer::FileBuffer;
 use chrono::{DateTime, Utc};
 use iggy_connector_sdk::{
-    ConsumedMessage, Error, MessagesMetadata, Payload, TopicMetadata, 
owned_value_to_serde_json,
+    ConsumedMessage, Error, MessagesMetadata, Payload, TopicMetadata, 
owned_value_into_serde_json,
 };
 use serde::Serialize;
 use serde_json::Value;
@@ -42,7 +42,7 @@ struct JsonMessage<'a> {
 }
 
 pub(crate) fn format_message(
-    message: &ConsumedMessage,
+    message: ConsumedMessage,
     topic_metadata: &TopicMetadata,
     messages_metadata: &MessagesMetadata,
     include_metadata: bool,
@@ -62,7 +62,7 @@ pub(crate) fn format_message(
 }
 
 fn format_json_message(
-    message: &ConsumedMessage,
+    message: ConsumedMessage,
     topic_metadata: &TopicMetadata,
     messages_metadata: &MessagesMetadata,
     include_metadata: bool,
@@ -100,7 +100,7 @@ fn format_json_message(
         } else {
             None
         },
-        payload: payload_to_json_value(&message.payload),
+        payload: payload_to_json_value(message.payload),
     };
 
     serde_json::to_vec(&msg).map_err(|e| {
@@ -111,8 +111,8 @@ fn format_json_message(
     })
 }
 
-fn format_raw_message(message: &ConsumedMessage) -> Result<Vec<u8>, Error> {
-    message.payload.try_to_bytes().map_err(|e| {
+fn format_raw_message(message: ConsumedMessage) -> Result<Vec<u8>, Error> {
+    message.payload.try_into_vec().map_err(|e| {
         Error::CannotStoreData(format!(
             "Failed to extract raw bytes at offset {}: {e}",
             message.offset
@@ -165,23 +165,17 @@ fn serialize_headers(
     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) {
+// Proto text holding JSON is written as the document it holds, like a native 
`json` batch.
+fn payload_to_json_value(payload: Payload) -> Value {
+    match payload.into_json_document() {
+        Payload::Json(value) => owned_value_into_serde_json(value),
+        Payload::Text(text) | Payload::Proto(text) => Value::String(text),
+        Payload::Raw(bytes) => match serde_json::from_slice(&bytes) {
             Ok(v) => v,
-            Err(_) => Value::String(base64_encode(bytes)),
+            Err(_) => Value::String(base64_encode(&bytes)),
         },
-        // Proto text holding JSON is the descriptor-less `proto_convert`
-        // fallback and is written as the document it holds, the way the same
-        // bytes were written when the batch was tagged `json`.
-        Payload::Proto(text) => match payload.json_document() {
-            Some(document) => owned_value_to_serde_json(document.as_ref()),
-            None => Value::String(text.clone()),
-        },
-        Payload::FlatBuffer(bytes) => Value::String(base64_encode(bytes)),
-        Payload::Avro(bytes) => Value::String(base64_encode(bytes)),
+        Payload::FlatBuffer(bytes) => Value::String(base64_encode(&bytes)),
+        Payload::Avro(bytes) => Value::String(base64_encode(&bytes)),
     }
 }
 
@@ -276,7 +270,7 @@ mod tests {
         let meta = make_messages_metadata();
 
         let bytes =
-            format_message(&msg, &topic, &meta, true, false, 
OutputFormat::JsonLines).unwrap();
+            format_message(msg, &topic, &meta, true, false, 
OutputFormat::JsonLines).unwrap();
         let value: Value = serde_json::from_slice(&bytes).unwrap();
 
         assert_eq!(value["offset"], 42);
@@ -295,7 +289,7 @@ mod tests {
         let meta = make_messages_metadata();
 
         let bytes =
-            format_message(&msg, &topic, &meta, false, false, 
OutputFormat::JsonLines).unwrap();
+            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());
@@ -318,7 +312,7 @@ mod tests {
         let meta = make_messages_metadata();
 
         let bytes =
-            format_message(&msg, &topic, &meta, false, true, 
OutputFormat::JsonLines).unwrap();
+            format_message(msg, &topic, &meta, false, true, 
OutputFormat::JsonLines).unwrap();
         let value: Value = serde_json::from_slice(&bytes).unwrap();
 
         assert!(value["headers"].is_object());
@@ -330,21 +324,68 @@ mod tests {
         let payload = Payload::Proto(r#"{"id":1,"name":"row-1"}"#.to_string());
 
         assert_eq!(
-            payload_to_json_value(&payload),
+            payload_to_json_value(payload),
             serde_json::json!({"id": 1, "name": "row-1"})
         );
     }
 
+    #[test]
+    fn text_payload_holding_json_looking_content_stays_a_string() {
+        let payload = Payload::Text(r#"{"id":1}"#.to_string());
+
+        assert_eq!(
+            payload_to_json_value(payload),
+            Value::String(r#"{"id":1}"#.to_string())
+        );
+    }
+
     #[test]
     fn proto_text_that_is_not_json_is_written_as_a_string() {
         let payload = Payload::Proto("name: \"row-1\"".to_string());
 
         assert_eq!(
-            payload_to_json_value(&payload),
+            payload_to_json_value(payload),
             Value::String("name: \"row-1\"".to_string())
         );
     }
 
+    #[test]
+    fn raw_payload_holding_json_bytes_is_parsed() {
+        let payload = Payload::Raw(br#"{"id":1}"#.to_vec());
+
+        assert_eq!(payload_to_json_value(payload), serde_json::json!({"id": 
1}));
+    }
+
+    #[test]
+    fn raw_payload_that_is_not_json_is_base64_encoded() {
+        let payload = Payload::Raw(vec![0xff, 0x00, 0x10]);
+
+        assert_eq!(
+            payload_to_json_value(payload),
+            Value::String("/wAQ".to_string())
+        );
+    }
+
+    #[test]
+    fn flatbuffer_payload_is_base64_encoded() {
+        let payload = Payload::FlatBuffer(vec![1, 2, 3]);
+
+        assert_eq!(
+            payload_to_json_value(payload),
+            Value::String("AQID".to_string())
+        );
+    }
+
+    #[test]
+    fn avro_payload_is_base64_encoded() {
+        let payload = Payload::Avro(vec![4, 5, 6]);
+
+        assert_eq!(
+            payload_to_json_value(payload),
+            Value::String("BAUG".to_string())
+        );
+    }
+
     #[test]
     fn raw_format() {
         let payload = Payload::Text("hello world".to_string());
@@ -352,7 +393,7 @@ mod tests {
         let topic = make_topic_metadata();
         let meta = make_messages_metadata();
 
-        let bytes = format_message(&msg, &topic, &meta, true, false, 
OutputFormat::Raw).unwrap();
+        let bytes = format_message(msg, &topic, &meta, true, false, 
OutputFormat::Raw).unwrap();
         assert_eq!(bytes, b"hello world");
     }
 
diff --git a/core/connectors/sinks/s3_sink/src/sink.rs 
b/core/connectors/sinks/s3_sink/src/sink.rs
index fcf155f60..09fcaf399 100644
--- a/core/connectors/sinks/s3_sink/src/sink.rs
+++ b/core/connectors/sinks/s3_sink/src/sink.rs
@@ -100,7 +100,7 @@ impl Sink for S3Sink {
                 &buffer_arc,
                 topic_metadata,
                 &messages_metadata,
-                &messages,
+                messages,
                 &mut processed,
             )
             .await;
@@ -250,11 +250,12 @@ impl S3Sink {
         buffer_arc: &Arc<tokio::sync::Mutex<FileBuffer>>,
         topic_metadata: &TopicMetadata,
         messages_metadata: &MessagesMetadata,
-        messages: &[ConsumedMessage],
+        messages: Vec<ConsumedMessage>,
         processed: &mut u64,
     ) -> Result<(), Error> {
         for message in messages {
             let resolved = self.resolved();
+            let (offset, timestamp) = (message.offset, message.timestamp);
             let formatted = formatter::format_message(
                 message,
                 topic_metadata,
@@ -266,7 +267,7 @@ impl S3Sink {
 
             let flush_payload = {
                 let mut buffer = buffer_arc.lock().await;
-                buffer.append(&formatted, message.offset, message.timestamp);
+                buffer.append(&formatted, offset, timestamp);
 
                 if buffer.should_rotate(
                     self.config.file_rotation,
@@ -619,6 +620,157 @@ mod tests {
         });
     }
 
+    // FileBuffer only ever records the first message's timestamp, so that's 
all this can observe.
+    #[test]
+    fn given_multiple_messages_should_write_in_offset_order_with_batch_key() {
+        let runtime = tokio::runtime::Runtime::new().expect("Start test 
runtime");
+        runtime.block_on(async {
+            let server = MockServer::start().await;
+            mock_write_probe(&server, "allowed/events/.iggy-sink-probe").await;
+            Mock::given(method("PUT"))
+                .respond_with(ResponseTemplate::new(200))
+                .mount(&server)
+                .await;
+            let config = S3SinkConfig {
+                prefix: Some("/allowed/events/".to_string()),
+                endpoint: Some(server.uri()),
+                access_key_id: Some("test-access-key".into()),
+                secret_access_key: Some("test-secret-key".into()),
+                path_template: "{topic}/{timestamp}".to_string(),
+                output_format: "raw".to_string(),
+                ..test_config()
+            };
+            let mut sink = S3Sink::new(1, config);
+            sink.open().await.expect("Sink should initialize");
+
+            let message = |offset, timestamp, data: &[u8]| ConsumedMessage {
+                id: u128::from(offset) + 1,
+                offset,
+                checksum: 0,
+                timestamp,
+                origin_timestamp: 0,
+                headers: None,
+                payload: Payload::Raw(data.to_vec()),
+            };
+            sink.consume(
+                &TopicMetadata {
+                    stream: "events".to_string(),
+                    topic: "messages".to_string(),
+                },
+                MessagesMetadata {
+                    partition_id: 0,
+                    current_offset: 10,
+                    schema: Schema::Raw,
+                },
+                vec![
+                    message(10, 1_000_000, b"a"),
+                    message(11, 2_000_000, b"b"),
+                    message(12, 3_000_000, b"c"),
+                ],
+            )
+            .await
+            .expect("Batch should upload");
+            sink.close().await.expect("Sink should close");
+
+            let requests = server.received_requests().await.expect("record 
requests");
+            assert_eq!(
+                requests.len(),
+                3,
+                "Probe POST + DELETE, then one data PUT: {requests:?}"
+            );
+            assert_eq!(requests[2].method, "PUT");
+            assert_eq!(
+                requests[2].body, b"abc",
+                "Each message's own bytes must land in its own offset's 
position"
+            );
+            assert_eq!(
+                requests[2].url.path(),
+                
"/test-bucket/allowed/events/messages/1000/00000-00000000000000000010-00000000000000000012.bin",
+                "Key must reflect the batch's offset range and the first 
message's own timestamp, not a shared or stale one"
+            );
+        });
+    }
+
+    // Rotation exposes the middle offset and last timestamp a single flush 
hides.
+    #[test]
+    fn 
given_rotation_mid_batch_should_use_each_segments_own_offset_and_timestamp() {
+        let runtime = tokio::runtime::Runtime::new().expect("Start test 
runtime");
+        runtime.block_on(async {
+            let server = MockServer::start().await;
+            mock_write_probe(&server, "allowed/events/.iggy-sink-probe").await;
+            Mock::given(method("PUT"))
+                .respond_with(ResponseTemplate::new(200))
+                .mount(&server)
+                .await;
+            let config = S3SinkConfig {
+                prefix: Some("/allowed/events/".to_string()),
+                endpoint: Some(server.uri()),
+                access_key_id: Some("test-access-key".into()),
+                secret_access_key: Some("test-secret-key".into()),
+                path_template: "{topic}/{timestamp}".to_string(),
+                output_format: "raw".to_string(),
+                file_rotation: FileRotation::Messages,
+                max_messages_per_file: Some(2),
+                ..test_config()
+            };
+            let mut sink = S3Sink::new(1, config);
+            sink.open().await.expect("Sink should initialize");
+
+            let message = |offset, timestamp, data: &[u8]| ConsumedMessage {
+                id: u128::from(offset) + 1,
+                offset,
+                checksum: 0,
+                timestamp,
+                origin_timestamp: 0,
+                headers: None,
+                payload: Payload::Raw(data.to_vec()),
+            };
+            sink.consume(
+                &TopicMetadata {
+                    stream: "events".to_string(),
+                    topic: "messages".to_string(),
+                },
+                MessagesMetadata {
+                    partition_id: 0,
+                    current_offset: 10,
+                    schema: Schema::Raw,
+                },
+                vec![
+                    message(10, 1_000_000, b"a"),
+                    message(11, 2_000_000, b"b"),
+                    message(12, 3_000_000, b"c"),
+                ],
+            )
+            .await
+            .expect("Batch should upload");
+            sink.close().await.expect("Sink should close");
+
+            let requests = server.received_requests().await.expect("record 
requests");
+            assert_eq!(
+                requests.len(),
+                4,
+                "Probe POST + DELETE, then one PUT per rotated segment: 
{requests:?}"
+            );
+            assert_eq!(requests[2].method, "PUT");
+            assert_eq!(
+                requests[2].body, b"ab",
+                "First segment holds messages 1-2"
+            );
+            assert_eq!(
+                requests[2].url.path(),
+                
"/test-bucket/allowed/events/messages/1000/00000-00000000000000000010-00000000000000000011.bin",
+                "First segment's key must reflect message 2's own offset as 
last_offset"
+            );
+            assert_eq!(requests[3].method, "PUT");
+            assert_eq!(requests[3].body, b"c", "Second segment holds message 3 
alone");
+            assert_eq!(
+                requests[3].url.path(),
+                
"/test-bucket/allowed/events/messages/3000/00000-00000000000000000012-00000000000000000012.bin",
+                "Second segment's key must reflect message 3's own timestamp, 
not message 1's"
+            );
+        });
+    }
+
     #[test]
     fn 
given_a_probe_prefix_when_opening_should_use_the_configured_write_scope() {
         let runtime = tokio::runtime::Runtime::new().expect("Start test 
runtime");
diff --git a/core/connectors/sinks/surrealdb_sink/src/lib.rs 
b/core/connectors/sinks/surrealdb_sink/src/lib.rs
index 994d4373b..12755e68a 100644
--- a/core/connectors/sinks/surrealdb_sink/src/lib.rs
+++ b/core/connectors/sinks/surrealdb_sink/src/lib.rs
@@ -19,7 +19,7 @@ use async_trait::async_trait;
 use base64::Engine;
 use base64::engine::general_purpose;
 use bytes::Bytes;
-use iggy_connector_sdk::convert::owned_value_to_serde_json;
+use iggy_connector_sdk::convert::owned_value_into_serde_json;
 use iggy_connector_sdk::retry::{parse_duration, retry_backoff};
 use iggy_connector_sdk::{
     ConsumedMessage, Error, MessagesMetadata, Payload, Sink, TopicMetadata, 
sink_connector,
@@ -896,7 +896,7 @@ fn build_auto_payload_document(payload: Payload) -> 
Result<PayloadDocument, Erro
     let payload = payload.into_json_document();
     match payload {
         Payload::Json(value) => Ok(PayloadDocument {
-            value: owned_value_to_serde_json(&value),
+            value: owned_value_into_serde_json(value),
             encoding: ENCODING_JSON,
         }),
         Payload::Text(text) | Payload::Proto(text) => Ok(PayloadDocument {
@@ -912,7 +912,7 @@ fn build_auto_payload_document(payload: Payload) -> 
Result<PayloadDocument, Erro
 fn build_json_payload_document(payload: Payload) -> Result<PayloadDocument, 
Error> {
     match payload {
         Payload::Json(value) => Ok(PayloadDocument {
-            value: owned_value_to_serde_json(&value),
+            value: owned_value_into_serde_json(value),
             encoding: ENCODING_JSON,
         }),
         _ => {
@@ -1655,6 +1655,15 @@ mod tests {
         assert_eq!(document.value, json!({"count": 3}));
     }
 
+    #[test]
+    fn given_json_payload_format_should_convert_a_json_payload_directly() {
+        let payload = json_payload(json!({"count": 3}));
+        let document = build_json_payload_document(payload).expect("Failed to 
build payload");
+
+        assert_eq!(document.encoding, ENCODING_JSON);
+        assert_eq!(document.value, json!({"count": 3}));
+    }
+
     #[test]
     fn given_json_payload_format_when_invalid_should_fail() {
         let payload = Payload::Raw(b"not-json".to_vec());

Reply via email to