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

github-merge-queue[bot] pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new c2bd543ffc Connector couchbase migration (#12076)
c2bd543ffc is described below

commit c2bd543ffccacd50f7d55425f6118f7771ba00a1
Author: Kaustubh Abhinand <[email protected]>
AuthorDate: Sat Sep 5 12:56:44 2026 +0000

    Connector couchbase migration (#12076)
---
 docs/en/connectors/sink/Couchbase.md               |  23 +-
 docs/zh/connectors/sink/Couchbase.md               |  21 +-
 .../couchbase/config/CouchbaseSinkOptions.java     |  13 --
 .../couchbase/sink/CouchbaseSinkFactory.java       |   3 -
 .../seatunnel/couchbase/sink/CouchbaseWriter.java  | 137 ++---------
 .../couchbase/sink/CouchbaseWriterOptions.java     |   8 -
 .../couchbase/sink/CouchbaseWriterCloseTest.java   | 256 ---------------------
 .../sink/CouchbaseWriterConstructorLeakTest.java   |   1 -
 .../couchbase/sink/CouchbaseWriterTest.java        | 250 ++++++++++++++++++++
 .../e2e/connector/couchbase/CouchbaseIT.java       | 110 ++++++++-
 .../test/resources/fake_source_to_couchbase.conf   |   1 -
 ...f => fake_source_to_couchbase_timer_flush.conf} |  16 +-
 12 files changed, 418 insertions(+), 421 deletions(-)

diff --git a/docs/en/connectors/sink/Couchbase.md 
b/docs/en/connectors/sink/Couchbase.md
index e543fdff35..1304718d51 100644
--- a/docs/en/connectors/sink/Couchbase.md
+++ b/docs/en/connectors/sink/Couchbase.md
@@ -81,7 +81,6 @@ Couchbase stores JSON documents. The connector maps SeaTunnel 
types to JSON valu
 | primary-key           | `List<String>` | No       | -          | Field names 
used to build the document key (length-prefixed encoding: `<len>:<value>` 
components separated by `#`). A random UUID is used when not set. |
 | upsert-enable         | Boolean       | No       | `false`    | Enable 
upsert (insert-or-replace) mode. When `false`, duplicate keys will cause an 
error. |
 | buffer-flush.max-rows | Integer       | No       | `1000`     | Maximum rows 
to buffer before a batch write is triggered. Use `-1` to disable. |
-| buffer-flush.interval | Long          | No       | `30000`    | Maximum 
milliseconds between batch writes. Use `-1` to disable. |
 | retry.max             | Integer       | No       | `3`        | Maximum 
retry attempts on transient write failure. |
 | retry.interval        | Long          | No       | `1000`     | Base 
milliseconds for linear retry delay. Attempt `n` waits `retry.interval × n` ms. 
|
 
@@ -161,11 +160,31 @@ sink {
     primary-key            = ["user_id", "order_id"]
     upsert-enable          = true
     buffer-flush.max-rows  = 500
-    buffer-flush.interval  = 10000
     retry.max              = 5
     retry.interval         = 2000
   }
 }
 ```
 
+### Timer Flush
+
+The sink can flush its buffer on a timer so that buffered rows are written 
even when the upstream  
+flow is idle and fewer than `buffer-flush.max-rows` rows have been buffered. 
This timer is driven  
+by the engine, not by the connector, and is currently supported only by 
**SeaTunnel Zeta**.
+
+Enable it by setting `sink.flush.interval` (milliseconds) in the job `env` 
block:
+
+```hocon  
+env {  
+sink.flush.interval = 10000  
+}  
+```
+
+> On Spark and Flink there is no sub-checkpoint timer flush: 
`sink.flush.interval` is a Zeta engine  
+> primitive, and the Spark/Flink sink writer context does not implement it. On 
those engines the  
+> buffer is flushed when it reaches `buffer-flush.max-rows`, on checkpoint 
(`CouchbaseWriter`  
+> flushes in `prepareCommit()`), and when the writer is closed. For lower 
latency between  
+> checkpoints on Spark or Flink, tune `buffer-flush.max-rows` accordingly.
+
+
 <ChangeLog />
diff --git a/docs/zh/connectors/sink/Couchbase.md 
b/docs/zh/connectors/sink/Couchbase.md
index dbf101070b..43883e9544 100644
--- a/docs/zh/connectors/sink/Couchbase.md
+++ b/docs/zh/connectors/sink/Couchbase.md
@@ -76,7 +76,6 @@ sh bin/install-plugin.sh ${version}
 | primary-key            | `List<String>`  | 否       | -          | 
用于构建文档键的字段名列表(长度前缀编码:`<长度>:<值>` 分量以 `#` 分隔)。未设置时使用随机 UUID。 |
 | upsert-enable          | Boolean        | 否       | `false`    | 是否启用 
Upsert(插入或替换)模式。为 `false` 时,重复键将报错。 |
 | buffer-flush.max-rows  | Integer        | 否       | `1000`     | 
触发批量写入的最大缓冲行数。设为 `-1` 禁用。 |
-| buffer-flush.interval  | Long           | 否       | `30000`    | 
批量写入之间的最大间隔(毫秒)。设为 `-1` 禁用。 |
 | retry.max              | Integer        | 否       | `3`        | 
写入失败时的最大重试次数。 |
 | retry.interval         | Long           | 否       | `1000`     | 
线性退避基础间隔(毫秒)。第 n 次重试等待 `retry.interval × n` 毫秒。 |
 
