spuru9 commented on code in PR #259:
URL: 
https://github.com/apache/flink-connector-jdbc/pull/259#discussion_r4572643913


##########
flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/datasource/connections/xa/SimpleXaConnectionProvider.java:
##########
@@ -222,6 +226,16 @@ public void rollback(Xid xid) {
                         this::buildRollbackErrorDesc));
     }
 
+    private void rollbackIfKnown(Xid xid) {
+        try {
+            xaResource.rollback(xid);
+        } catch (XAException e) {
+            if (e.errorCode != XAException.XAER_NOTA) {
+                LOG.warn(formatErrorMessage("rollback", xid, e.errorCode));
+            }
+        }
+    }

Review Comment:
   **major:** This only logs when the rollback fails (anything but 
`XAER_NOTA`), and `failOrRollback` then calls `result.succeeded(x)`, so the xid 
is never retried. A transient `XAER_RMFAIL` leaves the transaction prepared 
with its locks, which is what this PR is trying to avoid. Can it rethrow like 
`rollback(xid)` does, so transient failures land in the retry list?
   
   ```suggestion
       private void rollbackIfKnown(Xid xid) {
           try {
               xaResource.rollback(xid);
           } catch (XAException e) {
               if (e.errorCode != XAException.XAER_NOTA) {
                   throw wrapException("rollback", xid, e);
               }
           }
       }
   ```



##########
flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/JdbcExactlyOnceOptions.java:
##########
@@ -27,15 +27,16 @@
  * JDBC exactly once sink options.
  *
  * <p><b>maxCommitAttempts</b> - maximum number of commit attempts to make per 
transaction; must be
- * > 0; state size is proportional to the product of max number of in-flight 
snapshots and this
- * number.
+ * > 0. The committer retries a transient failure right away, and Flink stops 
after {@code
+ * sink.committer.retries} retries, so a transaction gets at most the smaller 
of maxCommitAttempts
+ * and {@code sink.committer.retries} + 1 attempts.
  *
