This is an automated email from the ASF dual-hosted git repository.

slbotbm 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 3ca8695d3 feat(connectors): add Meilisearch sink connector (#3497)
3ca8695d3 is described below

commit 3ca8695d330e17c7eaa3f269c19cb1ee90354100
Author: Diaconu Radu-Mihai <[email protected]>
AuthorDate: Tue Aug 4 02:08:16 2026 +0300

    feat(connectors): add Meilisearch sink connector (#3497)
    
    ## Summary
    - add the Meilisearch sink connector using the official
    `meilisearch-sdk`
    - document the sink configuration and payload mapping behavior
    - add Docker-backed integration coverage for indexing JSON messages into
    Meilisearch
    
    ## Motivation
    This splits the sink portion out of #3404 so the Meilisearch sink can be
    reviewed and merged independently from the source connector.
    
    Closes #3495.
    
    ## Validation
    - `cargo sort --no-format --workspace`
    - `cargo test -p iggy_connector_meilisearch_sink`
    - `cargo clippy -p iggy_connector_meilisearch_sink --all-targets -- -D
    warnings`
    - `env CARGO_BIN_EXE_iggy-server=... CARGO_BIN_EXE_iggy-connectors=...
    cargo test -p integration --test mod --
    connectors::meilisearch::meilisearch_sink`
    - `git diff --check`
    
    ---------
    
    Co-authored-by: radudiaconu <[email protected]>
    Co-authored-by: Hubert Gruszecki <[email protected]>
    Co-authored-by: Rimuksh Kansal <[email protected]>
---
 Cargo.lock                                         |   90 ++
 Cargo.toml                                         |    6 +
 core/connectors/README.md                          |    1 +
 core/connectors/sinks/README.md                    |    1 +
 core/connectors/sinks/meilisearch_sink/Cargo.toml  |   47 +
 core/connectors/sinks/meilisearch_sink/README.md   |  105 ++
 core/connectors/sinks/meilisearch_sink/config.toml |   43 +
 core/connectors/sinks/meilisearch_sink/src/lib.rs  | 1412 ++++++++++++++++++++
 .../connectors/fixtures/meilisearch/container.rs   |  180 +++
 .../tests/connectors/fixtures/meilisearch/mod.rs   |   22 +
 .../tests/connectors/fixtures/meilisearch/sink.rs  |   90 ++
 core/integration/tests/connectors/fixtures/mod.rs  |    2 +
 .../connectors/meilisearch/meilisearch_sink.rs     |   81 ++
 .../tests/connectors/meilisearch/mod.rs            |   18 +
 .../tests/connectors/meilisearch/sink.toml         |   20 +
 core/integration/tests/connectors/mod.rs           |    1 +
 16 files changed, 2119 insertions(+)

diff --git a/Cargo.lock b/Cargo.lock
index 0ecf6332b..3cd7dccbc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3300,6 +3300,15 @@ dependencies = [
  "unicode-segmentation",
 ]
 
+[[package]]
+name = "convert_case"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f"
+dependencies = [
+ "unicode-segmentation",
+]
+
 [[package]]
 name = "convert_case"
 version = "0.9.0"
@@ -7048,6 +7057,24 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "iggy_connector_meilisearch_sink"
+version = "0.4.1-edge.1"
+dependencies = [
+ "async-trait",
+ "base64",
+ "iggy_common",
+ "iggy_connector_sdk",
+ "meilisearch-sdk",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "simd-json",
+ "tokio",
+ "tracing",
+ "url",
+]
+
 [[package]]
 name = "iggy_connector_mongodb_sink"
 version = "0.4.1-edge.1"
@@ -7536,6 +7563,15 @@ version = "1.70.2"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
 
+[[package]]
+name = "iso8601"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "74a0559b45528cf0732d911524974977a5749f477d7dd99652830ffdaf53c4d1"
+dependencies = [
+ "nom 8.0.0",
+]
+
 [[package]]
 name = "itertools"
 version = "0.13.0"
@@ -8335,6 +8371,49 @@ version = "0.8.1"
 source = "registry+https://github.com/rust-lang/crates.io-index";
 checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c"
 
+[[package]]
+name = "meilisearch-index-setting-macro"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "93b5b21df781c820a9cc387b808d4128cbc164dd28d67ac6ed666a00996f8f15"
+dependencies = [
+ "convert_case 0.8.0",
+ "proc-macro2",
+ "quote",
+ "structmeta",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "meilisearch-sdk"
+version = "0.33.0"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "19e6e3646ba2a9a306296c1edf4a050508a408c1b59ca456d9ad4965ec6e91e9"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "iso8601",
+ "jsonwebtoken",
+ "log",
+ "meilisearch-index-setting-macro",
+ "pin-project-lite",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.19",
+ "time",
+ "tokio",
+ "uuid",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "yaup",
+]
+
 [[package]]
 name = "memchr"
 version = "2.8.3"
@@ -14971,6 +15050,17 @@ dependencies = [
  "time",
 ]
 
+[[package]]
+name = "yaup"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "b0144f1a16a199846cb21024da74edd930b43443463292f536b7110b4855b5c6"
+dependencies = [
+ "form_urlencoded",
+ "serde",
+ "thiserror 1.0.69",
+]
+
 [[package]]
 name = "yew"
 version = "0.23.0"
diff --git a/Cargo.toml b/Cargo.toml
index 0147c62ed..0bd07364f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -40,6 +40,7 @@ members = [
     "core/connectors/sinks/http_sink",
     "core/connectors/sinks/iceberg_sink",
     "core/connectors/sinks/influxdb_sink",
+    "core/connectors/sinks/meilisearch_sink",
     "core/connectors/sinks/mongodb_sink",
     "core/connectors/sinks/postgres_sink",
     "core/connectors/sinks/quickwit_sink",
@@ -216,6 +217,11 @@ lending-iterator = "0.1.7"
 libc = "0.2.188"
 log = "0.4.33"
 lz4_flex = "0.14.0"
+meilisearch-sdk = { version = "0.33.0", default-features = false, features = [
+    "reqwest",
+    "tls",
+    "jwt_rust_crypto",
+] }
 message_bus = { path = "core/message_bus" }
 metadata = { path = "core/metadata" }
 mimalloc = "0.1"
diff --git a/core/connectors/README.md b/core/connectors/README.md
index 64bfc9a1f..2690a9775 100644
--- a/core/connectors/README.md
+++ b/core/connectors/README.md
@@ -83,6 +83,7 @@ Each sink should have its own, custom configuration, which is 
passed along with
 - **Doris Sink** - loads JSON messages into Apache Doris tables via the Stream 
Load HTTP API
 - **Elasticsearch Sink** - sends messages to Elasticsearch indices
 - **Iceberg Sink** - writes data to Apache Iceberg tables via REST catalog
+- **Meilisearch Sink** - indexes messages in Meilisearch
 - **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)
diff --git a/core/connectors/sinks/README.md b/core/connectors/sinks/README.md
index 57ea05549..e23e1ace9 100644
--- a/core/connectors/sinks/README.md
+++ b/core/connectors/sinks/README.md
@@ -12,6 +12,7 @@ Sink connectors are responsible for writing data from Iggy 
streams to external s
 | **elasticsearch_sink** | Sends messages to Elasticsearch indices for 
full-text search and analytics |
 | **iceberg_sink** | Writes data to Apache Iceberg tables via REST catalog 
with S3/GCS/Azure storage |
 | **influxdb_sink** | Writes messages to InfluxDB as line-protocol points; 
supports both V2 (org/bucket, Flux) and V3 (db, SQL) |
+| **meilisearch_sink** | Indexes messages in Meilisearch for full-text search |
 | **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) |
diff --git a/core/connectors/sinks/meilisearch_sink/Cargo.toml 
b/core/connectors/sinks/meilisearch_sink/Cargo.toml
new file mode 100644
index 000000000..44233a9dc
--- /dev/null
+++ b/core/connectors/sinks/meilisearch_sink/Cargo.toml
@@ -0,0 +1,47 @@
+# 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_meilisearch_sink"
+version = "0.4.1-edge.1"
+description = "Iggy Meilisearch sink connector"
+edition = "2024"
+license = "Apache-2.0"
+keywords = ["iggy", "messaging", "streaming", "meilisearch", "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 }
+iggy_common = { workspace = true }
+iggy_connector_sdk = { workspace = true }
+meilisearch-sdk = { workspace = true }
+secrecy = { workspace = true }
+serde = { workspace = true }
+serde_json = { workspace = true }
+simd-json = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+url = { workspace = true }
diff --git a/core/connectors/sinks/meilisearch_sink/README.md 
b/core/connectors/sinks/meilisearch_sink/README.md
new file mode 100644
index 000000000..f164c83dd
--- /dev/null
+++ b/core/connectors/sinks/meilisearch_sink/README.md
@@ -0,0 +1,105 @@
+# Meilisearch Sink Connector
+
+A sink connector that consumes messages from Iggy streams and writes them to a
+Meilisearch index through the official Rust SDK.
+
+## Configuration
+
+```toml
+[plugin_config]
+url = "https://meilisearch.example.com";
+index = "iggy_messages"
+# api_key = "..."
+primary_key = "iggy_id"
+document_action = "replace"
+create_index_if_not_exists = true
+include_metadata = true
+batch_size = 1000
+timeout = "30s"
+wait_for_tasks = true
+task_timeout = "30s"
+task_poll_interval = "100ms"
+max_retries = 3
+retry_delay = "500ms"
+max_retry_delay = "5s"
+max_open_retries = 5
+```
+
+- `url`: Meilisearch base URL. Paths, query strings, and fragments are ignored.
+- `index`: Target index UID.
+- `api_key`: Optional Meilisearch API key sent as `Authorization: Bearer`.
+  Use HTTPS for non-local Meilisearch hosts; HTTP sends the key without
+  transport encryption.
+- `primary_key`: Index primary key field. Defaults to `iggy_id`.
+- `document_action`: `replace` uses SDK add-or-replace semantics; `update`
+  uses SDK add-or-update semantics. Defaults to `replace`.
+- `create_index_if_not_exists`: Create the index during `open()` when missing. 
Defaults to `true`.
+- `include_metadata`: Add Iggy metadata fields to each document. Defaults to 
`true`.
+- `batch_size`: Maximum documents per Meilisearch document request. Defaults 
to `1000`.
+- `timeout`: Request timeout as a humantime string, for example `30s`. 
Defaults to `30s`.
+- `wait_for_tasks`: Poll Meilisearch tasks until terminal state before
+  returning from `consume()`. Defaults to `true`. Setting this to `false` makes
+  document indexing fire-and-forget, so asynchronous Meilisearch task failures
+  are not observed by the connector.
+- `task_timeout`: Maximum time to wait for each Meilisearch task. Defaults to 
`30s`.
+- `task_poll_interval`: Delay between task polls. Defaults to `100ms`.
+- `max_retries`: Maximum transient retries after the initial request. Defaults 
to `3`.
+- `retry_delay`: Initial transient retry delay. Defaults to `500ms`.
+- `max_retry_delay`: Maximum transient retry delay. Defaults to `5s`.
+- `max_open_retries`: Maximum transient retries after the initial request while
+  opening the index. Defaults to `5`. This also applies to `get_task` polls
+  while waiting for index creation during `open()`. Each retried open operation
+  uses the configured request `timeout` plus backoff; there is no single total
+  open deadline.
+
+## Behavior
+
+JSON object payloads are indexed as documents. JSON arrays or scalar values are
+wrapped in a `value` field because Meilisearch documents must be objects. Raw
+payloads are parsed as JSON when possible; otherwise, they are indexed as 
base64
+data. Text payloads are indexed in a `text` field. Unsupported payload schemas
+are skipped with a warning and counted as sink errors, matching the connector
+runtime's per-record drop behavior for malformed records. Because the sink
+returns success after dropping an unsupported-schema record, the runtime can
+commit the consumer offset for that record. There is no built-in dead-letter
+queue for these drops.
+
+When the configured primary key is absent, the connector injects a stable value
+derived from the exact Iggy stream, topic, partition, offset, and message ID.
+This avoids Meilisearch primary-key inference failures. If the payload already
+contains the configured primary key, that value is preserved. Operators must
+ensure user-provided primary keys are unique, otherwise Meilisearch
+add-or-replace semantics can collapse distinct messages into one document.
+
+When `include_metadata` is enabled, the connector writes reserved `iggy_*`
+provenance fields after payload parsing. These fields overwrite same-named
+payload fields so audit metadata reflects the actual stream, topic, partition,
+offset, checksum, and timestamps. `iggy_checksum` is stored as a string to 
avoid
+JSON number precision loss in Meilisearch clients. Offset and timestamp 
metadata
+remain JSON numbers. If `primary_key` is set to a field other than `iggy_id`,
+the connector also writes `iggy_id` as stable Iggy metadata.
+
+## Delivery Semantics
+
+The connector runtime invokes `consume()` through an FFI callback whose status
+code is not currently used to gate offset commits. A batch error returned by 
the
+sink is logged by the sink, but the runtime does not redeliver that batch. The
+effective runtime-level delivery guarantee is at-most-once on sink errors. The
+sink's retry settings only provide best-effort retries inside a single
+`consume()` call.
+
+`wait_for_tasks=false` only skips waiting for document indexing tasks during
+`consume()`. In that mode, successful submission lets the runtime commit the
+consumer offset before Meilisearch has confirmed indexing, so later task
+failures are not retried, logged, or counted by this connector. If
+`create_index_if_not_exists=true` and the connector creates the index during
+`open()`, it still waits for that index-creation task so the first batch cannot
+race the index creation. This mode is fire-and-forget and does not provide
+durability.
+
+The close-time counters are attempt counters. `documents_enqueued` counts
+documents accepted by completed Meilisearch SDK calls in this process, and
+`documents_confirmed` counts those same documents only when task waiting is
+enabled and the corresponding task reached success. `errors` includes invalid
+records plus documents in failed chunks and trailing chunks that were not
+attempted after an earlier chunk failed.
diff --git a/core/connectors/sinks/meilisearch_sink/config.toml 
b/core/connectors/sinks/meilisearch_sink/config.toml
new file mode 100644
index 000000000..1d9963115
--- /dev/null
+++ b/core/connectors/sinks/meilisearch_sink/config.toml
@@ -0,0 +1,43 @@
+# 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 = "meilisearch"
+enabled = true
+version = 0
+name = "Meilisearch sink"
+path = "../../target/release/libiggy_connector_meilisearch_sink"
+verbose = false
+
+[[streams]]
+stream = "test_stream"
+topics = ["test_topic"]
+schema = "json"
+batch_length = 100
+poll_interval = "5ms"
+consumer_group = "meilisearch_sink"
+
+[plugin_config]
+url = "http://localhost:7700";
+index = "iggy_messages"
+primary_key = "iggy_id"
+document_action = "replace"
+create_index_if_not_exists = true
+include_metadata = true
+batch_size = 1000
+timeout = "30s"
+wait_for_tasks = true
diff --git a/core/connectors/sinks/meilisearch_sink/src/lib.rs 
b/core/connectors/sinks/meilisearch_sink/src/lib.rs
new file mode 100644
index 000000000..64b42ab8c
--- /dev/null
+++ b/core/connectors/sinks/meilisearch_sink/src/lib.rs
@@ -0,0 +1,1412 @@
+// 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 base64::{Engine as _, engine::general_purpose};
+use iggy_common::IggyTimestamp;
+use iggy_connector_sdk::{
+    ConsumedMessage, Error, MessagesMetadata, Payload, Sink, TopicMetadata,
+    retry::{exponential_backoff, jitter, parse_duration},
+    sink_connector,
+};
+use meilisearch_sdk::{
+    client::Client,
+    errors::{
+        Error as MeilisearchSdkError, ErrorCode as MeilisearchErrorCode,
+        ErrorType as MeilisearchErrorType,
+    },
+    indexes::Index,
+    task_info::TaskInfo,
+    tasks::Task,
+};
+use secrecy::{ExposeSecret, SecretString};
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value, json};
+use std::{cmp, future::Future, net::IpAddr, time::Duration};
+use tokio::{
+    sync::Mutex,
+    time::{Instant, sleep},
+};
+use tracing::{debug, error, info, warn};
+use url::Url;
+
+sink_connector!(MeilisearchSink);
+
+const DEFAULT_PRIMARY_KEY: &str = "iggy_id";
+const DEFAULT_CREATE_INDEX_IF_NOT_EXISTS: bool = true;
+const DEFAULT_INCLUDE_METADATA: bool = true;
+const DEFAULT_BATCH_SIZE: usize = 1000;
+const DEFAULT_TIMEOUT: &str = "30s";
+const DEFAULT_WAIT_FOR_TASKS: bool = true;
+const DEFAULT_TASK_TIMEOUT: &str = "30s";
+const DEFAULT_TASK_POLL_INTERVAL: &str = "100ms";
+const DEFAULT_RETRY_DELAY: &str = "500ms";
+const DEFAULT_MAX_RETRY_DELAY: &str = "5s";
+const DEFAULT_MAX_RETRIES: u32 = 3;
+const DEFAULT_MAX_OPEN_RETRIES: u32 = 5;
+const ENCODING_BASE64: &str = "base64";
+
+#[derive(Debug)]
+struct State {
+    invocations_count: usize,
+    documents_enqueued: usize,
+    documents_confirmed: usize,
+    errors_count: usize,
+}
+
+#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum MeilisearchDocumentAction {
+    #[default]
+    Replace,
+    Update,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct MeilisearchSinkConfig {
+    pub url: String,
+    pub index: String,
+    #[serde(serialize_with = 
"iggy_common::serde_secret::serialize_optional_secret")]
+    pub api_key: Option<SecretString>,
+    pub primary_key: Option<String>,
+    pub document_action: Option<MeilisearchDocumentAction>,
+    pub create_index_if_not_exists: Option<bool>,
+    pub include_metadata: Option<bool>,
+    pub batch_size: Option<usize>,
+    pub timeout: Option<String>,
+    pub wait_for_tasks: Option<bool>,
+    pub task_timeout: Option<String>,
+    pub task_poll_interval: Option<String>,
+    pub max_retries: Option<u32>,
+    pub retry_delay: Option<String>,
+    pub max_retry_delay: Option<String>,
+    pub max_open_retries: Option<u32>,
+}
+
+#[derive(Debug)]
+pub struct MeilisearchSink {
+    id: u32,
+    config: ResolvedMeilisearchSinkConfig,
+    client: Option<Client>,
+    state: Mutex<State>,
+}
+
+#[derive(Debug)]
+struct ResolvedMeilisearchSinkConfig {
+    url: String,
+    index: String,
+    api_key: Option<SecretString>,
+    primary_key: String,
+    document_action: MeilisearchDocumentAction,
+    create_index_if_not_exists: bool,
+    include_metadata: bool,
+    batch_size: usize,
+    timeout: Duration,
+    wait_for_tasks: bool,
+    task_timeout: Duration,
+    task_poll_interval: Duration,
+    max_retries: u32,
+    retry_delay: Duration,
+    max_retry_delay: Duration,
+    max_open_retries: u32,
+}
+
+impl From<MeilisearchSinkConfig> for ResolvedMeilisearchSinkConfig {
+    fn from(config: MeilisearchSinkConfig) -> Self {
+        let primary_key = config
+            .primary_key
+            .map(|value| value.trim().to_string())
+            .filter(|value| !value.is_empty())
+            .unwrap_or_else(|| DEFAULT_PRIMARY_KEY.to_string());
+        let document_action = config.document_action.unwrap_or_default();
+        let create_index_if_not_exists = config
+            .create_index_if_not_exists
+            .unwrap_or(DEFAULT_CREATE_INDEX_IF_NOT_EXISTS);
+        let include_metadata = 
config.include_metadata.unwrap_or(DEFAULT_INCLUDE_METADATA);
+        let batch_size = 
config.batch_size.unwrap_or(DEFAULT_BATCH_SIZE).max(1);
+        let timeout = parse_duration(config.timeout.as_deref(), 
DEFAULT_TIMEOUT);
+        let wait_for_tasks = 
config.wait_for_tasks.unwrap_or(DEFAULT_WAIT_FOR_TASKS);
+        let task_timeout = parse_duration(config.task_timeout.as_deref(), 
DEFAULT_TASK_TIMEOUT);
+        let task_poll_interval = parse_duration(
+            config.task_poll_interval.as_deref(),
+            DEFAULT_TASK_POLL_INTERVAL,
+        );
+        let max_retries = config.max_retries.unwrap_or(DEFAULT_MAX_RETRIES);
+        let mut retry_delay = parse_duration(config.retry_delay.as_deref(), 
DEFAULT_RETRY_DELAY);
+        let mut max_retry_delay =
+            parse_duration(config.max_retry_delay.as_deref(), 
DEFAULT_MAX_RETRY_DELAY);
+        if retry_delay > max_retry_delay {
+            warn!(
+                "Meilisearch sink retry_delay ({:?}) exceeds max_retry_delay 
({:?}). Swapping values.",
+                retry_delay, max_retry_delay
+            );
+            std::mem::swap(&mut retry_delay, &mut max_retry_delay);
+        }
+        let max_open_retries = 
config.max_open_retries.unwrap_or(DEFAULT_MAX_OPEN_RETRIES);
+
+        Self {
+            url: config.url,
+            index: config.index.trim().to_string(),
+            api_key: config.api_key,
+            primary_key,
+            document_action,
+            create_index_if_not_exists,
+            include_metadata,
+            batch_size,
+            timeout,
+            wait_for_tasks,
+            task_timeout,
+            task_poll_interval,
+            max_retries,
+            retry_delay,
+            max_retry_delay,
+            max_open_retries,
+        }
+    }
+}
+
+impl MeilisearchSink {
+    pub fn new(id: u32, config: MeilisearchSinkConfig) -> Self {
+        Self {
+            id,
+            config: config.into(),
+            client: None,
+            state: Mutex::new(State {
+                invocations_count: 0,
+                documents_enqueued: 0,
+                documents_confirmed: 0,
+                errors_count: 0,
+            }),
+        }
+    }
+
+    fn create_client(&self) -> Result<Client, Error> {
+        let url = normalize_host(&self.config.url)?;
+        warn_if_api_key_uses_insecure_http(&self.config.url, &url, 
self.config.api_key.is_some());
+        let api_key = self.config.api_key.as_ref().map(|key| 
key.expose_secret());
+        Client::new(url, api_key).map_err(|error| {
+            Error::Connection(format!("Failed to create Meilisearch client: 
{error}"))
+        })
+    }
+
+    fn validate_config(&self) -> Result<(), Error> {
+        if self.config.index.is_empty() {
+            return Err(Error::InvalidConfigValue(
+                "Meilisearch index cannot be empty".to_string(),
+            ));
+        }
+
+        Ok(())
+    }
+
+    async fn check_connectivity(&self, client: &Client) -> Result<(), Error> {
+        let mut retries = 0u32;
+
+        loop {
+            let result = tokio::time::timeout(self.config.timeout, 
client.health()).await;
+            match result {
+                Ok(Ok(health)) if health.status == "available" => return 
Ok(()),
+                Ok(Ok(health)) => {
+                    if retries >= self.config.max_open_retries {
+                        return Err(Error::Connection(format!(
+                            "Meilisearch health check returned status '{}'",
+                            health.status
+                        )));
+                    }
+                    retries += 1;
+                    let delay = jitter(exponential_backoff(
+                        self.config.retry_delay,
+                        retries,
+                        self.config.max_retry_delay,
+                    ));
+                    warn!(
+                        "Meilisearch health check returned status '{}' (retry 
{}/{}). Retrying in {:?}...",
+                        health.status, retries, self.config.max_open_retries, 
delay
+                    );
+                    sleep(delay).await;
+                }
+                Ok(Err(error)) => {
+                    let should_retry =
+                        retries < self.config.max_open_retries && 
is_transient_sdk_error(&error);
+                    if !should_retry {
+                        return Err(map_sdk_error(error));
+                    }
+                    retries += 1;
+                    let delay = jitter(exponential_backoff(
+                        self.config.retry_delay,
+                        retries,
+                        self.config.max_retry_delay,
+                    ));
+                    warn!(
+                        "Meilisearch health check failed (retry {}/{}): {}. 
Retrying in {:?}...",
+                        retries, self.config.max_open_retries, error, delay
+                    );
+                    sleep(delay).await;
+                }
+                Err(_) => {
+                    if retries >= self.config.max_open_retries {
+                        return Err(Error::HttpRequestFailed(format!(
+                            "Meilisearch health check timed out after {:?}",
+                            self.config.timeout
+                        )));
+                    }
+                    retries += 1;
+                    let delay = jitter(exponential_backoff(
+                        self.config.retry_delay,
+                        retries,
+                        self.config.max_retry_delay,
+                    ));
+                    warn!(
+                        "Meilisearch health check timed out after {:?} (retry 
{}/{}). Retrying in {:?}...",
+                        self.config.timeout, retries, 
self.config.max_open_retries, delay
+                    );
+                    sleep(delay).await;
+                }
+            }
+        }
+    }
+
+    async fn ensure_index_exists(&self, client: &Client) -> Result<(), Error> {
+        match self.get_index_if_exists(client).await? {
+            Some(index) => {
+                info!("Meilisearch index '{}' already exists", 
self.config.index);
+                if let Some(primary_key) = index.primary_key.as_deref()
+                    && primary_key != self.config.primary_key
+                {
+                    warn!(
+                        "Meilisearch index '{}' primary key '{}' differs from 
configured primary key '{}'",
+                        self.config.index, primary_key, self.config.primary_key
+                    );
+                } else if index.primary_key.is_none() {
+                    warn!(
+                        "Meilisearch index '{}' does not currently have a 
primary key. Configured primary key '{}' will be sent with document indexing 
requests.",
+                        self.config.index, self.config.primary_key
+                    );
+                }
+                Ok(())
+            }
+            None if self.config.create_index_if_not_exists => 
self.create_index(client).await,
+            None => Err(Error::InitError(format!(
+                "Meilisearch index '{}' does not exist and 
create_index_if_not_exists=false",
+                self.config.index
+            ))),
+        }
+    }
+
+    async fn get_index_if_exists(&self, client: &Client) -> 
Result<Option<Index>, Error> {
+        self.retry_sdk_open_operation("get index", || async {
+            match client.get_index(&self.config.index).await {
+                Ok(index) => Ok(Some(index)),
+                Err(error) if is_index_not_found(&error) => Ok(None),
+                Err(error) => Err(error),
+            }
+        })
+        .await
+    }
+
+    async fn create_index(&self, client: &Client) -> Result<(), Error> {
+        info!(
+            "Creating Meilisearch index '{}' with primary key '{}'",
+            self.config.index, self.config.primary_key
+        );
+
+        let task = self
+            .retry_sdk_open_operation("create index", || {
+                client.create_index(&self.config.index, 
Some(&self.config.primary_key))
+            })
+            .await?;
+        self.wait_for_index_creation_task(client, task).await?;
+
+        info!("Created Meilisearch index '{}'", self.config.index);
+        Ok(())
+    }
+
+    fn prepare_document(
+        &self,
+        topic_metadata: &TopicMetadata,
+        messages_metadata: &MessagesMetadata,
+        message: ConsumedMessage,
+    ) -> Result<Value, Error> {
+        let ConsumedMessage {
+            id: message_id,
+            offset,
+            checksum,
+            timestamp,
+            origin_timestamp,
+            headers,
+            payload,
+        } = message;
+
+        let mut document = match payload {
+            Payload::Json(value) => {
+                
Self::document_from_json_value(owned_value_into_serde_json(value))
+            }
+            Payload::Raw(bytes) => {
+                let mut bytes_copy = bytes.clone();
+                match simd_json::from_slice::<simd_json::OwnedValue>(&mut 
bytes_copy) {
+                    Ok(value) => 
Self::document_from_json_value(owned_value_into_serde_json(value)),
+                    Err(_) => Map::from_iter([
+                        (
+                            "data".to_string(),
+                            
Value::String(general_purpose::STANDARD.encode(&bytes)),
+                        ),
+                        ("data_type".to_string(), 
Value::String("raw".to_string())),
+                        (
+                            "data_encoding".to_string(),
+                            Value::String(ENCODING_BASE64.to_string()),
+                        ),
+                    ]),
+                }
+            }
+            Payload::Text(text) => Map::from_iter([
+                ("text".to_string(), Value::String(text)),
+                ("data_type".to_string(), Value::String("text".to_string())),
+            ]),
+            _ => {
+                return Err(Error::InvalidRecordValue(format!(
+                    "Unsupported payload format for Meilisearch sink: {}",
+                    messages_metadata.schema
+                )));
+            }
+        };
+
+        let mut generated_id = None;
+        if !document.contains_key(self.config.primary_key.as_str()) {
+            let value = generated_document_id_from_parts(
+                topic_metadata,
+                messages_metadata,
+                offset,
+                message_id,
+            )?;
+            if self.config.primary_key != DEFAULT_PRIMARY_KEY {
+                generated_id = Some(value.clone());
+            }
+            document.insert(self.config.primary_key.clone(), 
Value::String(value));
+        }
+
+        if self.config.include_metadata {
+            if self.config.primary_key != DEFAULT_PRIMARY_KEY {
+                let id = match generated_id {
+                    Some(id) => id,
+                    None => generated_document_id_from_parts(
+                        topic_metadata,
+                        messages_metadata,
+                        offset,
+                        message_id,
+                    )?,
+                };
+                upsert_metadata_field(&mut document, DEFAULT_PRIMARY_KEY, 
Value::String(id));
+            }
+            upsert_metadata_field(
+                &mut document,
+                "iggy_message_id",
+                Value::String(message_id.to_string()),
+            );
+            upsert_metadata_field(&mut document, "iggy_offset", 
Value::from(offset));
+            upsert_metadata_field(
+                &mut document,
+                "iggy_stream",
+                Value::from(topic_metadata.stream.as_str()),
+            );
+            upsert_metadata_field(
+                &mut document,
+                "iggy_topic",
+                Value::from(topic_metadata.topic.as_str()),
+            );
+            upsert_metadata_field(
+                &mut document,
+                "iggy_partition",
+                Value::from(messages_metadata.partition_id),
+            );
+            upsert_metadata_field(
+                &mut document,
+                "iggy_checksum",
+                Value::String(checksum.to_string()),
+            );
+            upsert_metadata_field(&mut document, "iggy_timestamp", 
Value::from(timestamp));
+            upsert_metadata_field(
+                &mut document,
+                "iggy_origin_timestamp",
+                Value::from(origin_timestamp),
+            );
+            upsert_metadata_field(
+                &mut document,
+                "iggy_ingested_at",
+                Value::from(IggyTimestamp::now().as_millis() as i64),
+            );
+            if let Some(headers) = &headers
+                && let Ok(headers_value) = serde_json::to_value(headers)
+            {
+                upsert_metadata_field(&mut document, "iggy_headers", 
headers_value);
+            }
+        }
+
+        Ok(Value::Object(document))
+    }
+
+    fn document_from_json_value(value: Value) -> Map<String, Value> {
+        match value {
+            Value::Object(object) => object,
+            other => {
+                let mut object = Map::new();
+                object.insert("value".to_string(), other);
+                object
+            }
+        }
+    }
+
+    async fn index_documents(
+        &self,
+        client: &Client,
+        documents: Vec<Value>,
+    ) -> Result<usize, PartialIndexError> {
+        let mut accepted = 0usize;
+        let mut accounted = 0usize;
+        let documents_count = documents.len();
+        for chunk in documents.chunks(self.config.batch_size) {
+            match self.index_document_chunk(client, chunk).await {
+                Ok(indexed) => {
+                    accepted += indexed;
+                    accounted += chunk.len();
+                }
+                Err(partial_error) => {
+                    let accepted = accepted + partial_error.accepted;
+                    let accounted = accounted + partial_error.accepted + 
partial_error.failed;
+                    let failed = partial_error.failed + 
documents_count.saturating_sub(accounted);
+                    return Err(PartialIndexError {
+                        accepted,
+                        failed,
+                        error: partial_error.error,
+                    });
+                }
+            }
+        }
+        Ok(accepted)
+    }
+
+    async fn index_document_chunk(
+        &self,
+        client: &Client,
+        documents: &[Value],
+    ) -> Result<usize, PartialIndexError> {
+        if documents.is_empty() {
+            return Ok(0);
+        }
+
+        let index = client.index(&self.config.index);
+        let task = match self.config.document_action {
+            MeilisearchDocumentAction::Replace => {
+                self.retry_sdk_operation("add or replace documents", || {
+                    index.add_or_replace(documents, 
Some(&self.config.primary_key))
+                })
+                .await
+            }
+            MeilisearchDocumentAction::Update => {
+                self.retry_sdk_operation("add or update documents", || {
+                    index.add_or_update(documents, 
Some(&self.config.primary_key))
+                })
+                .await
+            }
+        }
+        .map_err(|error| PartialIndexError {
+            accepted: 0,
+            failed: documents.len(),
+            error,
+        })?;
+        self.wait_for_task(client, task)
+            .await
+            .map_err(|error| PartialIndexError {
+                accepted: 0,
+                failed: documents.len(),
+                error,
+            })?;
+        Ok(documents.len())
+    }
+
+    async fn wait_for_task(&self, client: &Client, task: TaskInfo) -> 
Result<(), Error> {
+        if !self.config.wait_for_tasks {
+            return Ok(());
+        }
+
+        self.wait_for_task_completion(client, task).await
+    }
+
+    async fn wait_for_index_creation_task(
+        &self,
+        client: &Client,
+        task: TaskInfo,
+    ) -> Result<(), Error> {
+        let task = self
+            .wait_for_task_status(client, task, self.config.max_open_retries)
+            .await?;
+
+        if task.is_success() {
+            return Ok(());
+        }
+
+        if task.is_failure() {
+            let failure = task.unwrap_failure();
+            if failure.error_code == MeilisearchErrorCode::IndexAlreadyExists {
+                return Ok(());
+            }
+            return Err(Error::PermanentHttpError(format!(
+                "Meilisearch task failed: {}",
+                failure
+            )));
+        }
+
+        Err(Error::HttpRequestFailed(
+            "Meilisearch task did not reach a terminal state".to_string(),
+        ))
+    }
+
+    async fn wait_for_task_completion(&self, client: &Client, task: TaskInfo) 
-> Result<(), Error> {
+        let task = self
+            .wait_for_task_status(client, task, self.config.max_retries)
+            .await?;
+
+        if task.is_success() {
+            return Ok(());
+        }
+
+        if task.is_failure() {
+            let failure = task.unwrap_failure();
+            return Err(Error::PermanentHttpError(format!(
+                "Meilisearch task failed: {}",
+                failure
+            )));
+        }
+
+        Err(Error::HttpRequestFailed(
+            "Meilisearch task did not reach a terminal state".to_string(),
+        ))
+    }
+
+    async fn wait_for_task_status(
+        &self,
+        client: &Client,
+        task: TaskInfo,
+        max_get_task_retries: u32,
+    ) -> Result<Task, Error> {
+        let task_uid = task.get_task_uid();
+        let started = Instant::now();
+
+        loop {
+            let remaining = 
self.config.task_timeout.saturating_sub(started.elapsed());
+            if remaining.is_zero() {
+                break;
+            }
+
+            let status = match tokio::time::timeout(
+                remaining,
+                self.retry_sdk_operation_with_retries(
+                    "get task status",
+                    max_get_task_retries,
+                    || client.get_task(TaskUid(task_uid)),
+                ),
+            )
+            .await
+            {
+                Ok(status) => status?,
+                Err(_) => break,
+            };
+
+            if status.is_success() || status.is_failure() {
+                return Ok(status);
+            }
+
+            let remaining = 
self.config.task_timeout.saturating_sub(started.elapsed());
+            if remaining.is_zero() {
+                break;
+            }
+            sleep(cmp::min(self.config.task_poll_interval, remaining)).await;
+        }
+
+        Err(Error::HttpRequestFailed(format!(
+            "Meilisearch task {task_uid} timed out after {:?}",
+            self.config.task_timeout
+        )))
+    }
+
+    async fn retry_sdk_operation<T, Fut, Op>(
+        &self,
+        operation: &str,
+        operation_fn: Op,
+    ) -> Result<T, Error>
+    where
+        Op: FnMut() -> Fut,
+        Fut: Future<Output = Result<T, MeilisearchSdkError>>,
+    {
+        self.retry_sdk_operation_with_retries(operation, 
self.config.max_retries, operation_fn)
+            .await
+    }
+
+    async fn retry_sdk_open_operation<T, Fut, Op>(
+        &self,
+        operation: &str,
+        operation_fn: Op,
+    ) -> Result<T, Error>
+    where
+        Op: FnMut() -> Fut,
+        Fut: Future<Output = Result<T, MeilisearchSdkError>>,
+    {
+        self.retry_sdk_operation_with_retries(operation, 
self.config.max_open_retries, operation_fn)
+            .await
+    }
+
+    async fn retry_sdk_operation_with_retries<T, Fut, Op>(
+        &self,
+        operation: &str,
+        max_retries: u32,
+        mut operation_fn: Op,
+    ) -> Result<T, Error>
+    where
+        Op: FnMut() -> Fut,
+        Fut: Future<Output = Result<T, MeilisearchSdkError>>,
+    {
+        let started = Instant::now();
+        let mut retries = 0u32;
+
+        loop {
+            let remaining = 
self.config.timeout.saturating_sub(started.elapsed());
+            if remaining.is_zero() {
+                return Err(Error::HttpRequestFailed(format!(
+                    "Meilisearch {operation} timed out after {:?}",
+                    self.config.timeout
+                )));
+            }
+
+            let result = tokio::time::timeout(remaining, operation_fn()).await;
+            match result {
+                Ok(Ok(value)) => return Ok(value),
+                Ok(Err(error)) => {
+                    let should_retry = retries < max_retries && 
is_transient_sdk_error(&error);
+                    if !should_retry {
+                        return Err(map_sdk_error(error));
+                    }
+                    retries += 1;
+                    let delay = jitter(exponential_backoff(
+                        self.config.retry_delay,
+                        retries,
+                        self.config.max_retry_delay,
+                    ));
+                    warn!(
+                        "Meilisearch {operation} failed (retry 
{retries}/{max_retries}): {error}. Retrying in {delay:?}..."
+                    );
+                    let remaining = 
self.config.timeout.saturating_sub(started.elapsed());
+                    if remaining.is_zero() {
+                        return Err(map_sdk_error(error));
+                    }
+                    sleep(cmp::min(delay, remaining)).await;
+                }
+                Err(_) => {
+                    if retries >= max_retries {
+                        return Err(Error::HttpRequestFailed(format!(
+                            "Meilisearch {operation} timed out after {:?}",
+                            self.config.timeout
+                        )));
+                    }
+                    retries += 1;
+                    let delay = jitter(exponential_backoff(
+                        self.config.retry_delay,
+                        retries,
+                        self.config.max_retry_delay,
+                    ));
+                    warn!(
+                        "Meilisearch {operation} timed out after {:?} (retry 
{retries}/{max_retries}). Retrying in {delay:?}...",
+                        self.config.timeout
+                    );
+                    let remaining = 
self.config.timeout.saturating_sub(started.elapsed());
+                    if remaining.is_zero() {
+                        return Err(Error::HttpRequestFailed(format!(
+                            "Meilisearch {operation} timed out after {:?}",
+                            self.config.timeout
+                        )));
+                    }
+                    sleep(cmp::min(delay, remaining)).await;
+                }
+            }
+        }
+    }
+
+    async fn record_errors(&self, errors: usize) {
+        let mut state = self.state.lock().await;
+        state.errors_count += errors;
+    }
+}
+
+#[async_trait]
+impl Sink for MeilisearchSink {
+    async fn open(&mut self) -> Result<(), Error> {
+        self.validate_config()?;
+        info!(
+            "Opening Meilisearch sink connector with ID: {} for URL: {}, 
index: {}",
+            self.id,
+            sanitize_url_for_log(&self.config.url),
+            self.config.index
+        );
+        if self.config.document_action == MeilisearchDocumentAction::Update {
+            warn!(
+                "Meilisearch sink connector with ID: {} is using 
document_action=update. Internal retries and ambiguous task outcomes can apply 
non-idempotent updates more than once.",
+                self.id
+            );
+        }
+        if !self.config.wait_for_tasks {
+            warn!(
+                "Meilisearch sink connector with ID: {} is opening with 
wait_for_tasks=false. Submitted document tasks may still be in flight or fail 
after offsets are committed; this mode does not provide durability.",
+                self.id
+            );
+        }
+
+        let client = self.create_client()?;
+        self.check_connectivity(&client).await?;
+        self.ensure_index_exists(&client).await?;
+
+        self.client = Some(client);
+        info!(
+            "Successfully opened Meilisearch sink connector with ID: {}",
+            self.id
+        );
+        Ok(())
+    }
+
+    async fn consume(
+        &self,
+        topic_metadata: &TopicMetadata,
+        messages_metadata: MessagesMetadata,
+        messages: Vec<ConsumedMessage>,
+    ) -> Result<(), Error> {
+        let mut state = self.state.lock().await;
+        state.invocations_count += 1;
+        let invocation = state.invocations_count;
+        drop(state);
+
+        info!(
+            "Meilisearch sink with ID: {} received: {} messages, schema: {}, 
stream: {}, topic: {}, partition: {}, offset: {}, invocation: {}",
+            self.id,
+            messages.len(),
+            messages_metadata.schema,
+            topic_metadata.stream,
+            topic_metadata.topic,
+            messages_metadata.partition_id,
+            messages_metadata.current_offset,
+            invocation
+        );
+
+        let client = self
+            .client
+            .as_ref()
+            .ok_or_else(|| Error::Connection("Meilisearch client not 
initialized".to_string()))?;
+
+        let messages_count = messages.len();
+        let mut documents = Vec::with_capacity(messages.len());
+        let mut invalid_records = 0usize;
+        for message in messages {
+            match self.prepare_document(topic_metadata, &messages_metadata, 
message) {
+                Ok(document) => documents.push(document),
+                Err(Error::InvalidRecordValue(reason)) => {
+                    invalid_records += 1;
+                    warn!(
+                        "Dropping invalid Meilisearch sink record for 
connector ID: {}, reason: {}",
+                        self.id, reason
+                    );
+                }
+                Err(error) => return Err(error),
+            }
+        }
+        if invalid_records > 0 {
+            self.record_errors(invalid_records).await;
+        }
+
+        if documents.is_empty() {
+            return Ok(());
+        }
+
+        match self.index_documents(client, documents).await {
+            Ok(accepted) => {
+                let mut state = self.state.lock().await;
+                state.documents_enqueued += accepted;
+                if self.config.wait_for_tasks {
+                    state.documents_confirmed += accepted;
+                }
+                info!(
+                    "Accepted {} of {} messages into Meilisearch index '{}'",
+                    accepted, messages_count, self.config.index
+                );
+                Ok(())
+            }
+            Err(partial_error) => {
+                let mut state = self.state.lock().await;
+                state.documents_enqueued += partial_error.accepted;
+                if self.config.wait_for_tasks {
+                    state.documents_confirmed += partial_error.accepted;
+                }
+                state.errors_count += partial_error.failed;
+                drop(state);
+                error!(
+                    "Failed to index Meilisearch sink batch for connector ID: 
{}, index: {}, accepted: {}, failed: {}, error: {}",
+                    self.id,
+                    self.config.index,
+                    partial_error.accepted,
+                    partial_error.failed,
+                    partial_error.error
+                );
+                Err(partial_error.error)
+            }
+        }
+    }
+
+    async fn close(&mut self) -> Result<(), Error> {
+        let state = self.state.lock().await;
+        if self.config.wait_for_tasks {
+            info!(
+                "Meilisearch sink connector with ID: {} is closing. Stats: {} 
invocations, {} documents enqueued, {} documents confirmed, {} errors",
+                self.id,
+                state.invocations_count,
+                state.documents_enqueued,
+                state.documents_confirmed,
+                state.errors_count
+            );
+        } else {
+            warn!(
+                "Meilisearch sink connector with ID: {} is closing with 
wait_for_tasks=false. Submitted document tasks may still be in flight or fail 
after offsets are committed.",
+                self.id
+            );
+            info!(
+                "Meilisearch sink connector with ID: {} is closing. Stats: {} 
invocations, {} documents enqueued, documents confirmed unavailable 
(wait_for_tasks=false), {} errors",
+                self.id, state.invocations_count, state.documents_enqueued, 
state.errors_count
+            );
+        }
+        drop(state);
+
+        self.client = None;
+        info!("Meilisearch sink connector with ID: {} is closed.", self.id);
+        Ok(())
+    }
+}
+
+#[cfg(test)]
+fn generated_document_id(
+    topic_metadata: &TopicMetadata,
+    messages_metadata: &MessagesMetadata,
+    message: &ConsumedMessage,
+) -> String {
+    generated_document_id_from_parts(
+        topic_metadata,
+        messages_metadata,
+        message.offset,
+        message.id,
+    )
+    .expect("test generated ID components should serialize")
+}
+
+fn generated_document_id_from_parts(
+    topic_metadata: &TopicMetadata,
+    messages_metadata: &MessagesMetadata,
+    offset: u64,
+    id: u128,
+) -> Result<String, Error> {
+    let components = json!([
+        topic_metadata.stream.as_str(),
+        topic_metadata.topic.as_str(),
+        messages_metadata.partition_id,
+        offset,
+        id.to_string()
+    ]);
+    let encoded = serde_json::to_vec(&components)
+        .map(|bytes| general_purpose::URL_SAFE_NO_PAD.encode(bytes))
+        .map_err(|error| {
+            Error::Serialization(format!(
+                "Failed to serialize generated document ID: {error}"
+            ))
+        })?;
+    Ok(format!("iggy_{encoded}"))
+}
+
+fn upsert_metadata_field(object: &mut Map<String, Value>, field: &str, value: 
Value) {
+    if object.insert(field.to_string(), value).is_some() {
+        debug!(
+            "Document already contains Meilisearch metadata field '{field}', 
overwriting with connector provenance"
+        );
+    }
+}
+
+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 {
+        return "<invalid-url>".to_string();
+    };
+
+    if !url.username().is_empty() {
+        let _ = url.set_username("");
+    }
+    if url.password().is_some() {
+        let _ = url.set_password(None);
+    }
+    url.to_string().trim_end_matches('/').to_string()
+}
+
+fn normalize_host(raw: &str) -> Result<String, Error> {
+    let trimmed = raw.trim();
+    if trimmed.is_empty() {
+        return Err(Error::Connection(
+            "Invalid Meilisearch URL: host cannot be empty".to_string(),
+        ));
+    }
+
+    let with_scheme = if trimmed.starts_with("http://";) || 
trimmed.starts_with("https://";) {
+        trimmed.to_string()
+    } else {
+        format!("http://{trimmed}";)
+    };
+    let url = Url::parse(&with_scheme)
+        .map_err(|error| Error::Connection(format!("Invalid Meilisearch URL: 
{error}")))?;
+    if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
+        warn!("Ignoring path, query, or fragment from Meilisearch URL");
+    }
+    let mut base_url = url;
+    base_url.set_path("");
+    base_url.set_query(None);
+    base_url.set_fragment(None);
+    Ok(base_url.as_str().trim_end_matches('/').to_string())
+}
+
+fn warn_if_api_key_uses_insecure_http(raw: &str, normalized: &str, 
has_api_key: bool) {
+    if !has_api_key {
+        return;
+    }
+
+    let Ok(url) = Url::parse(normalized) else {
+        return;
+    };
+    if url.scheme() != "http" {
+        return;
+    }
+    let Some(host) = url.host_str() else {
+        return;
+    };
+    if is_loopback_host(host) {
+        return;
+    }
+
+    let scheme_hint = if raw.trim().starts_with("http://";) {
+        "explicit http://";
+    } else {
+        "implicit http://";
+    };
+    warn!(
+        "Meilisearch API key is configured with {scheme_hint} for non-loopback 
host '{host}'. Credentials will be sent without TLS; use https:// unless this 
is intentional."
+    );
+}
+
+fn is_loopback_host(host: &str) -> bool {
+    host.eq_ignore_ascii_case("localhost")
+        || host
+            .parse::<IpAddr>()
+            .map(|address| address.is_loopback())
+            .unwrap_or(false)
+}
+
+#[derive(Debug)]
+struct PartialIndexError {
+    accepted: usize,
+    failed: usize,
+    error: Error,
+}
+
+#[derive(Clone, Copy)]
+struct TaskUid(u32);
+
+impl AsRef<u32> for TaskUid {
+    fn as_ref(&self) -> &u32 {
+        &self.0
+    }
+}
+
+fn is_index_not_found(error: &MeilisearchSdkError) -> bool {
+    matches!(
+        error,
+        MeilisearchSdkError::Meilisearch(meilisearch_error)
+            if meilisearch_error.error_code == 
MeilisearchErrorCode::IndexNotFound
+    )
+}
+
+fn is_transient_sdk_error(error: &MeilisearchSdkError) -> bool {
+    match error {
+        MeilisearchSdkError::Meilisearch(meilisearch_error) => {
+            meilisearch_error.error_type == MeilisearchErrorType::Internal
+        }
+        MeilisearchSdkError::MeilisearchCommunication(communication_error) => {
+            communication_error.status_code == 0
+                || communication_error.status_code == 429
+                || communication_error.status_code >= 500
+        }
+        MeilisearchSdkError::HttpError(_) | MeilisearchSdkError::Timeout => 
true,
+        _ => false,
+    }
+}
+
+fn map_sdk_error(error: MeilisearchSdkError) -> Error {
+    match error {
+        MeilisearchSdkError::Meilisearch(meilisearch_error) => {
+            if meilisearch_error.error_type == MeilisearchErrorType::Internal {
+                Error::HttpRequestFailed(meilisearch_error.to_string())
+            } else {
+                Error::PermanentHttpError(meilisearch_error.to_string())
+            }
+        }
+        MeilisearchSdkError::MeilisearchCommunication(communication_error) => {
+            if communication_error.status_code == 0
+                || communication_error.status_code == 429
+                || communication_error.status_code >= 500
+            {
+                Error::HttpRequestFailed(communication_error.to_string())
+            } else {
+                Error::PermanentHttpError(communication_error.to_string())
+            }
+        }
+        MeilisearchSdkError::ParseError(error) => {
+            Error::Serialization(format!("Invalid Meilisearch response: 
{error}"))
+        }
+        MeilisearchSdkError::Timeout => {
+            Error::HttpRequestFailed("Meilisearch task timed out".to_string())
+        }
+        MeilisearchSdkError::HttpError(error) => 
Error::HttpRequestFailed(error.to_string()),
+        other => Error::HttpRequestFailed(other.to_string()),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use iggy_connector_sdk::Schema;
+
+    fn topic_metadata() -> TopicMetadata {
+        TopicMetadata {
+            stream: "orders.stream".to_string(),
+            topic: "created/topic".to_string(),
+        }
+    }
+
+    fn messages_metadata() -> MessagesMetadata {
+        MessagesMetadata {
+            partition_id: 7,
+            current_offset: 10,
+            schema: Schema::Json,
+        }
+    }
+
+    fn message(payload: Payload) -> ConsumedMessage {
+        ConsumedMessage {
+            id: 42,
+            offset: 11,
+            checksum: 12,
+            timestamp: 13,
+            origin_timestamp: 14,
+            headers: None,
+            payload,
+        }
+    }
+
+    fn sink_with_config(config: MeilisearchSinkConfig) -> MeilisearchSink {
+        MeilisearchSink::new(1, config)
+    }
+
+    fn base_config() -> MeilisearchSinkConfig {
+        MeilisearchSinkConfig {
+            url: "http://localhost:7700".to_string(),
+            index: "messages".to_string(),
+            api_key: None,
+            primary_key: None,
+            document_action: None,
+            create_index_if_not_exists: None,
+            include_metadata: None,
+            batch_size: None,
+            timeout: None,
+            wait_for_tasks: None,
+            task_timeout: None,
+            task_poll_interval: None,
+            max_retries: None,
+            retry_delay: None,
+            max_retry_delay: None,
+            max_open_retries: None,
+        }
+    }
+
+    #[test]
+    fn swaps_retry_delays_when_retry_delay_exceeds_max_retry_delay() {
+        let mut config = base_config();
+        config.retry_delay = Some("10s".to_string());
+        config.max_retry_delay = Some("1s".to_string());
+
+        let sink = sink_with_config(config);
+
+        assert_eq!(sink.config.retry_delay, Duration::from_secs(1));
+        assert_eq!(sink.config.max_retry_delay, Duration::from_secs(10));
+    }
+
+    #[test]
+    fn generated_ids_use_meilisearch_safe_characters() {
+        let id = generated_document_id(
+            &topic_metadata(),
+            &messages_metadata(),
+            &message(Payload::Text("x".to_string())),
+        );
+
+        assert!(id.starts_with("iggy_"));
+        assert!(
+            id.chars()
+                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
+        );
+    }
+
+    #[test]
+    fn generated_ids_do_not_collapse_sanitized_names() {
+        let first_topic = TopicMetadata {
+            stream: "orders.stream".to_string(),
+            topic: "created/topic".to_string(),
+        };
+        let second_topic = TopicMetadata {
+            stream: "orders/stream".to_string(),
+            topic: "created.topic".to_string(),
+        };
+
+        let first = generated_document_id(
+            &first_topic,
+            &messages_metadata(),
+            &message(Payload::Text("x".to_string())),
+        );
+        let second = generated_document_id(
+            &second_topic,
+            &messages_metadata(),
+            &message(Payload::Text("x".to_string())),
+        );
+
+        assert_ne!(first, second);
+    }
+
+    #[test]
+    fn generated_ids_support_u128_message_ids() {
+        let mut message = message(Payload::Text("x".to_string()));
+        message.id = u128::MAX;
+
+        let id = generated_document_id(&topic_metadata(), 
&messages_metadata(), &message);
+
+        assert!(id.starts_with("iggy_"));
+    }
+
+    #[test]
+    fn injects_default_primary_key_and_metadata() {
+        let sink = sink_with_config(base_config());
+        let payload = Payload::Json(simd_json::json!({
+            "name": "Alice"
+        }));
+        let message = message(payload);
+        let expected_id = generated_document_id(&topic_metadata(), 
&messages_metadata(), &message);
+
+        let document = sink
+            .prepare_document(&topic_metadata(), &messages_metadata(), message)
+            .expect("prepare document");
+
+        assert_eq!(document["name"], "Alice");
+        assert_eq!(document["iggy_id"], expected_id);
+        assert_eq!(document["iggy_offset"], 11);
+        assert_eq!(document["iggy_stream"], "orders.stream");
+        assert_eq!(document["iggy_topic"], "created/topic");
+        assert_eq!(document["iggy_checksum"], "12");
+    }
+
+    #[test]
+    fn preserves_existing_configured_primary_key() {
+        let mut config = base_config();
+        config.primary_key = Some("id".to_string());
+        let sink = sink_with_config(config);
+        let payload = Payload::Json(simd_json::json!({
+            "id": "existing",
+            "name": "Alice"
+        }));
+        let message = message(payload);
+        let expected_id = generated_document_id(&topic_metadata(), 
&messages_metadata(), &message);
+
+        let document = sink
+            .prepare_document(&topic_metadata(), &messages_metadata(), message)
+            .expect("prepare document");
+
+        assert_eq!(document["id"], "existing");
+        assert_eq!(document["iggy_id"], expected_id);
+    }
+
+    #[test]
+    fn overwrites_reserved_metadata_fields() {
+        let sink = sink_with_config(base_config());
+        let payload = Payload::Json(simd_json::json!({
+            "name": "Alice",
+            "iggy_offset": 999,
+            "iggy_stream": "user-stream",
+            "iggy_checksum": 999
+        }));
+
+        let document = sink
+            .prepare_document(&topic_metadata(), &messages_metadata(), 
message(payload))
+            .expect("prepare document");
+
+        assert_eq!(document["iggy_offset"], 11);
+        assert_eq!(document["iggy_stream"], "orders.stream");
+        assert_eq!(document["iggy_checksum"], "12");
+    }
+
+    #[test]
+    fn omits_metadata_when_include_metadata_is_false() {
+        let mut config = base_config();
+        config.include_metadata = Some(false);
+        let sink = sink_with_config(config);
+        let payload = Payload::Json(simd_json::json!({
+            "name": "Alice"
+        }));
+
+        let document = sink
+            .prepare_document(&topic_metadata(), &messages_metadata(), 
message(payload))
+            .expect("prepare document");
+
+        assert_eq!(document["name"], "Alice");
+        assert!(document["iggy_id"].as_str().is_some());
+        assert!(document.get("iggy_offset").is_none());
+        assert!(document.get("iggy_stream").is_none());
+    }
+
+    #[test]
+    fn wraps_non_object_json_payloads() {
+        let sink = sink_with_config(base_config());
+        let message = message(Payload::Json(simd_json::json!(["a", "b"])));
+        let expected_id = generated_document_id(&topic_metadata(), 
&messages_metadata(), &message);
+
+        let document = sink
+            .prepare_document(&topic_metadata(), &messages_metadata(), message)
+            .expect("prepare document");
+
+        assert_eq!(document["value"], json!(["a", "b"]));
+        assert_eq!(document["iggy_id"], expected_id);
+    }
+
+    #[test]
+    fn raw_payloads_are_base64_encoded_when_not_json() {
+        let sink = sink_with_config(base_config());
+
+        let document = sink
+            .prepare_document(
+                &topic_metadata(),
+                &messages_metadata(),
+                message(Payload::Raw(vec![0, 1, 2, 3])),
+            )
+            .expect("prepare document");
+
+        assert_eq!(document["data"], "AAECAw==");
+        assert_eq!(document["data_encoding"], ENCODING_BASE64);
+    }
+
+    #[test]
+    fn raw_payloads_preserve_original_bytes_when_json_parse_mutates_buffer() {
+        let sink = sink_with_config(base_config());
+        let bytes = b"[1,2,3".to_vec();
+        let expected = general_purpose::STANDARD.encode(&bytes);
+
+        let document = sink
+            .prepare_document(
+                &topic_metadata(),
+                &messages_metadata(),
+                message(Payload::Raw(bytes)),
+            )
+            .expect("prepare document");
+
+        assert_eq!(document["data"], expected);
+        assert_eq!(document["data_encoding"], ENCODING_BASE64);
+    }
+
+    #[test]
+    fn sanitize_url_should_redact_credentials_without_scheme() {
+        let url = sanitize_url_for_log("user:pass@localhost:7700/indexes");
+        assert_eq!(url, "http://localhost:7700";);
+    }
+
+    #[test]
+    fn normalize_host_should_strip_path_query_and_fragment() {
+        let url =
+            
normalize_host("https://localhost:7700/path?foo=bar#section";).expect("normalize 
host");
+
+        assert_eq!(url, "https://localhost:7700";);
+    }
+
+    #[test]
+    fn validate_config_should_reject_empty_index() {
+        let mut config = base_config();
+        config.index = "  ".to_string();
+        let sink = sink_with_config(config);
+
+        let error = sink.validate_config().expect_err("empty index should 
fail");
+
+        assert!(matches!(error, Error::InvalidConfigValue(_)));
+    }
+
+    #[test]
+    fn loopback_host_detection_allows_local_addresses() {
+        assert!(is_loopback_host("localhost"));
+        assert!(is_loopback_host("127.0.0.1"));
+        assert!(is_loopback_host("::1"));
+        assert!(!is_loopback_host("meili.prod"));
+    }
+
+    #[test]
+    fn unsupported_payloads_return_error() {
+        let sink = sink_with_config(base_config());
+        let error = sink
+            .prepare_document(
+                &topic_metadata(),
+                &messages_metadata(),
+                message(Payload::Avro(vec![1, 2, 3])),
+            )
+            .expect_err("unsupported payload should fail");
+
+        assert!(matches!(error, Error::InvalidRecordValue(_)));
+    }
+}
diff --git 
a/core/integration/tests/connectors/fixtures/meilisearch/container.rs 
b/core/integration/tests/connectors/fixtures/meilisearch/container.rs
new file mode 100644
index 000000000..55cadbe21
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/meilisearch/container.rs
@@ -0,0 +1,180 @@
+// 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::fixtures;
+use integration::harness::TestBinaryError;
+use reqwest_middleware::ClientWithMiddleware as HttpClient;
+use reqwest_retry::RetryTransientMiddleware;
+use reqwest_retry::policies::ExponentialBackoff;
+use serde::Deserialize;
+use std::time::Duration;
+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 tokio::time::sleep;
+use tracing::info;
+use uuid::Uuid;
+
+const MEILISEARCH_IMAGE: &str = "getmeili/meilisearch";
+const MEILISEARCH_TAG: &str = "v1.13";
+const MEILISEARCH_PORT: u16 = 7700;
+const MEILISEARCH_HEALTH_ENDPOINT: &str = "/health";
+pub const TEST_INDEX: &str = "iggy_messages";
+const POLL_ATTEMPTS: usize = 100;
+const POLL_INTERVAL_MS: u64 = 50;
+
+#[derive(Debug, Deserialize)]
+pub struct MeilisearchDocumentsResponse {
+    pub results: Vec<serde_json::Value>,
+}
+
+pub struct MeilisearchContainer {
+    #[allow(dead_code)]
+    container: ContainerAsync<GenericImage>,
+    pub base_url: String,
+}
+
+impl MeilisearchContainer {
+    pub async fn start() -> Result<Self, TestBinaryError> {
+        let unique_network = format!("iggy-meilisearch-{}", Uuid::new_v4());
+
+        let container = GenericImage::new(MEILISEARCH_IMAGE, MEILISEARCH_TAG)
+            .with_exposed_port(MEILISEARCH_PORT.tcp())
+            .with_wait_for(WaitFor::http(
+                HttpWaitStrategy::new(MEILISEARCH_HEALTH_ENDPOINT)
+                    .with_port(MEILISEARCH_PORT.tcp())
+                    .with_expected_status_code(200u16),
+            ))
+            .with_network(unique_network)
+            
.with_container_name(fixtures::unique_container_name("meilisearch"))
+            .with_env_var("MEILI_ENV", "development")
+            .with_mapped_port(0, MEILISEARCH_PORT.tcp())
+            .start()
+            .await
+            .map_err(|e| TestBinaryError::FixtureSetup {
+                fixture_type: "MeilisearchContainer".to_string(),
+                message: format!("Failed to start container: {e}"),
+            })?;
+
+        info!("Started Meilisearch container");
+
+        let mapped_port = container
+            .ports()
+            .await
+            .map_err(|e| TestBinaryError::FixtureSetup {
+                fixture_type: "MeilisearchContainer".to_string(),
+                message: format!("Failed to get ports: {e}"),
+            })?
+            .map_to_host_port_ipv4(MEILISEARCH_PORT)
+            .ok_or_else(|| TestBinaryError::FixtureSetup {
+                fixture_type: "MeilisearchContainer".to_string(),
+                message: "No mapping for Meilisearch port".to_string(),
+            })?;
+
+        let base_url = format!("http://localhost:{mapped_port}";);
+        info!("Meilisearch container available at {base_url}");
+
+        Ok(Self {
+            container,
+            base_url,
+        })
+    }
+}
+
+pub fn create_http_client() -> HttpClient {
+    let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
+    let client = reqwest::Client::builder()
+        .timeout(Duration::from_secs(30))
+        .build()
+        .expect("Failed to build HTTP client");
+    reqwest_middleware::ClientBuilder::new(client)
+        .with(RetryTransientMiddleware::new_with_policy(retry_policy))
+        .build()
+}
+
+pub trait MeilisearchOps: Sync {
+    fn container(&self) -> &MeilisearchContainer;
+    fn http_client(&self) -> &HttpClient;
+
+    fn list_documents(
+        &self,
+        index_name: &str,
+    ) -> impl std::future::Future<Output = Result<Vec<serde_json::Value>, 
TestBinaryError>> + Send
+    {
+        async move {
+            let url = format!(
+                "{}/indexes/{}/documents",
+                self.container().base_url,
+                index_name
+            );
+            let response = self
+                .http_client()
+                .get(&url)
+                .query(&[("limit", "100")])
+                .send()
+                .await
+                .map_err(|e| TestBinaryError::InvalidState {
+                    message: format!("Failed to list Meilisearch documents: 
{e}"),
+                })?;
+
+            if !response.status().is_success() {
+                let status = response.status();
+                let body = response.text().await.unwrap_or_default();
+                return Err(TestBinaryError::InvalidState {
+                    message: format!(
+                        "Failed to list Meilisearch documents: 
status={status}, body={body}"
+                    ),
+                });
+            }
+
+            response
+                .json::<MeilisearchDocumentsResponse>()
+                .await
+                .map(|documents| documents.results)
+                .map_err(|e| TestBinaryError::InvalidState {
+                    message: format!("Failed to parse Meilisearch documents 
response: {e}"),
+                })
+        }
+    }
+
+    fn wait_for_documents(
+        &self,
+        index_name: &str,
+        expected_count: usize,
+    ) -> impl std::future::Future<Output = Result<Vec<serde_json::Value>, 
TestBinaryError>> + Send
+    {
+        async move {
+            let mut last_count = 0usize;
+            for _ in 0..POLL_ATTEMPTS {
+                if let Ok(documents) = self.list_documents(index_name).await {
+                    last_count = documents.len();
+                    if documents.len() >= expected_count {
+                        return Ok(documents);
+                    }
+                }
+                sleep(Duration::from_millis(POLL_INTERVAL_MS)).await;
+            }
+
+            Err(TestBinaryError::InvalidState {
+                message: format!(
+                    "Expected {expected_count} Meilisearch documents, found 
{last_count} after {POLL_ATTEMPTS} attempts"
+                ),
+            })
+        }
+    }
+}
diff --git a/core/integration/tests/connectors/fixtures/meilisearch/mod.rs 
b/core/integration/tests/connectors/fixtures/meilisearch/mod.rs
new file mode 100644
index 000000000..09e0b9d91
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/meilisearch/mod.rs
@@ -0,0 +1,22 @@
+// 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 container;
+mod sink;
+
+pub use container::{MeilisearchOps, TEST_INDEX};
+pub use sink::MeilisearchSinkFixture;
diff --git a/core/integration/tests/connectors/fixtures/meilisearch/sink.rs 
b/core/integration/tests/connectors/fixtures/meilisearch/sink.rs
new file mode 100644
index 000000000..23f4ce7be
--- /dev/null
+++ b/core/integration/tests/connectors/fixtures/meilisearch/sink.rs
@@ -0,0 +1,90 @@
+// 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 super::container::{MeilisearchContainer, MeilisearchOps, TEST_INDEX, 
create_http_client};
+use async_trait::async_trait;
+use integration::harness::{TestBinaryError, TestFixture, seeds};
+use reqwest_middleware::ClientWithMiddleware as HttpClient;
+use std::collections::HashMap;
+
+const ENV_SINK_URL: &str = 
"IGGY_CONNECTORS_SINK_MEILISEARCH_PLUGIN_CONFIG_URL";
+const ENV_SINK_INDEX: &str = 
"IGGY_CONNECTORS_SINK_MEILISEARCH_PLUGIN_CONFIG_INDEX";
+const ENV_SINK_PRIMARY_KEY: &str = 
"IGGY_CONNECTORS_SINK_MEILISEARCH_PLUGIN_CONFIG_PRIMARY_KEY";
+const ENV_SINK_TASK_POLL_INTERVAL: &str =
+    "IGGY_CONNECTORS_SINK_MEILISEARCH_PLUGIN_CONFIG_TASK_POLL_INTERVAL";
+const ENV_SINK_TASK_TIMEOUT: &str = 
"IGGY_CONNECTORS_SINK_MEILISEARCH_PLUGIN_CONFIG_TASK_TIMEOUT";
+const ENV_SINK_STREAMS_0_STREAM: &str = 
"IGGY_CONNECTORS_SINK_MEILISEARCH_STREAMS_0_STREAM";
+const ENV_SINK_STREAMS_0_TOPICS: &str = 
"IGGY_CONNECTORS_SINK_MEILISEARCH_STREAMS_0_TOPICS";
+const ENV_SINK_STREAMS_0_SCHEMA: &str = 
"IGGY_CONNECTORS_SINK_MEILISEARCH_STREAMS_0_SCHEMA";
+const ENV_SINK_STREAMS_0_CONSUMER_GROUP: &str =
+    "IGGY_CONNECTORS_SINK_MEILISEARCH_STREAMS_0_CONSUMER_GROUP";
+const ENV_SINK_PATH: &str = "IGGY_CONNECTORS_SINK_MEILISEARCH_PATH";
+
+pub struct MeilisearchSinkFixture {
+    container: MeilisearchContainer,
+    http_client: HttpClient,
+}
+
+impl MeilisearchOps for MeilisearchSinkFixture {
+    fn container(&self) -> &MeilisearchContainer {
+        &self.container
+    }
+
+    fn http_client(&self) -> &HttpClient {
+        &self.http_client
+    }
+}
+
+#[async_trait]
+impl TestFixture for MeilisearchSinkFixture {
+    async fn setup() -> Result<Self, TestBinaryError> {
+        let container = MeilisearchContainer::start().await?;
+        let http_client = create_http_client();
+
+        Ok(Self {
+            container,
+            http_client,
+        })
+    }
+
+    fn connectors_runtime_envs(&self) -> HashMap<String, String> {
+        HashMap::from([
+            (ENV_SINK_URL.to_string(), self.container.base_url.clone()),
+            (ENV_SINK_INDEX.to_string(), TEST_INDEX.to_string()),
+            (ENV_SINK_PRIMARY_KEY.to_string(), "iggy_id".to_string()),
+            (ENV_SINK_TASK_TIMEOUT.to_string(), "10s".to_string()),
+            (ENV_SINK_TASK_POLL_INTERVAL.to_string(), "25ms".to_string()),
+            (
+                ENV_SINK_STREAMS_0_STREAM.to_string(),
+                seeds::names::STREAM.to_string(),
+            ),
+            (
+                ENV_SINK_STREAMS_0_TOPICS.to_string(),
+                format!("[{}]", seeds::names::TOPIC),
+            ),
+            (ENV_SINK_STREAMS_0_SCHEMA.to_string(), "json".to_string()),
+            (
+                ENV_SINK_STREAMS_0_CONSUMER_GROUP.to_string(),
+                "meilisearch_sink".to_string(),
+            ),
+            (
+                ENV_SINK_PATH.to_string(),
+                
"../../target/debug/libiggy_connector_meilisearch_sink".to_string(),
+            ),
+        ])
+    }
+}
diff --git a/core/integration/tests/connectors/fixtures/mod.rs 
b/core/integration/tests/connectors/fixtures/mod.rs
index 6d75fbf97..e4992d678 100644
--- a/core/integration/tests/connectors/fixtures/mod.rs
+++ b/core/integration/tests/connectors/fixtures/mod.rs
@@ -24,6 +24,7 @@ mod elasticsearch;
 mod http;
 mod iceberg;
 mod influxdb;
+mod meilisearch;
 mod mongodb;
 mod postgres;
 mod quickwit;
@@ -68,6 +69,7 @@ pub use influxdb::{
     InfluxDbSinkNoMetadataFixture, InfluxDbSinkNsPrecisionFixture, 
InfluxDbSinkTextFixture,
     InfluxDbSourceFixture, InfluxDbSourceRawFixture, InfluxDbSourceTextFixture,
 };
+pub use meilisearch::{MeilisearchOps, MeilisearchSinkFixture, TEST_INDEX};
 pub use mongodb::{
     MongoDbOps, MongoDbSinkAutoCreateFixture, MongoDbSinkBatchFixture, 
MongoDbSinkFailpointFixture,
     MongoDbSinkFixture, MongoDbSinkJsonFixture, MongoDbSinkWriteConcernFixture,
diff --git a/core/integration/tests/connectors/meilisearch/meilisearch_sink.rs 
b/core/integration/tests/connectors/meilisearch/meilisearch_sink.rs
new file mode 100644
index 000000000..17a81db44
--- /dev/null
+++ b/core/integration/tests/connectors/meilisearch/meilisearch_sink.rs
@@ -0,0 +1,81 @@
+// 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::fixtures::{MeilisearchOps, MeilisearchSinkFixture, 
TEST_INDEX};
+use bytes::Bytes;
+use iggy::prelude::{IggyMessage, Partitioning};
+use iggy_common::{Identifier, MessageClient};
+use integration::harness::seeds;
+use integration::iggy_harness;
+
+#[iggy_harness(
+    server(connectors_runtime(config_path = 
"tests/connectors/meilisearch/sink.toml")),
+    seed = seeds::connector_stream
+)]
+async fn given_json_messages_when_sink_consumes_should_index_documents(
+    harness: &TestHarness,
+    fixture: MeilisearchSinkFixture,
+) {
+    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 payloads = [
+        serde_json::json!({"name": "first", "category": "alpha"}),
+        serde_json::json!({"name": "second", "category": "beta"}),
+    ];
+
+    let mut messages = payloads
+        .iter()
+        .enumerate()
+        .map(|(i, payload)| {
+            IggyMessage::builder()
+                .id((i + 1) as u128)
+                
.payload(Bytes::from(serde_json::to_vec(payload).expect("serialize")))
+                .build()
+                .expect("build message")
+        })
+        .collect::<Vec<_>>();
+
+    client
+        .send_messages(
+            &stream_id,
+            &topic_id,
+            &Partitioning::partition_id(0),
+            &mut messages,
+        )
+        .await
+        .expect("send messages");
+
+    let documents = fixture
+        .wait_for_documents(TEST_INDEX, payloads.len())
+        .await
+        .expect("wait for Meilisearch documents");
+
+    assert_eq!(documents.len(), payloads.len());
+    assert!(documents.iter().any(|document| document["name"] == "first"));
+    assert!(
+        documents
+            .iter()
+            .any(|document| document["name"] == "second")
+    );
+    assert!(
+        documents
+            .iter()
+            .all(|document| document["iggy_id"].as_str().is_some())
+    );
+}
diff --git a/core/integration/tests/connectors/meilisearch/mod.rs 
b/core/integration/tests/connectors/meilisearch/mod.rs
new file mode 100644
index 000000000..fd50dc367
--- /dev/null
+++ b/core/integration/tests/connectors/meilisearch/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 meilisearch_sink;
diff --git a/core/integration/tests/connectors/meilisearch/sink.toml 
b/core/integration/tests/connectors/meilisearch/sink.toml
new file mode 100644
index 000000000..0a846ac96
--- /dev/null
+++ b/core/integration/tests/connectors/meilisearch/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/meilisearch_sink"
diff --git a/core/integration/tests/connectors/mod.rs 
b/core/integration/tests/connectors/mod.rs
index a1433160b..08b794fe8 100644
--- a/core/integration/tests/connectors/mod.rs
+++ b/core/integration/tests/connectors/mod.rs
@@ -25,6 +25,7 @@ mod http;
 mod http_config_provider;
 mod iceberg;
 mod influxdb;
+mod meilisearch;
 mod mongodb;
 mod postgres;
 mod quickwit;

Reply via email to