@@ -154,11 +153,29 @@ sink {
     primary-key            = ["user_id", "order_id"]
     upsert-enable          = true
     buffer-flush.max-rows  = 500
-    buffer-flush.interval  = 10000
     retry.max              = 5
     retry.interval         = 2000
   }
 }
 ```
 
+### 定时刷新
+
+该连接器支持在空闲时按计时器刷新缓冲区,即使尚未达到 `buffer-flush.max-rows`,也能定期发送已缓冲的记录。  
+此定时器由**引擎**驱动,而非连接器本身,目前仅 **SeaTunnel Zeta** 支持。
+
+在 Job 的 `env` 块中设置 `sink.flush.interval`(毫秒)即可启用:
+
+```hocon  
+env {  
+  sink.flush.interval = 10000  
+}
+
+```
+> 在 Spark 和 Flink 上没有检查点之间的定时刷新:`sink.flush.interval` 是 Zeta 引擎的能力,  
+> Spark/Flink 的 Sink 写入器上下文并未实现它。在这两个引擎上,缓存会在达到  
+> `buffer-flush.max-rows`、检查点时(`CouchbaseWriter` 在 `prepareCommit()` 中刷新)  
+> 以及写入器关闭时被刷新。如需降低 Spark 或 Flink 上检查点之间的延迟,  
+> 请相应调整 `buffer-flush.max-rows`。
+
 <ChangeLog />
diff --git 
a/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/config/CouchbaseSinkOptions.java
 
b/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/config/CouchbaseSinkOptions.java
index 6162ec3940..4d09a802c5 100644
--- 
a/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/config/CouchbaseSinkOptions.java
+++ 
b/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/config/CouchbaseSinkOptions.java
@@ -38,19 +38,6 @@ public class CouchbaseSinkOptions extends CouchbaseConfig {
                             "The maximum number of buffered rows per batch 
write request."
                                     + " Use -1 to disable size-based 
flushing.");
 
-    /**
-     * Maximum time (ms) between two consecutive batch writes.
-     *
-     * <p>A value of {@code -1} disables interval-based flushing.
-     */
-    public static final Option<Long> BUFFER_FLUSH_INTERVAL =
-            Options.key("buffer-flush.interval")
-                    .longType()
-                    .defaultValue(30000L)
-                    .withDescription(
-                            "The maximum interval between batch write 
requests, in milliseconds."
-                                    + " Use -1 to disable interval-based 
flushing.");
-
     /** Number of retry attempts on transient write failures before giving up. 
*/
     public static final Option<Integer> RETRY_MAX =
             Options.key("retry.max")
diff --git 
a/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseSinkFactory.java
 
b/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseSinkFactory.java
index 2fff3c8663..630580311e 100644
--- 
a/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseSinkFactory.java
+++ 
b/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseSinkFactory.java
@@ -56,7 +56,6 @@ public class CouchbaseSinkFactory implements TableSinkFactory 
{
                 .optional(
                         CouchbaseSinkOptions.SCOPE,
                         CouchbaseSinkOptions.BUFFER_FLUSH_MAX_ROWS,
-                        CouchbaseSinkOptions.BUFFER_FLUSH_INTERVAL,
                         CouchbaseSinkOptions.RETRY_MAX,
                         CouchbaseSinkOptions.RETRY_INTERVAL,
                         CouchbaseSinkOptions.UPSERT_ENABLE,
@@ -94,8 +93,6 @@ public class CouchbaseSinkFactory implements TableSinkFactory 
{
 
         config.getOptional(CouchbaseSinkOptions.BUFFER_FLUSH_MAX_ROWS)
                 .ifPresent(builder::withFlushSize);
-        config.getOptional(CouchbaseSinkOptions.BUFFER_FLUSH_INTERVAL)
-                .ifPresent(builder::withBatchIntervalMs);
         
config.getOptional(CouchbaseSinkOptions.RETRY_MAX).ifPresent(builder::withRetryMax);
         config.getOptional(CouchbaseSinkOptions.RETRY_INTERVAL)
                 .ifPresent(builder::withRetryInterval);
diff --git 
a/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriter.java
 
b/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriter.java
index 18c9f7e7c1..5da720e4ad 100644
--- 
a/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriter.java
+++ 
b/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriter.java
@@ -46,11 +46,7 @@ import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 import java.util.UUID;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
-import java.util.concurrent.atomic.AtomicReference;
 
 /**
  * Writes {@link SeaTunnelRow} records to a Couchbase collection.
@@ -59,8 +55,6 @@ import java.util.concurrent.atomic.AtomicReference;
  *
  * <ul>
  *   <li>The buffer reaches {@code buffer-flush.max-rows}, or
- *   <li>A periodic background timer fires every {@code buffer-flush.interval} 
milliseconds (the
- *       real max-latency guarantee, enforced even when no new rows arrive), or
  *   <li>A checkpoint or shutdown is triggered.
  * </ul>
  *
@@ -80,11 +74,6 @@ import java.util.concurrent.atomic.AtomicReference;
  * pre-assigned ids — no new UUIDs are generated on a second pass, so silent 
duplicate documents and
  * spurious {@link com.couchbase.client.core.error.DocumentExistsException} 
collisions on
  * already-committed rows are both eliminated.
- *
- * <p><b>Async-error propagation:</b> when the background timer flush fails 
the first exception is
- * latched in {@link #asyncFlushError}. Subsequent calls to {@link #write}, 
{@link #prepareCommit},
- * and {@link #close} check the latch and rethrow the failure so the task 
cannot continue silently
- * after a broken flush cycle.
  */
 @Slf4j
 public class CouchbaseWriter implements SinkWriter<SeaTunnelRow, Void, Void> {
@@ -99,25 +88,6 @@ public class CouchbaseWriter implements 
SinkWriter<SeaTunnelRow, Void, Void> {
     private final int maxRetries;
     private final long retryIntervalMs;
 
-    /**
-     * Background scheduler that fires a periodic flush at every {@code 
batchIntervalMs}. This is
-     * the mechanism that enforces the {@code buffer-flush.interval} contract 
even when no new rows
-     * arrive (low-throughput / idle streaming jobs). {@code null} when the 
interval is disabled
-     * ({@code batchIntervalMs == -1}).
-     */
-    private final ScheduledExecutorService flushScheduler;
-
-    private final ScheduledFuture<?> flushFuture;
-
-    /**
-     * Latches the first exception thrown by the background timer flush. 
Checked and rethrown by
-     * {@link #write}, {@link #prepareCommit}, and {@link #close} so that the 
task lifecycle methods
-     * surface the failure instead of silently continuing after a broken flush.
-     */
-    private final AtomicReference<Throwable> asyncFlushError = new 
AtomicReference<>();
-
-    // TODO: Reserve context for future parallelism/metrics use.
-    @SuppressWarnings("unused")
     private final SinkWriter.Context context;
 
     public CouchbaseWriter(
@@ -162,39 +132,16 @@ public class CouchbaseWriter implements 
SinkWriter<SeaTunnelRow, Void, Void> {
         this.cluster = connectedCluster;
         this.collection = resolvedCollection;
 
-        // Start a periodic background flush so that buffer-flush.interval is 
a true max-latency
-        // guarantee, even when no rows arrive (idle or low-throughput 
streaming jobs).
-        final long batchIntervalMs = options.getBatchIntervalMs();
-        if (batchIntervalMs > 0) {
-            this.flushScheduler =
-                    Executors.newSingleThreadScheduledExecutor(
-                            r -> {
-                                Thread t = new Thread(r, 
"couchbase-flush-timer");
-                                t.setDaemon(true);
-                                return t;
-                            });
-            this.flushFuture =
-                    flushScheduler.scheduleAtFixedRate(
-                            () -> {
-                                try {
-                                    doFlush();
-                                } catch (Exception e) {
-                                    // Latch only the first failure; 
subsequent timer ticks will
-                                    // observe the latch and skip attempting 
further flushes.
-                                    if (asyncFlushError.compareAndSet(null, 
e)) {
-                                        log.error(
-                                                "Periodic Couchbase flush 
failed — task will abort"
-                                                        + " on the next write 
or checkpoint",
-                                                e);
-                                    }
-                                }
-                            },
-                            batchIntervalMs,
-                            batchIntervalMs,
-                            TimeUnit.MILLISECONDS);
-        } else {
-            this.flushScheduler = null;
-            this.flushFuture = null;
+        // Opt in to engine-level timer flush. On Zeta the engine invokes this 
action on the normal
+        // Sink input-processing path when a FlushSignal arrives, so there is 
no connector-owned
+        // scheduler thread and no concurrency with write/checkpoint/close. On 
Spark and Flink the
+        // Context does not implement registerFlushAction (it keeps the 
interface's no-op default),
+        // so there is no periodic timer flush there; the buffer is flushed on
+        // buffer-flush.max-rows,
+        // on checkpoint, and on close(). The null-check is defensive for 
non-standard/test call
+        // sites that may not supply a context.
+        if (context != null) {
+            context.registerFlushAction(this::doFlush);
         }
     }
 
@@ -204,22 +151,17 @@ public class CouchbaseWriter implements 
SinkWriter<SeaTunnelRow, Void, Void> {
      * because CDC delete support is out of scope for this initial 
implementation.
      *
      * <p>The row's document id is assigned here, at buffer-add time, so that 
every subsequent flush
-     * attempt (including a {@link #close}-triggered re-flush of a buffer that 
a prior interrupted
-     * periodic flush left un-cleared) uses the same stable id. This prevents 
silent duplicate
-     * documents when no primary key is configured (UUID path) and prevents 
spurious {@link
+     * attempt (including a {@link #close}-triggered re-flush of a buffer that 
a prior flush left
+     * un-cleared) uses the same stable id. This prevents silent duplicate 
documents when no primary
+     * key is configured (UUID path) and prevents spurious {@link
      * com.couchbase.client.core.error.DocumentExistsException} collisions on 
already-committed rows
      * when a primary key is configured and insert mode is active.
      *
-     * <p>Checks {@link #asyncFlushError} before buffering and rethrows any 
latched background
-     * failure so the task cannot continue after a broken periodic flush.
-     *
-     * @param row the incoming row
-     * @throws CouchbaseConnectorException if the row kind is {@code DELETE} 
or a prior async flush
-     *     failed
+     * @param row the incoming row throws the CouchBaseConnectorException if 
the row kind is {@code
+     *     DELETE}
      */
     @Override
     public void write(SeaTunnelRow row) {
-        checkAsyncFlushError();
         if (row.getRowKind() == RowKind.UPDATE_BEFORE) {
             return;
         }
@@ -243,7 +185,6 @@ public class CouchbaseWriter implements 
SinkWriter<SeaTunnelRow, Void, Void> {
 
     @Override
     public Optional<Void> prepareCommit() {
-        checkAsyncFlushError();
         doFlush();
         return Optional.empty();
     }
@@ -253,36 +194,8 @@ public class CouchbaseWriter implements 
SinkWriter<SeaTunnelRow, Void, Void> {
 
     @Override
     public void close() {
-        // Cancel the periodic flush timer first so it cannot race with the 
final flush below.
-        if (flushFuture != null) {
-            flushFuture.cancel(false);
-        }
-        if (flushScheduler != null) {
-            // shutdownNow() interrupts any in-flight periodic doFlush() that 
is sleeping in its
-            // retry backoff. We then awaitTermination() to ensure the 
interrupted timer thread
-            // has fully unwound — in particular that its catch/finally has 
set asyncFlushError
-            // if the flush failed — before we read the latch or attempt our 
own doFlush().
-            // Without awaitTermination() there is a window where 
checkAsyncFlushError() sees null
-            // even though the interrupted task's error handler has not yet 
executed.
-            flushScheduler.shutdownNow();
-            try {
-                if (!flushScheduler.awaitTermination(30, TimeUnit.SECONDS)) {
-                    log.warn(
-                            "Couchbase flush scheduler did not terminate 
within 30 s; "
-                                    + "proceeding with close anyway.");
-                }
-            } catch (InterruptedException ie) {
-                Thread.currentThread().interrupt();
-                log.warn("Interrupted while waiting for Couchbase flush 
scheduler to terminate.");
-            }
-        }
-        // checkAsyncFlushError() and doFlush() are both inside the try so 
that the finally
-        // block always disconnects the cluster even when a latched background 
failure exists.
-        // If doFlush() throws and cluster.disconnect() also throws, attach 
the disconnect failure
-        // as a suppressed exception so the data-relevant flush error is not 
masked.
         Throwable primaryThrowable = null;
         try {
-            checkAsyncFlushError();
             doFlush();
         } catch (Throwable t) {
             primaryThrowable = t;
@@ -317,26 +230,6 @@ public class CouchbaseWriter implements 
SinkWriter<SeaTunnelRow, Void, Void> {
     // 
---------------------------------------------------------------------------
     // Internal helpers
     // 
---------------------------------------------------------------------------
-
-    /**
-     * Throws a {@link CouchbaseConnectorException} wrapping the latched async 
error if one has been
-     * recorded by the background flush timer.
-     *
-     * <p>The latch is intentionally <em>sticky</em>: it is read with {@link 
AtomicReference#get()}
-     * rather than {@code getAndSet(null)} so that a fatal background failure 
remains visible to
-     * every subsequent caller ({@link #write}, {@link #prepareCommit}, {@link 
#close}) and cannot
-     * be silently consumed and lost.
-     */
-    private void checkAsyncFlushError() {
-        Throwable error = asyncFlushError.get();
-        if (error != null) {
-            throw new CouchbaseConnectorException(
-                    CouchbaseConnectorErrorCode.WRITE_RECORDS_FAILED,
-                    "A background Couchbase flush failed; task cannot continue 
safely",
-                    error);
-        }
-    }
-
     /** Converts a {@link SeaTunnelRow} to a {@link JsonObject} using the 
schema field names. */
     private JsonObject toJsonObject(SeaTunnelRow row) {
         JsonObject doc = JsonObject.create();
diff --git 
a/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterOptions.java
 
b/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterOptions.java
index 866c5fa7d3..324176d276 100644
--- 
a/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterOptions.java
+++ 
b/seatunnel-connectors-v2/connector-couchbase/src/main/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterOptions.java
@@ -38,7 +38,6 @@ public class CouchbaseWriterOptions implements Serializable {
     private final String scope;
     private final String collection;
     private final int flushSize;
-    private final long batchIntervalMs;
     private final boolean upsertEnable;
     private final String[] primaryKey;
     private final int retryMax;
@@ -52,7 +51,6 @@ public class CouchbaseWriterOptions implements Serializable {
         this.scope = builder.scope;
         this.collection = builder.collection;
         this.flushSize = builder.flushSize;
-        this.batchIntervalMs = builder.batchIntervalMs;
         this.upsertEnable = builder.upsertEnable;
         this.primaryKey = builder.primaryKey;
         this.retryMax = builder.retryMax;
@@ -72,7 +70,6 @@ public class CouchbaseWriterOptions implements Serializable {
         private String scope = "_default";
         private String collection;
         private int flushSize = 1000;
-        private long batchIntervalMs = 30000L;
         private boolean upsertEnable = false;
         private String[] primaryKey = new String[0];
         private int retryMax = 3;
@@ -113,11 +110,6 @@ public class CouchbaseWriterOptions implements 
Serializable {
             return this;
         }
 
-        public Builder withBatchIntervalMs(long batchIntervalMs) {
-            this.batchIntervalMs = batchIntervalMs;
-            return this;
-        }
-
         public Builder withUpsertEnable(boolean upsertEnable) {
             this.upsertEnable = upsertEnable;
             return this;
diff --git 
a/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterCloseTest.java
 
b/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterCloseTest.java
deleted file mode 100644
index aac404e279..0000000000
--- 
a/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterCloseTest.java
+++ /dev/null
@@ -1,256 +0,0 @@
-/*
- * 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 org.apache.seatunnel.connectors.seatunnel.couchbase.sink;
-
-import org.apache.seatunnel.api.sink.SinkWriter;
-import org.apache.seatunnel.api.table.catalog.CatalogTable;
-import org.apache.seatunnel.api.table.catalog.TableIdentifier;
-import org.apache.seatunnel.api.table.catalog.TableSchema;
-
-import org.junit.jupiter.api.Test;
-import org.mockito.MockedStatic;
-import org.mockito.Mockito;
-
-import com.couchbase.client.java.Bucket;
-import com.couchbase.client.java.Cluster;
-import com.couchbase.client.java.Collection;
-import com.couchbase.client.java.Scope;
-
-import java.lang.reflect.Field;
-import java.util.concurrent.atomic.AtomicReference;
-
-import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
-import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.atLeastOnce;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-/**
- * Unit tests covering the two fixes applied to {@link 
CouchbaseWriter#close()} and {@link
- * CouchbaseWriter}'s async-error latch:
- *
- * <ol>
- *   <li><b>Blocker 1a</b> — {@code cluster.disconnect()} must be called even 
when a latched
- *       background flush error causes {@code checkAsyncFlushError()} to throw 
inside {@code
- *       close()}.
- *   <li><b>Blocker 1b</b> — The {@code asyncFlushError} latch must be 
<em>sticky</em>: reading it
- *       via {@link AtomicReference#get()} must not clear it, so a second call 
to {@code
- *       checkAsyncFlushError()} still sees the same error.
- * </ol>
- *
- * <p>Uses {@code mockito-inline} to intercept the static {@link 
Cluster#connect} factory.
- */
-class CouchbaseWriterCloseTest {
-
-    // 
-------------------------------------------------------------------------
-    // Shared helpers (mirrors CouchbaseWriterConstructorLeakTest)
-    // 
-------------------------------------------------------------------------
-
-    private static CouchbaseWriterOptions minimalOptions() {
-        return CouchbaseWriterOptions.builder()
-                .withConnectionString("couchbase://localhost")
-                .withUsername("user")
-                .withPassword("pass")
-                .withBucket("test-bucket")
-                .withScope("_default")
-                .withCollection("test-collection")
-                .withBatchIntervalMs(-1) // disable background scheduler
-                .build();
-    }
-
-    private static CatalogTable minimalCatalogTable() {
-        TableSchema schema = TableSchema.builder().build();
-        return CatalogTable.of(
-                TableIdentifier.of("catalog", "database", "table"),
-                schema,
-                java.util.Collections.emptyMap(),
-                java.util.Collections.emptyList(),
-                "");
-    }
-
-    private static SinkWriter.Context noopContext() {
-        return mock(SinkWriter.Context.class);
-    }
-
-    private static Cluster buildMockClusterChain() {
-        Collection collection = mock(Collection.class);
-        Scope scope = mock(Scope.class);
-        when(scope.collection(anyString())).thenReturn(collection);
-        Bucket bucket = mock(Bucket.class);
-        when(bucket.scope(anyString())).thenReturn(scope);
-        Cluster cluster = mock(Cluster.class);
-        when(cluster.bucket(anyString())).thenReturn(bucket);
-        return cluster;
-    }
-
-    /**
-     * Reflectively injects a {@link Throwable} into the {@code 
asyncFlushError} latch of the given
-     * writer, simulating a failure that was recorded by the background flush 
timer.
-     */
-    private static void injectAsyncFlushError(CouchbaseWriter writer, 
Throwable error)
-            throws Exception {
-        Field field = 
CouchbaseWriter.class.getDeclaredField("asyncFlushError");
-        field.setAccessible(true);
-        @SuppressWarnings("unchecked")
-        AtomicReference<Throwable> ref = (AtomicReference<Throwable>) 
field.get(writer);
-        ref.set(error);
-    }
-
-    // 
-------------------------------------------------------------------------
-    // Blocker 1a — disconnect() must execute even when the latch throws
-    // 
-------------------------------------------------------------------------
-
-    /**
-     * Verifies that {@code cluster.disconnect()} is called by the {@code 
finally} block inside
-     * {@code close()} even when a latched async flush error causes {@code 
checkAsyncFlushError()}
-     * to throw.
-     *
-     * <p>Before the fix, {@code checkAsyncFlushError()} was invoked 
<em>before</em> entering the
-     * {@code try/finally}, so a latched error caused the method to return 
early and {@code
-     * disconnect()} was never reached.
-     */
-    @Test
-    void close_withLatchedAsyncError_stillDisconnectsCluster() throws 
Exception {
-        Cluster cluster = buildMockClusterChain();
-
-        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
-            staticCluster
-                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
-                    .thenReturn(cluster);
-
-            CouchbaseWriter writer =
-                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), noopContext());
-
-            // Simulate a background flush failure recorded by the timer 
thread.
-            injectAsyncFlushError(writer, new RuntimeException("simulated 
timer flush failure"));
-
-            // close() must throw because of the latched error — but 
disconnect() must still run.
-            assertThrows(
-                    Exception.class, writer::close, "close() must rethrow the 
latched async error");
-
-            // The critical assertion: disconnect() must have been called 
exactly once
-            // via the finally block, regardless of the async-error exception.
-            verify(cluster, atLeastOnce()).disconnect();
-        }
-    }
-
-    /**
-     * Complementary happy-path: when no error is latched, {@code close()} 
must complete without
-     * throwing and {@code disconnect()} must still be called.
-     */
-    @Test
-    void close_withNoLatchedError_disconnectsCluster() {
-        Cluster cluster = buildMockClusterChain();
-
-        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
-            staticCluster
-                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
-                    .thenReturn(cluster);
-
-            CouchbaseWriter writer =
-                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), noopContext());
-
-            assertDoesNotThrow(writer::close, "close() must not throw when 
there is no error");
-
-            verify(cluster, atLeastOnce()).disconnect();
-        }
-    }
-
-    // 
-------------------------------------------------------------------------
-    // Blocker 1b — asyncFlushError latch must be sticky (not consumed on 
first read)
-    // 
-------------------------------------------------------------------------
-
-    /**
-     * Verifies that the {@code asyncFlushError} latch is <em>sticky</em>: 
after a background error
-     * is recorded, two successive calls to {@code checkAsyncFlushError()} 
(via {@link
-     * CouchbaseWriter#write} and then {@link CouchbaseWriter#prepareCommit}) 
must both throw.
-     *
-     * <p>Before the fix, the latch used {@code getAndSet(null)}, so only the 
first caller saw the
-     * error; the second call would silently return, allowing the task to 
proceed after a fatal
-     * flush failure.
-     */
-    @Test
-    void asyncFlushError_latchIsSticky_secondCallerAlsoSeesError() throws 
Exception {
-        Cluster cluster = buildMockClusterChain();
-
-        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
-            staticCluster
-                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
-                    .thenReturn(cluster);
-
-            CouchbaseWriter writer =
-                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), noopContext());
-
-            injectAsyncFlushError(writer, new RuntimeException("background 
error"));
-
-            // First caller (write) must throw.
-            assertThrows(
-                    Exception.class,
-                    () -> writer.write(new 
org.apache.seatunnel.api.table.type.SeaTunnelRow(0)),
-                    "write() must throw when the latch is set");
-
-            // Second caller (prepareCommit) must ALSO throw — the latch must 
not have been cleared.
-            assertThrows(
-                    Exception.class,
-                    writer::prepareCommit,
-                    "prepareCommit() must still throw: the async-error latch 
must be sticky");
-
-            // Cleanup: close() will also throw (latch still set) but 
disconnect() must run.
-            assertThrows(Exception.class, writer::close);
-            verify(cluster, atLeastOnce()).disconnect();
-        }
-    }
-
-    /**
-     * Verifies that a single injected error remains visible through all three 
lifecycle callers in
-     * sequence: {@link CouchbaseWriter#write}, {@link 
CouchbaseWriter#prepareCommit}, and {@link
-     * CouchbaseWriter#close}.
-     */
-    @Test
-    void asyncFlushError_latchIsSticky_allThreeLifecycleCallersThrow() throws 
Exception {
-        Cluster cluster = buildMockClusterChain();
-
-        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
-            staticCluster
-                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
-                    .thenReturn(cluster);
-
-            CouchbaseWriter writer =
-                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), noopContext());
-
-            injectAsyncFlushError(writer, new RuntimeException("persistent 
background error"));
-
-            assertThrows(
-                    Exception.class,
-                    () -> writer.write(new 
org.apache.seatunnel.api.table.type.SeaTunnelRow(0)),
-                    "write() must throw");
-            assertThrows(
-                    Exception.class,
-                    writer::prepareCommit,
-                    "prepareCommit() must throw (latch not cleared by write)");
-            assertThrows(
-                    Exception.class,
-                    writer::close,
-                    "close() must throw (latch not cleared by prepareCommit)");
-
-            verify(cluster, atLeastOnce()).disconnect();
-        }
-    }
-}
diff --git 
a/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterConstructorLeakTest.java
 
b/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterConstructorLeakTest.java
index a943bda942..7d5ce27ef5 100644
--- 
a/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterConstructorLeakTest.java
+++ 
b/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterConstructorLeakTest.java
@@ -68,7 +68,6 @@ class CouchbaseWriterConstructorLeakTest {
                 .withBucket("test-bucket")
                 .withScope("_default")
                 .withCollection("test-collection")
-                .withBatchIntervalMs(-1) // disable background scheduler
                 .build();
     }
 
diff --git 
a/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterTest.java
 
b/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterTest.java
new file mode 100644
index 0000000000..4ee11ae9a4
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-couchbase/src/test/java/org/apache/seatunnel/connectors/seatunnel/couchbase/sink/CouchbaseWriterTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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 org.apache.seatunnel.connectors.seatunnel.couchbase.sink;
+
+import org.apache.seatunnel.api.sink.SinkWriter;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.type.RowKind;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.common.utils.function.RunnableWithException;
+import 
org.apache.seatunnel.connectors.seatunnel.couchbase.exception.CouchbaseConnectorException;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+
+import com.couchbase.client.java.Bucket;
+import com.couchbase.client.java.Cluster;
+import com.couchbase.client.java.Collection;
+import com.couchbase.client.java.Scope;
+import com.couchbase.client.java.kv.MutationResult;
+
+import java.util.Collections;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class CouchbaseWriterTest {
+
+    // ---- shared helpers -------------------------------------------------
+
+    private static CouchbaseWriterOptions minimalOptions() {
+        return CouchbaseWriterOptions.builder()
+                .withConnectionString("couchbase://localhost")
+                .withUsername("user")
+                .withPassword("pass")
+                .withBucket("test-bucket")
+                .withScope("_default")
+                .withCollection("test-collection")
+                .withUpsertEnable(true)
+                .withFlushSize(-1) // disable size-based flush unless a test 
needs it
+                .build();
+    }
+
+    private static CatalogTable minimalCatalogTable() {
+        TableSchema schema = TableSchema.builder().build();
+        return CatalogTable.of(
+                TableIdentifier.of("catalog", "database", "table"),
+                schema,
+                Collections.emptyMap(),
+                Collections.emptyList(),
+                "");
+    }
+
+    private static Collection buildMockCollectionChain(Cluster cluster) {
+        Collection collection = mock(Collection.class);
+        Scope scope = mock(Scope.class);
+        when(scope.collection(anyString())).thenReturn(collection);
+        Bucket bucket = mock(Bucket.class);
+        when(bucket.scope(anyString())).thenReturn(scope);
+        when(cluster.bucket(anyString())).thenReturn(bucket);
+        return collection;
+    }
+
+    private static SeaTunnelRow insertRow() {
+        SeaTunnelRow row = new SeaTunnelRow(new Object[] {"v1"});
+        row.setRowKind(RowKind.INSERT);
+        return row;
+    }
+
+    // ---- 1. registration + flush on signal -------------------------------
+
+    @Test
+    void shouldRegisterFlushActionAndFlushBufferedRecordsOnSignal() throws 
Exception {
+        Cluster cluster = mock(Cluster.class);
+        Collection collection = buildMockCollectionChain(cluster);
+        when(collection.upsert(anyString(), 
any())).thenReturn(mock(MutationResult.class));
+
+        SinkWriter.Context context = mock(SinkWriter.Context.class);
+        ArgumentCaptor<RunnableWithException> actionCaptor =
+                ArgumentCaptor.forClass(RunnableWithException.class);
+
+        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
+            staticCluster
+                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
+                    .thenReturn(cluster);
+
+            CouchbaseWriter writer =
+                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), context);
+            writer.write(insertRow());
+
+            verify(context, 
times(1)).registerFlushAction(actionCaptor.capture());
+            // Buffered only: nothing written until the engine delivers the 
flush signal.
+            verify(collection, never()).upsert(anyString(), any());
+
+            actionCaptor.getValue().run();
+
+            verify(collection, times(1)).upsert(anyString(), any());
+        }
+    }
+
+    // ---- 2. flush failure propagation -------------------------------------
+
+    @Test
+    void shouldPropagateFlushFailure() throws Exception {
+        Cluster cluster = mock(Cluster.class);
+        Collection collection = buildMockCollectionChain(cluster);
+        doThrow(new 
RuntimeException("boom")).when(collection).upsert(anyString(), any());
+
+        SinkWriter.Context context = mock(SinkWriter.Context.class);
+        ArgumentCaptor<RunnableWithException> actionCaptor =
+                ArgumentCaptor.forClass(RunnableWithException.class);
+
+        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
+            staticCluster
+                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
+                    .thenReturn(cluster);
+
+            CouchbaseWriter writer =
+                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), context);
+            writer.write(insertRow());
+
+            verify(context, 
times(1)).registerFlushAction(actionCaptor.capture());
+            Assertions.assertThrows(
+                    CouchbaseConnectorException.class, () -> 
actionCaptor.getValue().run());
+        }
+    }
+
+    // ---- 3. fallback flush on close (Spark/Flink; no registerFlushAction) --
+
+    @Test
+    void shouldFlushOnCloseWhenEngineNeverInvokesFlushAction() throws 
Exception {
+        Cluster cluster = mock(Cluster.class);
+        Collection collection = buildMockCollectionChain(cluster);
+        when(collection.upsert(anyString(), 
any())).thenReturn(mock(MutationResult.class));
+
+        SinkWriter.Context context = mock(SinkWriter.Context.class);
+
+        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
+            staticCluster
+                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
+                    .thenReturn(cluster);
+
+            CouchbaseWriter writer =
+                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), context);
+            writer.write(insertRow());
+
+            // Simulate Spark/Flink: registered action is never invoked.
+            verify(collection, never()).upsert(anyString(), any());
+
+            writer.close();
+
+            verify(collection, times(1)).upsert(anyString(), any());
+            verify(cluster, times(1)).disconnect();
+        }
+    }
+
+    // ---- 4. checkpoint flush (prepareCommit) -------------------------------
+
+    @Test
+    void shouldFlushOnPrepareCommitWhenEngineNeverInvokesFlushAction() throws 
Exception {
+        Cluster cluster = mock(Cluster.class);
+        Collection collection = buildMockCollectionChain(cluster);
+        when(collection.upsert(anyString(), 
any())).thenReturn(mock(MutationResult.class));
+
+        SinkWriter.Context context = mock(SinkWriter.Context.class);
+
+        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
+            staticCluster
+                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
+                    .thenReturn(cluster);
+
+            CouchbaseWriter writer =
+                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), context);
+            writer.write(insertRow());
+
+            verify(collection, never()).upsert(anyString(), any());
+
+            Assertions.assertEquals(java.util.Optional.empty(), 
writer.prepareCommit());
+
+            verify(collection, times(1)).upsert(anyString(), any());
+
+            // Buffer must be cleared: a second checkpoint with no new rows 
sends nothing more.
+            Assertions.assertEquals(java.util.Optional.empty(), 
writer.prepareCommit());
+            verify(collection, times(1)).upsert(anyString(), any());
+        }
+    }
+
+    // ---- 5. suppressed disconnect exception on close when flush fails ------
+
+    @Test
+    void closeShouldKeepFlushExceptionWhenDisconnectAlsoThrows() throws 
Exception {
+        Cluster cluster = mock(Cluster.class);
+        Collection collection = buildMockCollectionChain(cluster);
+        doThrow(new 
RuntimeException("boom")).when(collection).upsert(anyString(), any());
+        doThrow(new RuntimeException("disconnect 
failed")).when(cluster).disconnect();
+
+        SinkWriter.Context context = mock(SinkWriter.Context.class);
+
+        try (MockedStatic<Cluster> staticCluster = 
Mockito.mockStatic(Cluster.class)) {
+            staticCluster
+                    .when(() -> Cluster.connect(anyString(), anyString(), 
anyString()))
+                    .thenReturn(cluster);
+
+            CouchbaseWriter writer =
+                    new CouchbaseWriter(minimalOptions(), 
minimalCatalogTable(), context);
+            writer.write(insertRow());
+
+            CouchbaseConnectorException thrown =
+                    Assertions.assertThrows(CouchbaseConnectorException.class, 
writer::close);
+
+            boolean disconnectSuppressed = false;
+            for (Throwable suppressed : thrown.getSuppressed()) {
+                if (suppressed.getMessage() != null
+                        && suppressed.getMessage().contains("disconnect 
failed")) {
+                    disconnectSuppressed = true;
+                }
+            }
+            Assertions.assertTrue(
+                    disconnectSuppressed,
+                    "The disconnect failure should be suppressed on the 
primary flush exception");
+
+            verify(cluster, times(1)).disconnect();
+        }
+    }
+}
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/java/org/apache/seatunnel/e2e/connector/couchbase/CouchbaseIT.java
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/java/org/apache/seatunnel/e2e/connector/couchbase/CouchbaseIT.java
index b9be68fc96..20c7aeb8cd 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/java/org/apache/seatunnel/e2e/connector/couchbase/CouchbaseIT.java
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/java/org/apache/seatunnel/e2e/connector/couchbase/CouchbaseIT.java
@@ -19,8 +19,10 @@ package org.apache.seatunnel.e2e.connector.couchbase;
 
 import org.apache.seatunnel.e2e.common.TestResource;
 import org.apache.seatunnel.e2e.common.TestSuiteBase;
+import org.apache.seatunnel.e2e.common.container.EngineType;
 import org.apache.seatunnel.e2e.common.container.TestContainer;
 import org.apache.seatunnel.e2e.common.container.seatunnel.SeaTunnelContainer;
+import org.apache.seatunnel.e2e.common.junit.DisabledOnContainer;
 
 import org.awaitility.Awaitility;
 import org.junit.jupiter.api.AfterAll;
@@ -63,6 +65,7 @@ public class CouchbaseIT extends TestSuiteBase implements 
TestResource {
     private static final String COUCHBASE_BUCKET = "test_bucket";
     private static final String COUCHBASE_SCOPE = "_default";
     private static final String COUCHBASE_COLLECTION = "test_collection";
+    private static final String COUCHBASE_COLLECTION_TIMER_FLUSH = 
"test_collection_timer_flush";
 
     /** Matches row.num in fake_source_to_couchbase.conf. */
     private static final int EXPECTED_ROW_COUNT = 100;
@@ -125,6 +128,7 @@ public class CouchbaseIT extends TestSuiteBase implements 
TestResource {
         // reject requests for a short window after Cluster.connect() returns. 
Wrapping the DDL
         // itself in a retry loop is the safest approach — it eliminates the 
startup timing race
         // without relying on a fixed sleep.
+
         String createCollectionDdl =
                 "CREATE COLLECTION `"
                         + COUCHBASE_BUCKET