- * <p><b>allowOutOfOrderCommits</b> - If true, all prepared transactions will 
be attempted to commit
- * regardless of any transient failures during this operation. This may lead 
to inconsistency.
- * Default: false.
+ * <p><b>allowOutOfOrderCommits</b> - deprecated; see {@link
+ * JDBCExactlyOnceOptionsBuilder#withAllowOutOfOrderCommits(boolean)}. 
Default: false.
  *
  * <p><b>recoveredAndRollback</b> - whether to rollback prepared transactions 
known to XA RM on
- * startup (after committing <b>known</b> transactions, i.e. restored from 
state).
+ * startup (after committing <b>known</b> transactions, i.e. restored from 
state). Transactions of
+ * checkpoints before the restored one belong to the committer.

Review Comment:
   **minor:** This reads as if the flag controls the rollback of leftovers, but 
`recoverAndRollback()` filters with `belongsTo`, which never matches the driver 
xids `recover()` returns (FLINK-41101), so it's a no-op right now. The writer 
always rolls back its own leftovers in `rollbackPreparedTxs`. Could you say 
that here? The NOTE below it describes the old behavior too, so maybe mark it 
as legacy.
   
   ```suggestion
    * <p><b>recoveredAndRollback</b> - currently has no effect on the sink 
(FLINK-41101). The writer
    * always rolls back its own prepared transactions of the restored 
checkpoint or later on start.
    * Transactions of checkpoints before the restored one belong to the 
committer.
   ```



##########
flink-connector-jdbc-core/src/main/java/org/apache/flink/connector/jdbc/datasource/connections/xa/PoolingXaConnectionProvider.java:
##########
@@ -44,8 +45,10 @@
 /**
  * A "pooling" implementation of {@link XaConnectionProvider}. Some database 
implement XA such that
  * one connection is limited to a single transaction. As a workaround, this 
implementation creates a
- * new XA resource after each xa_start call is made (and associates it with 
the xid to commit
- * later).
+ * new XA resource after each xa_start call is made (and associates it with 
the xid until the
+ * transaction is prepared). A prepared transaction is committed or rolled 
back on any idle XA
+ * resource, so the database must allow that from another connection (for 
MySQL, {@code
+ * xa_detach_on_prepare}).

Review Comment:
   **nit:** "creates a new XA resource after each xa_start" isn't true anymore, 
`start()` takes an idle one from the pool and only creates one when the pool is 
empty.
   
   ```suggestion
    * one connection is limited to a single transaction. As a workaround, this 
implementation takes an
    * idle XA resource from a pool (or creates one) on each xa_start call and 
associates it with the
    * xid until the transaction is prepared. A prepared transaction is 
committed or rolled back on any
    * idle XA resource, so the database must allow that from another connection 
(for MySQL, {@code
    * xa_detach_on_prepare}).
   ```



##########
flink-connector-jdbc-core/src/test/java/org/apache/flink/connector/jdbc/core/datastream/sink/ExactlyOnceJdbcSinkTest.java:
##########
@@ -17,17 +17,286 @@
 
 package org.apache.flink.connector.jdbc.core.datastream.sink;
 
+import org.apache.flink.api.common.eventtime.WatermarkStrategy;
+import org.apache.flink.api.common.functions.MapFunction;
+import org.apache.flink.api.common.typeinfo.TypeInformation;
+import 
org.apache.flink.api.connector.source.util.ratelimit.RateLimiterStrategy;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.RestartStrategyOptions;
+import org.apache.flink.connector.datagen.source.DataGeneratorSource;
 import org.apache.flink.connector.jdbc.JdbcExactlyOnceOptions;
 import org.apache.flink.connector.jdbc.JdbcExecutionOptions;
+import 
org.apache.flink.connector.jdbc.datasource.connections.xa.SimpleXaConnectionProvider;
+import org.apache.flink.connector.jdbc.testutils.TableManaged;
+import org.apache.flink.connector.jdbc.testutils.tables.TableRow;
+import org.apache.flink.connector.jdbc.testutils.tables.templates.BooksTable;
+import org.apache.flink.runtime.state.FunctionInitializationContext;
+import org.apache.flink.runtime.state.FunctionSnapshotContext;
+import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.util.RestartStrategyUtils;
+import org.apache.flink.testutils.junit.SharedObjectsExtension;
+import org.apache.flink.testutils.junit.SharedReference;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import javax.transaction.xa.Xid;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import static 
org.apache.flink.connector.jdbc.testutils.tables.TableBuilder.dbType;
+import static 
org.apache.flink.connector.jdbc.testutils.tables.TableBuilder.field;
+import static 
org.apache.flink.connector.jdbc.testutils.tables.TableBuilder.tableRow;
+import static org.apache.flink.table.api.DataTypes.DOUBLE;
+import static org.apache.flink.table.api.DataTypes.INT;
+import static org.apache.flink.table.api.DataTypes.VARCHAR;
+import static org.assertj.core.api.Assertions.assertThat;
 
 /** Smoke tests for the {@link JdbcSink} and the underlying classes. */
 class ExactlyOnceJdbcSinkTest extends BaseJdbcSinkTest {
 
+    /** The books table without its key, so that a record written twice shows 
up twice. */
+    private static final TableRow RECOVERY_TABLE =
+            tableRow(
+                    "RecoverySinkTable",
+                    field("id", INT().notNull()),
+                    field("title", VARCHAR(50)),
+                    field("author", VARCHAR(50)),
+                    field("price", dbType("FLOAT"), DOUBLE()),
+                    field("qty", INT()));
+
+    private static final long RECOVERY_RECORDS = 100;
+
+    private static final BooksTable UPDATE_TABLE = new 
BooksTable("UpdateSinkTable");
+
+    private static final int UPDATES = 10_000;
+
+    @RegisterExtension
+    private final SharedObjectsExtension sharedObjects = 
SharedObjectsExtension.create();
+
     @Override
     protected <T> JdbcSink<T> finishSink(JdbcSinkBuilder<T> builder) {
         return builder.withExecutionOptions(
                         
JdbcExecutionOptions.builder().withMaxRetries(0).build())
                 .buildExactlyOnce(
                         JdbcExactlyOnceOptions.defaults(), 
getMetadata().getXaSourceSupplier());
     }
+
+    @Override
+    public List<TableManaged> getManagedTables() {
+        List<TableManaged> tables = new ArrayList<>(super.getManagedTables());
+        tables.add(RECOVERY_TABLE);
+        tables.add(UPDATE_TABLE);
+        return tables;
+    }
+
+    /** A failed test can leave a prepared transaction behind, which holds its 
locks. */
+    @AfterEach
+    void rollbackPreparedTransactions() throws SQLException {
+        try (SimpleXaConnectionProvider xa =
+                
SimpleXaConnectionProvider.from(getMetadata().buildXaDataSource())) {
+            xa.open();
+            for (Xid xid : xa.recover()) {
+                xa.rollback(xid);
+            }
+        }
+    }
+
+    /**
+     * An operator ahead of the sink fails the second checkpoint after the 
sink has prepared its
+     * transaction for it. The restart from the first checkpoint replays the 
records of that
+     * transaction, so it must not have been committed.
+     */
+    @Test
+    void testRecoveryAfterAFailedCheckpoint() throws Exception {
+        testRecoveryAfterFailedCheckpoints(2, 1, false);
+    }
+
+    /**
+     * The first checkpoint fails after the sink has prepared its first 
transaction. The restart has
+     * no state and starts the same xid again, so it must have been rolled 
back.
+     */
+    @Test
+    void testRecoveryAfterAFailedFirstCheckpoint() throws Exception {
+        testRecoveryAfterFailedCheckpoints(1, 1, false);
+    }
+
+    /**
+     * The second checkpoint expires, which is tolerated, and the third one 
expires too, which fails
+     * the job. The sink has prepared a transaction for each of them, and the 
restart from the first
+     * checkpoint must roll back both.
+     */
+    @Test
+    void testRecoveryAfterATolerableFailedCheckpoint() throws Exception {
+        testRecoveryAfterFailedCheckpoints(2, 2, true);
+    }
+
+    private void testRecoveryAfterFailedCheckpoints(
+            long firstFailingCheckpointId, int failingCheckpoints, boolean 
expire)
+            throws Exception {
+        Configuration config = new Configuration();
+        config.set(RestartStrategyOptions.RESTART_STRATEGY, "fixed-delay");
+        
config.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_ATTEMPTS, 1);
+        config.set(RestartStrategyOptions.RESTART_STRATEGY_FIXED_DELAY_DELAY, 
Duration.ZERO);
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment(config);
+        env.setParallelism(1);
+        env.enableCheckpointing(200L);
+        if (expire) {
+            env.getCheckpointConfig().setCheckpointTimeout(300L);
+            
env.getCheckpointConfig().setTolerableCheckpointFailureNumber(failingCheckpoints
 - 1);
+        }
+        SharedReference<AtomicInteger> failed = sharedObjects.add(new 
AtomicInteger());
+        DataGeneratorSource<BooksTable.BookEntry> books =
+                new DataGeneratorSource<>(
+                        ExactlyOnceJdbcSinkTest::book,
+                        RECOVERY_RECORDS,
+                        // records keep coming while checkpoints expire
+                        expire
+                                ? RateLimiterStrategy.perSecond(40)
+                                : RateLimiterStrategy.perCheckpoint(10),
+                        TypeInformation.of(BooksTable.BookEntry.class));
+
+        env.fromSource(books, WatermarkStrategy.noWatermarks(), "books")
+                .map(
+                        new FailingSnapshotMapper(
+                                failed, firstFailingCheckpointId, 
failingCheckpoints, expire))
+                .sinkTo(
+                        finishSink(
+                                new JdbcSinkBuilder<BooksTable.BookEntry>()
+                                        .withQueryStatement(
+                                                
RECOVERY_TABLE.getInsertIntoQuery(),
+                                                (ps, book) -> {
+                                                    ps.setInt(1, book.id);
+                                                    ps.setString(2, 
book.title);
+                                                    ps.setString(3, 
book.author);
+                                                    ps.setDouble(4, 
book.price);
+                                                    ps.setInt(5, book.qty);
+                                                })));
+        env.execute();
+
+        assertThat(failed.get().get()).isEqualTo(failingCheckpoints);
+        try (SimpleXaConnectionProvider xa =
+                
SimpleXaConnectionProvider.from(getMetadata().buildXaDataSource())) {
+            xa.open();
+            assertThat(xa.recover()).isEmpty();
+        }
+        List<Integer> written =
+                RECOVERY_TABLE.selectAllTable(getMetadata()).stream()
+                        .map(row -> (Integer) row.getField(0))
+                        .collect(Collectors.toList());
+        assertThat(written)
+                .containsExactlyInAnyOrderElementsOf(
+                        IntStream.range(0, (int) RECOVERY_RECORDS)
+                                .boxed()
+                                .collect(Collectors.toList()));
+    }
+
+    /**
+     * Every record updates the same row, so after each checkpoint the writer 
needs the lock that
+     * the transaction prepared for that checkpoint holds until the committer 
commits it.
+     */
+    @Test
+    void testUpdateRowOfThePreviousCheckpoint() throws Exception {
+        BooksTable.BookEntry book = new BooksTable.BookEntry(0, "Book 0", 
"Writer", 0D, 0);
+        try (Connection connection = getMetadata().getConnection();
+                PreparedStatement ps =
+                        
connection.prepareStatement(UPDATE_TABLE.getInsertIntoQuery())) {
+            UPDATE_TABLE.getStatementBuilder().accept(ps, book);
+            ps.execute();
+        }
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        RestartStrategyUtils.configureNoRestartStrategy(env);
+        env.setParallelism(1);
+        env.enableCheckpointing(50L);
+
+        env.fromSequence(1, UPDATES)
+                .sinkTo(
+                        new JdbcSinkBuilder<Long>()
+                                .withQueryStatement(
+                                        "UPDATE UpdateSinkTable SET qty = ? 
WHERE id = 0",
+                                        (ps, qty) -> ps.setInt(1, 
qty.intValue()))
+                                .withExecutionOptions(
+                                        JdbcExecutionOptions.builder()
+                                                .withBatchSize(1)
+                                                .withMaxRetries(0)
+                                                .build())
+                                .buildExactlyOnce(
+                                        JdbcExactlyOnceOptions.defaults(),
+                                        getMetadata().getXaSourceSupplier()));
+        env.execute();
+
+        try (Connection connection = getMetadata().getConnection()) {
+            assertThat(UPDATE_TABLE.selectAllTable(connection))
+                    .containsExactly(
+                            new BooksTable.BookEntry(
+                                    book.id, book.title, book.author, 
book.price, UPDATES));
+        }
+    }
+
+    private static BooksTable.BookEntry book(Long id) {
+        return new BooksTable.BookEntry(id.intValue(), "Book " + id, "Writer", 
id.doubleValue(), 1);
+    }
+
+    /**
+     * Fails the snapshots of the given number of checkpoints, or lets them 
expire. A chain takes
+     * its snapshots from the tail to the head, so the sink has already 
prepared its transaction for
+     * those checkpoints.
+     */
+    private static class FailingSnapshotMapper
+            implements MapFunction<BooksTable.BookEntry, BooksTable.BookEntry>,
+                    CheckpointedFunction {
+
+        private final SharedReference<AtomicInteger> failed;
+        private final long firstFailingCheckpointId;
+        private final int failingCheckpoints;
+        private final boolean expire;
+        private boolean restored;
+
+        FailingSnapshotMapper(
+                SharedReference<AtomicInteger> failed,
+                long firstFailingCheckpointId,
+                int failingCheckpoints,
+                boolean expire) {
+            this.failed = failed;
+            this.firstFailingCheckpointId = firstFailingCheckpointId;
+            this.failingCheckpoints = failingCheckpoints;
+            this.expire = expire;
+        }
+
+        @Override
+        public BooksTable.BookEntry map(BooksTable.BookEntry book) {
+            return book;
+        }
+
+        @Override
+        public void snapshotState(FunctionSnapshotContext context) throws 
Exception {
+            if (!restored
+                    && context.getCheckpointId() >= firstFailingCheckpointId
+                    && failed.get().get() < failingCheckpoints) {
+                failed.get().incrementAndGet();
+                if (expire) {
+                    // longer than the checkpoint timeout, short enough for 
the next barrier to
+                    // reach the sink before the job fails
+                    Thread.sleep(400L);

Review Comment:
   **minor:** Not a blocker, but this relies on 400 ms outlasting the 
checkpoint timeout while ending before the next barrier reaches the sink, which 
can flake on a slow CI box. Could it wait on a latch released from 
`notifyCheckpointAborted` instead?



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

Reply via email to