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


##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/IcebergSinkConfig.java:
##########
@@ -443,6 +443,16 @@ public String transactionalPrefix() {
     return "";
   }
 
+  /**
+   * The transactional ID for the coordinator's producer: scoped to the 
coordinator role, stable
+   * across coordinator instances and task restarts within a connector, and 
unique across
+   * connectors. Its stability lets an incoming coordinator's {@code 
initTransactions()} bump the
+   * producer epoch and fence a stale coordinator.
+   */
+  public String coordinatorTransactionalId() {
+    return transactionalPrefix() + connectGroupId() + "-coordinator";

Review Comment:
   The format change here is the right call, but it quietly changes two things 
worth calling out. The suffix is gone from the coordinator id — anyone who set 
the internal transactional suffix prop loses it on upgrade with no warning, so 
I'd either drop it deliberately and note it, or keep honoring it.
   
   The other one is the upgrade window: the old coordinator holds the epoch on 
the old `...coordinator` id while the new one initializes on 
`...<connectGroupId>-coordinator`, so for the duration of a rolling restart 
they're in different transactional-id namespaces and can't fence each other. 
Snapshot validation still protects the data, but the fencing guarantee has a 
gap exactly during the transition. An upgrade note covering both would do it — 
wdyt?
   



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Channel.java:
##########
@@ -177,7 +177,20 @@ protected void commitConsumerOffsets() {
 
     if (!offsetsToCommit.isEmpty()) {
       LOG.debug("Committing consumer offsets: {}", offsetsToCommit);
-      consumer.commitSync(offsetsToCommit);
+      synchronized (producer) {
+        producer.beginTransaction();
+        try {
+          producer.sendOffsetsToTransaction(offsetsToCommit, 
consumer.groupMetadata());
+          producer.commitTransaction();

Review Comment:
   `beginTransaction()` is outside the try, so when the producer is fenced the 
exception comes from `sendOffsetsToTransaction()`/`commitTransaction()` and we 
land in this catch. But `abortTransaction()` on an already-fenced producer is 
itself fatal — it throws again, we swallow it, and emit `LOG.warn("Error 
aborting producer transaction", ...)`.
   
   So every clean, expected fencing event — the exact happy path this PR is 
built around — produces a scary WARN that looks like a real failure. The 
original `ProducerFencedException` still propagates via `throw e`, so detection 
works; it's just noisy and alert-prone. I'd skip the abort when the cause is 
already fatal:
   
   ```java
   } catch (Exception e) {
     if (!(e instanceof ProducerFencedException)
         && !(e instanceof InvalidProducerEpochException)) {
       try {
         producer.abortTransaction();
       } catch (Exception ex) {
         LOG.warn("Error aborting producer transaction", ex);
       }
     }
     throw e;
   }
   ```
   
   The same shape exists in `send()` — worth fixing both while we're here.
   



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Worker.java:
##########
@@ -49,8 +49,8 @@ class Worker extends Channel {
       SinkTaskContext context) {
     // pass transient consumer group ID to which we never commit offsets
     super(
-        "worker",
         config.controlGroupIdPrefix() + UUID.randomUUID(),
+        config.transactionalPrefix() + "worker" + config.transactionalSuffix(),

Review Comment:
   Quick check on this one: if `transactionalSuffix()` can return null when the 
suffix prop is unset, this concatenates to `...workernull` for every task on 
the connector, and they'd all share one transactional id and fence each other 
on `initTransactions()`. With eager `initTransactions()` that surfaces as 
`ProducerFencedException` on every worker commit.
   
   Is the suffix guaranteed to default to `""` here rather than null? If so 
this is fine; if not, I'd add a default so it can't null-concatenate.
   



##########
kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCommitterImpl.java:
##########
@@ -165,4 +173,54 @@ public void 
testStartFailurePropagatesAsNotRunningException()
         .isInstanceOf(NotRunningException.class)
         .hasMessageContaining("Coordinator unexpectedly terminated");
   }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"ProducerFenced", "InvalidProducerEpoch"})
+  public void testFencedCoordinatorIsClearedWithoutFailingTask(String 
exceptionType)
+      throws NoSuchFieldException, IllegalAccessException {
+    RuntimeException fenceException =
+        "ProducerFenced".equals(exceptionType)
+            ? new ProducerFencedException("fenced by a newer coordinator")
+            : new InvalidProducerEpochException("producer epoch bumped by a 
newer coordinator");
+
+    Coordinator coordinator = mock(Coordinator.class);
+    doThrow(fenceException).when(coordinator).process();

Review Comment:
   This exercises the CoordinatorThread → CommitterImpl dispatch, but the 
Coordinator is mocked so it doesn't cover the layer that actually matters: a 
real Channel producer getting fenced, `Coordinator.process()` propagating it, 
and CoordinatorThread seeing it as fenced. That's the same chain where the 
abort-catch in `commitConsumerOffsets()` could swallow or reshape the exception.
   
   A test that builds a Coordinator over a `MockProducer` with 
`fenceProducer()` called, runs a real commit cycle, and asserts `isFenced()` 
then that the committer clears without throwing would lock the propagation end 
to end. wdyt?
   



##########
kafka-connect/kafka-connect/src/main/java/org/apache/iceberg/connect/channel/Channel.java:
##########
@@ -147,10 +146,11 @@ protected Map<Integer, Long> controlTopicOffsets() {
   }
 
   /**
-   * Commit consumer offsets. Only commits offsets if it has not committed 
offsets before or the
-   * value is greater than the cached offset.
-   *
-   * <p>Note: there is a risk that two parallel coordinators may overwrite 
each other's offsets.
+   * Commits consumer offsets through the coordinator's transactional 
producer, committing a

Review Comment:
   This reads like fencing covers the commit end to end, but it's the offset 
commit that's fenced — the table snapshot commit in `Coordinator.doCommit()` 
runs outside any Kafka transaction and still relies on 
`SnapshotAncestryValidator` for the stale-coordinator case.
   
   A stale coordinator can still land a stale snapshot in the window between 
the new coordinator's `initTransactions()` and its own fenced offset commit; 
that's caught at the Iceberg level, not by epoch fencing. I'd make that 
boundary explicit here (and maybe a line in `doCommit()`) so nobody over-trusts 
the Kafka guarantee. wdyt?
   



##########
kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCommitterImpl.java:
##########
@@ -165,4 +173,54 @@ public void 
testStartFailurePropagatesAsNotRunningException()
         .isInstanceOf(NotRunningException.class)
         .hasMessageContaining("Coordinator unexpectedly terminated");
   }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"ProducerFenced", "InvalidProducerEpoch"})
+  public void testFencedCoordinatorIsClearedWithoutFailingTask(String 
exceptionType)
+      throws NoSuchFieldException, IllegalAccessException {
+    RuntimeException fenceException =
+        "ProducerFenced".equals(exceptionType)
+            ? new ProducerFencedException("fenced by a newer coordinator")
+            : new InvalidProducerEpochException("producer epoch bumped by a 
newer coordinator");
+
+    Coordinator coordinator = mock(Coordinator.class);
+    doThrow(fenceException).when(coordinator).process();
+
+    CoordinatorThread coordinatorThread = new CoordinatorThread(coordinator);
+    coordinatorThread.start();
+
+    verify(coordinator, timeout(1000)).stop();
+    assertThat(coordinatorThread.isTerminated()).isTrue();
+    assertThat(coordinatorThread.isFenced()).isTrue();
+
+    CommitterImpl committer = new CommitterImpl();
+    Field field = CommitterImpl.class.getDeclaredField("coordinatorThread");
+    field.setAccessible(true);
+    field.set(committer, coordinatorThread);
+
+    committer.save(Collections.emptyList());
+    assertThat(field.get(committer)).isNull();
+  }
+
+  @Test
+  public void testRequestedTerminationWithNoRecordedErrorIsTreatedAsFatal()

Review Comment:
   This asserts the `terminated && !isFenced() && error == null` branch, but I 
don't think production can reach that state from explicit termination — 
`stopCoordinator()` does `terminate()` then nulls `coordinatorThread` on the 
same thread, so `processControlEvents()` never observes a 
terminated-with-no-error thread. Either drop this or add a comment that it's 
guarding an unreachable case and point at `stopCoordinator()`.
   



##########
kafka-connect/kafka-connect-runtime/src/integration/java/org/apache/iceberg/connect/TestCoordinatorFencing.java:
##########
@@ -0,0 +1,159 @@
+/*
+ * 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.iceberg.connect;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.clients.consumer.ConsumerGroupMetadata;
+import org.apache.kafka.clients.consumer.OffsetAndMetadata;
+import org.apache.kafka.clients.producer.KafkaProducer;
+import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.errors.ProducerFencedException;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Verifies that a stale coordinator cannot overwrite offsets committed by a 
newer coordinator, even
+ * when the stale coordinator commits an offset ahead of its own previous one 
but still behind the
+ * newer coordinator's.
+ */
+public class TestCoordinatorFencing {
+
+  private static final long STALE_INITIAL_OFFSET = 100L;
+  private static final long NEW_COORDINATOR_OFFSET = 200L;
+  private static final long NEXT_STALE_OFFSET = 150L;
+
+  private final TestContext context = TestContext.instance();
+
+  private String topicName;
+  private String groupId;
+  private TopicPartition topicPartition;
+  private Admin admin;
+
+  @BeforeEach
+  public void before() {
+    topicName = "coord-fencing-topic-" + UUID.randomUUID();
+    groupId = "coord-fencing-group-" + UUID.randomUUID();
+    topicPartition = new TopicPartition(topicName, 0);
+    admin = context.initLocalAdmin();
+    createTopic(topicName);
+  }
+
+  @AfterEach
+  public void after() {
+    deleteTopic(topicName);
+    admin.close();
+  }
+
+  @Test
+  public void newCoordinatorFencesStaleCoordinatorOffsetCommits() throws 
Exception {
+    Map<String, String> connectorProps = connectorProps();
+    String staleCoordinatorId = new 
IcebergSinkConfig(connectorProps).coordinatorTransactionalId();
+    String newCoordinatorId = new 
IcebergSinkConfig(connectorProps).coordinatorTransactionalId();

Review Comment:
   All three of us landed on this independently: the whole test hinges on 
`staleCoordinatorId` and `newCoordinatorId` being the same id, but that's never 
asserted. It won't pass silently if they diverge — the `assertThatThrownBy` 
would just see no exception and fail — but the precondition is invisible to 
anyone reading it.
   
   I'd assert it directly so the stability property is the thing under test, 
not an assumption:
   
   ```java
   assertThat(newCoordinatorId)
       .as("coordinator transactional id must be stable for the same connector 
config")
       .isEqualTo(staleCoordinatorId);
   ```
   
   A single `transactionalId` variable plus a comment that both coordinators 
share it and `initTransactions()` on the second bumps the epoch would read even 
more clearly.
   



##########
kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestChannel.java:
##########
@@ -70,8 +70,14 @@ public void 
committedControlTopicOffsetsDoNotRegressOnReplay() {
 
     // the offset committed for the group is what a restarted channel resumes 
from, and what the
     // coordinator stamps on the snapshot, so a regression here is durable
-    assertThat(consumer.committed(ImmutableSet.of(CTL_TOPIC_PARTITION)))
-        .containsEntry(CTL_TOPIC_PARTITION, new OffsetAndMetadata(5L));
+    assertThat(lastGroupOffsets()).containsEntry(CTL_TOPIC_PARTITION, new 
OffsetAndMetadata(5L));
+  }
+
+  private Map<TopicPartition, OffsetAndMetadata> lastGroupOffsets() {
+    List<Map<String, Map<TopicPartition, OffsetAndMetadata>>> history =
+        producer.consumerGroupOffsetsHistory();
+    assertThat(history).isNotEmpty();
+    return history.get(history.size() - 1).values().iterator().next();

Review Comment:
   `values().iterator().next()` pulls an arbitrary group's offsets — the outer 
map is keyed by consumer group id, so once more than one group has committed 
this returns whatever the map's iteration order hands back. Keying by the known 
group id and asserting `containsKey` would make it deterministic. Same shape in 
`TestCoordinator.lastCommittedOffset()` — worth fixing together.
   



##########
kafka-connect/kafka-connect/src/test/java/org/apache/iceberg/connect/channel/TestCoordinator.java:
##########
@@ -433,21 +408,30 @@ public void 
testCommitConsumerMixedPartitionsRewindOrAdvance() {
     coordinator.controlTopicOffsets().put(1, watermarkToSkip);
     coordinator.commitConsumerOffsets();
 
-    Map<TopicPartition, OffsetAndMetadata> committedOffsetAndMetadata =
-        consumer.committed(ImmutableSet.of(ctl0, ctl1));
-
-    OffsetAndMetadata committed0 = committedOffsetAndMetadata.get(ctl0);
-    OffsetAndMetadata committed1 = committedOffsetAndMetadata.get(ctl1);
-
-    assertThat(committed0 == null ? 0L : committed0.offset())
+    assertThat(lastCommittedOffset(0))
         .as("commitConsumerOffsets should advance the consumer group offsets")
         .isEqualTo(watermarkToCommit);
 
-    assertThat(committed1 == null ? 0L : committed1.offset())
+    assertThat(lastCommittedOffset(1))
         .as("commitConsumerOffsets should not rewind consumer group offsets")
         .isEqualTo(healthWatermark1);
   }
 
+  private Long lastCommittedOffset(int partition) {
+    TopicPartition topicPartition = new TopicPartition(CTL_TOPIC_NAME, 
partition);
+    Long result = null;
+    for (Map<String, Map<TopicPartition, OffsetAndMetadata>> committed :
+        producer.consumerGroupOffsetsHistory()) {
+      for (Map<TopicPartition, OffsetAndMetadata> offsets : 
committed.values()) {
+        OffsetAndMetadata metadata = offsets.get(topicPartition);
+        if (metadata != null) {
+          result = metadata.offset();
+        }
+      }
+    }
+    return result;

Review Comment:
   When nothing was ever committed this returns a boxed `null`, and the failure 
then reads `null is not equal to 100`, which hides that 
`sendOffsetsToTransaction` was never called at all. An 
`assertThat(result).isNotNull().as(...)` before returning would make that 
failure mode obvious (and avoids an unboxing NPE if a caller ever uses the 
value as a primitive `long`).
   



-- 
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