@@ -139,7 +143,6 @@ public class CouchbaseIT extends TestSuiteBase implements 
TestResource {
                 .atMost(60, TimeUnit.SECONDS)
                 .untilAsserted(() -> cluster.query(createCollectionDdl));
 
-        // Similarly retry CREATE PRIMARY INDEX until the collection is 
visible to the query path.
         String createIndexDdl =
                 "CREATE PRIMARY INDEX ON `"
                         + COUCHBASE_BUCKET
@@ -154,9 +157,6 @@ public class CouchbaseIT extends TestSuiteBase implements 
TestResource {
                 .atMost(60, TimeUnit.SECONDS)
                 .untilAsserted(() -> cluster.query(createIndexDdl));
 
-        // Wait until the primary index transitions to 'online' before 
returning.
-        // The index build is asynchronous; without this guard the first N1QL 
query in the
-        // test body may hit the collection before the index is ready and 
return no results.
         String indexStatusQuery =
                 String.format(
                         "SELECT state FROM system:indexes WHERE keyspace_id = 
'%s'"
@@ -178,6 +178,55 @@ public class CouchbaseIT extends TestSuiteBase implements 
TestResource {
                                     "Primary index not yet online");
                         });
 
+        // Similarly retry CREATE PRIMARY INDEX until the collection is 
visible to the query path.
+        String createCollectionDdlTimerFlush =
+                "CREATE COLLECTION `"
+                        + COUCHBASE_BUCKET
+                        + "`.`"
+                        + COUCHBASE_SCOPE
+                        + "`.`"
+                        + COUCHBASE_COLLECTION_TIMER_FLUSH
+                        + "`";
+        Awaitility.given()
+                .ignoreExceptions()
+                .pollInterval(2, TimeUnit.SECONDS)
+                .atMost(60, TimeUnit.SECONDS)
+                .untilAsserted(() -> 
cluster.query(createCollectionDdlTimerFlush));
+
+        String createIndexDdlTimerFlush =
+                "CREATE PRIMARY INDEX ON `"
+                        + COUCHBASE_BUCKET
+                        + "`.`"
+                        + COUCHBASE_SCOPE
+                        + "`.`"
+                        + COUCHBASE_COLLECTION_TIMER_FLUSH
+                        + "`";
+        Awaitility.given()
+                .ignoreExceptions()
+                .pollInterval(2, TimeUnit.SECONDS)
+                .atMost(60, TimeUnit.SECONDS)
+                .untilAsserted(() -> cluster.query(createIndexDdlTimerFlush));
+
+        String indexStatusQueryTimerFlush =
+                String.format(
+                        "SELECT state FROM system:indexes WHERE keyspace_id = 
'%s'"
+                                + " AND `using` = 'gsi' AND is_primary = true"
+                                + " AND `bucket_id` = '%s'",
+                        COUCHBASE_COLLECTION_TIMER_FLUSH, COUCHBASE_BUCKET);
+        Awaitility.given()
+                .ignoreExceptions()
+                .pollInterval(1, TimeUnit.SECONDS)
+                .atMost(60, TimeUnit.SECONDS)
+                .untilAsserted(
+                        () -> {
+                            QueryResult r = 
cluster.query(indexStatusQueryTimerFlush);
+                            List<JsonObject> rows = r.rowsAs(JsonObject.class);
+                            Assertions.assertFalse(rows.isEmpty(), "Primary 
index not created yet");
+                            Assertions.assertEquals(
+                                    "online",
+                                    rows.get(0).getString("state"),
+                                    "Primary index not yet online");
+                        });
         log.info("Couchbase cluster ready at {}", 
couchbaseContainer.getConnectionString());
     }
 
@@ -289,4 +338,57 @@ public class CouchbaseIT extends TestSuiteBase implements 
TestResource {
                                     doc.toMap());
                         });
     }
