laskoviymishka commented on code in PR #17376:
URL: https://github.com/apache/iceberg/pull/17376#discussion_r3739164389


##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java:
##########
@@ -197,8 +210,15 @@ public void save(Collection<SinkRecord> sinkRecords) {
 
   private void processControlEvents() {
     if (coordinatorThread != null && coordinatorThread.isTerminated()) {
-      throw new NotRunningException(
-          String.format("Coordinator unexpectedly terminated on committer %s", 
taskId));
+      if (isProducerFenced(coordinatorThread.exception())) {
+        // Lost the coordinator race (fenced by a newer coordinator). Clear it 
so
+        // commit thread pool are released.
+        LOG.warn("Committer {} coordinator was fenced by a newer coordinator; 
clearing it", taskId);
+        stopCoordinator();

Review Comment:
   After the fence is detected we clear the coordinator and return normally — 
but `startCoordinator()` is only ever called from `open()`, which only fires on 
a Connect rebalance.
   
   On a stable assignment (no rebalance coming) that leaves the task as a pure 
worker with no coordinator: it keeps writing parquet and buffering, but no 
`StartCommit` is ever broadcast, so no Iceberg commit happens and nothing 
surfaces to Connect's task monitoring. The connector reads as "running" while 
it has silently stopped committing, and if control-topic or source retention 
expires while the buffer is held that's a data-loss window.
   
   I get why you moved off `throw NotRunningException` (Connect won't 
auto-restart a FAILED task), but the replacement is an unbounded, invisible 
stall. I'd want either an active re-election trigger (e.g. 
`context.requestCommit()` / a health hook) or, at minimum, an ERROR-level "no 
coordinator active, commits suspended" signal plus an operator-guide note 
quantifying the stall window. How were you picturing recovery here on a cluster 
that isn't rebalancing?



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Channel.java:
##########
@@ -53,20 +54,29 @@ abstract class Channel {
   private final Admin admin;
   private final Map<Integer, Long> controlTopicOffsets = Maps.newHashMap();
   private final String producerId;
+  private final String channelId;
 
   Channel(
       String name,
       String consumerGroupId,
       IcebergSinkConfig config,
       KafkaClientFactory clientFactory,
       SinkTaskContext context) {
+    this.channelId = config.connectorName() + "-" + config.taskId() + "-" + 
name;
     this.controlTopic = config.controlTopic();
     this.connectGroupId = config.connectGroupId();
     this.context = context;
 
-    String transactionalId = config.transactionalPrefix() + name + 
config.transactionalSuffix();
+    String transactionalId =
+        "worker".equalsIgnoreCase(name)
+            ? config.transactionalPrefix() + name + 
config.transactionalSuffix()
+            : connectGroupId + "-" + config.connectorName() + "-coord";

Review Comment:
   The new coordinator transactional.id is a different string from the old 
`transactionalPrefix + "coordinator" + transactionalSuffix`, and Kafka fencing 
only kicks in when a new producer reuses the *same* id it's replacing.
   
   So during a rolling upgrade an old coordinator (old id) and a new one (new 
id) don't fence each other — both stay live for up to 
`COORDINATOR_STOP_TIMEOUT_MS`, both broadcast `StartCommit` with different 
UUIDs, and for a multi-table connector each can win a commit on a different 
table in the same window. `SnapshotAncestryValidator` guards a single table 
against the same base snapshot, but it won't catch two coordinators committing 
*different* tables, so cross-table consistency can quietly break.
   
   Which did you intend here — a migration that fences the old id explicitly 
(init + abort on the old format before switching), or a hard "this upgrade 
requires a full connector restart, not a rolling one"? Either is defensible, 
but if it's the latter I'd want it enforced with a startup check and 
documented, not left implicit. wdyt?



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Channel.java:
##########
@@ -53,20 +54,29 @@ abstract class Channel {
   private final Admin admin;
   private final Map<Integer, Long> controlTopicOffsets = Maps.newHashMap();
   private final String producerId;
+  private final String channelId;
 
   Channel(
       String name,
       String consumerGroupId,
       IcebergSinkConfig config,
       KafkaClientFactory clientFactory,
       SinkTaskContext context) {
+    this.channelId = config.connectorName() + "-" + config.taskId() + "-" + 
name;
     this.controlTopic = config.controlTopic();
     this.connectGroupId = config.connectGroupId();
     this.context = context;
 
-    String transactionalId = config.transactionalPrefix() + name + 
config.transactionalSuffix();
+    String transactionalId =
+        "worker".equalsIgnoreCase(name)
+            ? config.transactionalPrefix() + name + 
config.transactionalSuffix()
+            : connectGroupId + "-" + config.connectorName() + "-coord";
+
     this.producer = clientFactory.createProducer(transactionalId);
-    this.consumer = clientFactory.createConsumer(consumerGroupId);
+    this.consumer =
+        "coordinator".equalsIgnoreCase(name)

Review Comment:
   The base class is branching on subclass name strings to pick both the 
transactional.id format and the offset-reset policy, and the two checks are 
asymmetric — the id branch keys off `"worker"` (else → coord id) while the 
consumer branch keys off `"coordinator"` (else → latest). Any future subclass, 
or a rename/typo, silently inherits the coordinator id with a `latest` reset, 
both wrong.
   
   I'd lift these out of the base class — pass `transactionalId` and 
`autoOffsetReset` as explicit constructor params computed by each subclass 
before `super(...)`, so the dispatch is a real contract instead of a string 
match. Cheap to do now, and it removes the base-knows-subclass coupling.



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java:
##########
@@ -197,8 +210,15 @@ public void save(Collection<SinkRecord> sinkRecords) {
 
   private void processControlEvents() {
     if (coordinatorThread != null && coordinatorThread.isTerminated()) {
-      throw new NotRunningException(
-          String.format("Coordinator unexpectedly terminated on committer %s", 
taskId));
+      if (isProducerFenced(coordinatorThread.exception())) {

Review Comment:
   `coordinatorThread.exception()` can be null — `terminate()` sets `terminated 
= true` but never calls `exception.set(...)`, so a coordinator that stops 
without recording a throwable lands here with a null cause, 
`isProducerFenced(null)` returns false, and we fall into the `else` and 
hard-fail the task with `NotRunningException`.
   
   So this branch treats "terminated but no recorded exception" as fatal, and 
it can't distinguish a clean/requested stop from a genuine crash. Can you 
confirm whether a requested terminate can ever reach this check with the field 
still set? If it can, this crashes the task after a clean stop; if it can't 
today, it's one refactor away. Either way I'd add an explicit null guard and 
decide which side null falls on.
   
   While you're here — the new `NotRunningException(String, Throwable)` ctor is 
added but this throw site still uses the one-arg form, so the coordinator's 
actual exception is dropped from the failure. Passing 
`coordinatorThread.exception()` as the cause is presumably what the new ctor 
was for.



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Coordinator.java:
##########
@@ -195,6 +196,14 @@ private void commit(boolean partialCommit) {
     }
   }
 
+  @VisibleForTesting
+  static boolean isRetryable(RuntimeException exception) {

Review Comment:
   The interaction between this allowlist and the single 
`consecutiveCommitFailures` counter is under-specified, and it bites from two 
directions.
   
   Kafka-side retryable errors (`RebalanceInProgressException`, consumer 
`CommitFailedException`, any `RetriableException`) return true here, so they 
retry — but note these are thrown from `commitConsumerOffsets()` *after* every 
`commitToTable()` has already succeeded, and they share one budget with Iceberg 
OCC conflicts. With `max-consecutive-failures = 1`, a single 
`RebalanceInProgressException` terminates the coordinator even though every 
table committed fine; conversely a permanent rebalance loop can retry unbounded 
with no circuit breaker.
   
   What semantics did you intend for a purely Kafka-side failure where the 
Iceberg commit already landed — reset the counter (it's not an Iceberg-progress 
failure), or a separate budget? I'd split the two rather than have OCC 
conflicts and offset-commit hiccups share one threshold.



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java:
##########
@@ -109,6 +118,7 @@ boolean containsFirstPartition(
           "Committer {} contains the first partition {}, this task is the 
leader",
           taskId,
           firstTopicPartition);
+      leaderTopicPartition.set(firstTopicPartition);

Review Comment:
   `leaderTopicPartition` is only ever set, never cleared. If a task was leader 
(owned P0), loses P0, then `open()` runs again without P0, 
`containsFirstPartition` returns false and leaves the stale P0 in place — so a 
later `close()` seeing P0 fires `stopCoordinator()` for a coordinator this 
epoch never started.
   
   The null guard keeps it harmless, but the "lost leader partition" log then 
lies. I'd `leaderTopicPartition.set(null)` in the `else` branch (or in 
`stopCoordinator()`) so the field tracks actual leadership.



##########
kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCoordinator.java:
##########
@@ -284,6 +284,62 @@ public void testCommitBoundedRetryWithMultipleThreads() {
         .hasMessageContaining("concurrent update");
   }
 
+  @Test
+  public void testIsRetryableClassifiesCommitAndTransientKafkaErrors() {
+    // Iceberg optimistic-concurrency failure -> retry
+    assertThat(Coordinator.isRetryable(new 
CommitFailedException("occ"))).isTrue();

Review Comment:
   Now that `CommitFailedException` is retryable, the existing 
`testCommitFailedExceptionPropagates` passes for a different reason than its 
name — it only propagates because the mock sets `max-consecutive-failures = 1`, 
exhausting the budget on the first try, not because the exception is 
non-retryable.
   
   I'd rename it to something like 
`testCommitFailedExceptionPropagatesAfterThreshold` and assert the retryable 
path was taken first, so the test validates the new invariant rather than 
passing by coincidence.



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java:
##########
@@ -232,9 +255,48 @@ private void stopWorker() {
   }
 
   private void stopCoordinator() {
-    if (coordinatorThread != null) {
-      coordinatorThread.terminate();
-      coordinatorThread = null;
+    CoordinatorThread thread = coordinatorThread;
+    if (thread == null) {
+      return;
+    }
+    coordinatorThread = null;
+
+    try {
+      if (!thread.isTerminated()) {
+        thread.terminate();
+      }
+    } catch (RuntimeException e) {
+      LOG.warn(
+          "Committer {}: error signalling coordinator termination, continuing 
shutdown", taskId, e);
+    }
+
+    try {
+      thread.join(COORDINATOR_STOP_TIMEOUT_MS);

Review Comment:
   This `join(60s)` runs on the Connect task thread — `close()` is called on 
the poll thread, and `open()` (the next leader election) only runs after 
`close()` returns. So a stuck coordinator blocks the task for up to 60s during 
a rebalance, and the consumer can't poll in that window; if 
`max.poll.interval.ms` is under 60s Connect fences this consumer and triggers 
another rebalance, which can cascade.
   
   It's also stacked: `CoordinatorThread.terminate()` internally does 
`awaitTermination(1, MINUTES)`, so worst case is ~120s total, which can blow 
past Connect's task stop deadline (5s default) and get the task 
force-interrupted mid-shutdown.
   
   I'd bound the outer join well under `max.poll.interval.ms` (say 5–10s), keep 
the background-shutdown fallback for the rare stuck case, and make sure the 
outer timeout is shorter than the inner `awaitTermination` so it can actually 
detect the inner hang. Was 60s picked deliberately, or carried over?



##########
kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCoordinator.java:
##########
@@ -284,6 +284,62 @@ public void testCommitBoundedRetryWithMultipleThreads() {
         .hasMessageContaining("concurrent update");
   }
 
+  @Test
+  public void testIsRetryableClassifiesCommitAndTransientKafkaErrors() {

Review Comment:
   These classification and dedup tests are good, but the behavior this PR is 
actually about — `processControlEvents()` clearing a fenced coordinator instead 
of failing the task — has no test.
   
   I'd add one that starts a `CoordinatorThread`, injects a 
`ProducerFencedException` (or `InvalidProducerEpochException`) as the thread's 
exception and marks it terminated, calls `processControlEvents()`, and asserts 
`coordinatorThread == null` with no `NotRunningException` thrown — and the 
mirror case (terminated with a non-fencing exception) asserting it *does* 
throw. That locks both sides of the branch, including the null-exception edge 
flagged in `processControlEvents`.



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java:
##########
@@ -232,9 +255,48 @@ private void stopWorker() {
   }
 
   private void stopCoordinator() {
-    if (coordinatorThread != null) {
-      coordinatorThread.terminate();
-      coordinatorThread = null;
+    CoordinatorThread thread = coordinatorThread;
+    if (thread == null) {
+      return;
+    }
+    coordinatorThread = null;

Review Comment:
   We null `coordinatorThread` before the join completes, which opens a window. 
If the join is interrupted we hit the early `return` with the field already 
null but the old thread still alive and still holding its producer/consumer — a 
following `open()` → `startCoordinator()` then spins up a second coordinator on 
the same transactional.id, overlapping the one we never confirmed dead.
   
   The early return also skips the `thread.isAlive()` warning below, so 
operators lose the "coordinator still running in the background" signal 
precisely on the interrupted-shutdown path where it matters most.
   
   I'd only null the field after the join resolves (use the local `thread` ref 
plus a boolean to block a second `startCoordinator()` during the wait), and 
move the `isAlive()` check so the interrupt path still logs it.



##########
kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/IntegrationTestBase.java:
##########
@@ -225,7 +225,7 @@ protected void runTest(
     flush();
 
     Awaitility.await()
-        .atMost(Duration.ofSeconds(30))
+        .atMost(Duration.ofSeconds(60))

Review Comment:
   Doubling this (and the matching one in `TestIntegrationDynamicTable`) as 
"reduce CI flakiness" without a root cause makes future latency regressions 
invisible. Three changes in this PR add commit-path latency — the bounded join 
in `stopCoordinator()`, and especially the coordinator consumer now reading 
from `earliest`, which replays control-topic history on `Channel.start()` and 
gets worse as the topic grows in production.
   
   If `earliest` replay is the culprit that's a real production concern, not 
just a test-timing one. Can you measure which path slowed down and address that 
(e.g. ensure the `-coord` group has committed offsets before the wait), then 
restore or justify the timeout?



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/CommitterImpl.java:
##########
@@ -232,9 +255,48 @@ private void stopWorker() {
   }
 
   private void stopCoordinator() {
-    if (coordinatorThread != null) {
-      coordinatorThread.terminate();
-      coordinatorThread = null;
+    CoordinatorThread thread = coordinatorThread;
+    if (thread == null) {
+      return;
+    }
+    coordinatorThread = null;
+
+    try {
+      if (!thread.isTerminated()) {
+        thread.terminate();
+      }
+    } catch (RuntimeException e) {
+      LOG.warn(
+          "Committer {}: error signalling coordinator termination, continuing 
shutdown", taskId, e);
+    }
+
+    try {
+      thread.join(COORDINATOR_STOP_TIMEOUT_MS);
+    } catch (InterruptedException e) {
+      Thread.currentThread().interrupt();
+      LOG.warn(
+          "Committer {}: interrupted while waiting for the coordinator thread 
to stop", taskId, e);
+      return;
+    }
+
+    if (thread.isAlive()) {
+      LOG.warn(
+          "Committer {}: coordinator thread did not stop within {} ms; it will 
keep shutting down "
+              + "in the background (a newer coordinator fences its producer)",
+          taskId,
+          COORDINATOR_STOP_TIMEOUT_MS);
+    }
+  }
+
+  private static boolean isProducerFenced(Throwable cause) {
+    Throwable current = cause;
+    for (int depth = 0; current != null && depth < 20; depth++, current = 
current.getCause()) {
+      if (current instanceof ProducerFencedException
+          || current instanceof InvalidProducerEpochException
+          || current instanceof UnknownProducerIdException) {

Review Comment:
   `UnknownProducerIdException` isn't only a fencing signal — it also fires 
when the broker's transactional state for this id expires 
(`transactional.id.expiration.ms`) or on a broker restart, where the right 
response is re-init, not "a newer coordinator won."
   
   At minimum the log shouldn't assert "fenced by a newer coordinator" for this 
case — something like "coordinator producer was invalidated (fenced or its 
transactional state expired)" is honest. The open question is whether you'd 
rather classify it separately as recoverable and re-init the producer instead 
of clearing the coordinator — that's more work but avoids the silent-stall path 
above for the expiry case. wdyt?



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Channel.java:
##########
@@ -119,21 +129,27 @@ protected void send(List<Event> events, 
Map<TopicPartition, Offset> sourceOffset
   protected void consumeAvailable(Duration pollDuration) {
     ConsumerRecords<String, byte[]> records = consumer.poll(pollDuration);
     while (!records.isEmpty()) {
-      records.forEach(
-          record -> {
-            // the consumer stores the offsets that corresponds to the next 
record to consume,
-            // so increment the record offset by one
-            controlTopicOffsets.put(record.partition(), record.offset() + 1);
-
-            Event event = AvroUtil.decode(record.value());
-
-            if (event.groupId().equals(connectGroupId)) {
-              LOG.debug("Received event of type: {}", event.type().name());
-              if (receive(new Envelope(event, record.partition(), 
record.offset()))) {
-                LOG.info("Handled event of type: {}", event.type().name());
-              }
-            }
-          });
+      for (ConsumerRecord<String, byte[]> record : records) {
+        if (record.offset() < 
controlTopicOffsets.getOrDefault(record.partition(), 0L)) {

Review Comment:
   Worth being precise in the comment about what this filter does, since the PR 
frames it as dedup. `controlTopicOffsets` starts empty on every new 
coordinator, so this only guards against a fetch-position regression *within* a 
single coordinator session (e.g. an eager rebalance rewinding the position) — 
it isn't cross-session idempotency.
   
   The actual cross-session dedup comes from the `committedOffsets` comparison 
in `commitToTable` plus `SnapshotAncestryValidator`. A one-line comment saying 
"intra-session guard against position regression, not cross-session dedup" 
would keep the next reader from over-trusting it.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to