+
+    @TestTemplate
+    @DisabledOnContainer(
+            value = {},
+            type = {EngineType.SPARK, EngineType.FLINK},
+            disabledReason =
+                    "engine-level timer flush (sink.flush.interval) is only 
supported on Zeta engine")
+    public void testCouchbaseSinkTimerFlush(TestContainer container) throws 
Exception {
+        cluster.query(
+                String.format(
+                        "DELETE FROM `%s`.`%s`.`%s`",
+                        COUCHBASE_BUCKET, COUCHBASE_SCOPE, 
COUCHBASE_COLLECTION_TIMER_FLUSH));
+
+        String jobId = String.valueOf(System.currentTimeMillis());
+        java.util.concurrent.CompletableFuture<Container.ExecResult> jobFuture 
=
+                java.util.concurrent.CompletableFuture.supplyAsync(
+                        () -> {
+                            try {
+                                return container.executeJob(
+                                        
"/fake_source_to_couchbase_timer_flush.conf", jobId);
+                            } catch (Exception e) {
+                                throw new RuntimeException(e);
+                            }
+                        });
+
+        String countQuery =
+                String.format(
+                        "SELECT COUNT(*) AS cnt FROM `%s`.`%s`.`%s`",
+                        COUCHBASE_BUCKET, COUCHBASE_SCOPE, 
COUCHBASE_COLLECTION_TIMER_FLUSH);
+
+        try {
+            Awaitility.given()
+                    .ignoreExceptions()
+                    .pollInterval(2, TimeUnit.SECONDS)
+                    .atMost(60, TimeUnit.SECONDS)
+                    .untilAsserted(
+                            () -> {
+                                Assertions.assertFalse(
+                                        jobFuture.isDone(),
+                                        "The streaming job must still be 
running when timer flush publishes the buffered rows");
+                                QueryResult result = cluster.query(countQuery);
+                                List<JsonObject> rows = 
result.rowsAs(JsonObject.class);
+                                Assertions.assertFalse(
+                                        rows.isEmpty(), "COUNT query returned 
no rows");
+                                Assertions.assertEquals(10, 
rows.get(0).getInt("cnt"));
+                            });
+        } finally {
+            if (!jobFuture.isDone()) {
+                Container.ExecResult cancelResult = container.cancelJob(jobId);
+                Assertions.assertEquals(0, cancelResult.getExitCode(), 
cancelResult.getStderr());
+            }
+        }
+    }
 }
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase.conf
index e1986d88dd..9ec98fb6d3 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase.conf
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase.conf
@@ -55,6 +55,5 @@ sink {
     primary-key       = ["id"]
     upsert-enable     = true
     buffer-flush.max-rows    = 50
-    buffer-flush.interval    = 5000
   }
 }
diff --git 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase.conf
 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase_timer_flush.conf
similarity index 79%
copy from 
seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase.conf
copy to 
seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase_timer_flush.conf
index e1986d88dd..6ee7ac6fdf 100644
--- 
a/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase.conf
+++ 
b/seatunnel-e2e/seatunnel-connector-v2-e2e/connector-couchbase-e2e/src/test/resources/fake_source_to_couchbase_timer_flush.conf
@@ -17,16 +17,15 @@
 
 env {
   parallelism = 1
-  job.mode = "BATCH"
+  job.mode = "STREAMING"
+  checkpoint.interval = 300000
+  sink.flush.interval = 3000
 }
 
 source {
   FakeSource {
     plugin_output = "fake"
-    row.num = 100
-    # auto.increment.enabled makes the primary-key field sequential (1..100) so
-    # upsert-enable=true produces exactly 100 distinct document keys and the
-    # COUNT(*) assertion in CouchbaseIT is fully deterministic.
+    row.num = 10
     auto.increment.enabled = true
     auto.increment.start   = 1
     schema = {
@@ -51,10 +50,9 @@ sink {
     password          = "password"
     bucket            = "test_bucket"
     scope             = "_default"
-    collection        = "test_collection"
+    collection        = "test_collection_timer_flush"
     primary-key       = ["id"]
     upsert-enable     = true
-    buffer-flush.max-rows    = 50
-    buffer-flush.interval    = 5000
+    buffer-flush.max-rows = 100000
   }
-}
+}
\ No newline at end of file

Reply via email to