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

snuyanzin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-connector-gcp-pubsub.git


The following commit(s) were added to refs/heads/main by this push:
     new a8308e9  [FLINK-24298] Add GCP PubSub Sink API Implementation, bump 
Flink version to 1.19.0
a8308e9 is described below

commit a8308e9d5b9e333d1be2a80085adb3c1a48295a5
Author: Ahmed Hamdy <[email protected]>
AuthorDate: Fri Jun 7 00:05:25 2024 +0100

    [FLINK-24298] Add GCP PubSub Sink API Implementation, bump Flink version to 
1.19.0
---
 .github/workflows/push_pr.yml                      |   9 +-
 .github/workflows/weekly.yml                       |   4 -
 .../e67546b8-cd8f-4e55-ac55-83cf9f170610           |  10 +
 .../archunit-violations/stored.rules               |   3 +-
 flink-connector-gcp-pubsub/pom.xml                 |  36 +++
 .../connector/gcp/pubsub/sink/GcpWriterClient.java |  25 +++
 .../connector/gcp/pubsub/sink/PubSubException.java |  11 +
 .../pubsub/sink/PubSubExceptionClassifiers.java    |  30 +++
 .../connector/gcp/pubsub/sink/PubSubSinkV2.java    |  96 ++++++++
 .../gcp/pubsub/sink/PubSubSinkV2Builder.java       |  94 ++++++++
 .../connector/gcp/pubsub/sink/PubSubWriter.java    | 208 +++++++++++++++++
 .../gcp/pubsub/sink/PubSubWriterClient.java        |  37 ++++
 .../gcp/pubsub/sink/config/GcpPublisherConfig.java | 113 ++++++++++
 .../SerializableTransportChannelProvider.java      |  26 +++
 .../connectors/gcp/pubsub/PubSubSink.java          |   1 +
 .../gcp/pubsub/sink/PubSubSinkV2BuilderTest.java   | 150 +++++++++++++
 .../gcp/pubsub/sink/PubSubWriterTest.java          | 246 +++++++++++++++++++++
 .../gcp/pubsub/sink/util/TestGcpWriterClient.java  | 185 ++++++++++++++++
 pom.xml                                            |   2 +-
 19 files changed, 1273 insertions(+), 13 deletions(-)

diff --git a/.github/workflows/push_pr.yml b/.github/workflows/push_pr.yml
index c6d2a0e..1aec95c 100644
--- a/.github/workflows/push_pr.yml
+++ b/.github/workflows/push_pr.yml
@@ -28,13 +28,8 @@ jobs:
   compile_and_test:
     strategy:
       matrix:
-          flink: [ 1.18-SNAPSHOT ]
-          jdk: [ '8, 11, 17' ]
-          include:
-            - flink: 1.19-SNAPSHOT
-              jdk: '8, 11, 17, 21'
-            - flink: 1.20-SNAPSHOT
-              jdk: '8, 11, 17, 21'
+          flink: [ 1.19-SNAPSHOT, 1.20-SNAPSHOT ]
+          jdk: [ '8, 11, 17, 21' ]
     uses: apache/flink-connector-shared-utils/.github/workflows/ci.yml@ci_utils
     with:
       flink_version: ${{ matrix.flink }}
diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml
index f0c481e..d224537 100644
--- a/.github/workflows/weekly.yml
+++ b/.github/workflows/weekly.yml
@@ -37,10 +37,6 @@ jobs:
           flink: 1.19-SNAPSHOT,
           jdk: '8, 11, 17, 21',
           branch: main
-        }, {
-          flink: 1.18-SNAPSHOT,
-          jdk: '8, 11, 17',
-          branch: main
         }, {
           flink: 1.18.1,
           jdk: '8, 11, 17',
diff --git 
a/flink-connector-gcp-pubsub/archunit-violations/e67546b8-cd8f-4e55-ac55-83cf9f170610
 
b/flink-connector-gcp-pubsub/archunit-violations/e67546b8-cd8f-4e55-ac55-83cf9f170610
new file mode 100644
index 0000000..b52e90c
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/archunit-violations/e67546b8-cd8f-4e55-ac55-83cf9f170610
@@ -0,0 +1,10 @@
+Constructor 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSource$PubSubSourceBuilder.<init>(org.apache.flink.api.common.serialization.DeserializationSchema)>
 calls method 
<org.apache.flink.util.Preconditions.checkNotNull(java.lang.Object)> in 
(PubSubSource.java:251)
+Constructor 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSource$PubSubSourceBuilder.<init>(org.apache.flink.streaming.connectors.gcp.pubsub.common.PubSubDeserializationSchema)>
 calls method 
<org.apache.flink.util.Preconditions.checkNotNull(java.lang.Object)> in 
(PubSubSource.java:256)
+Method 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSink$PubSubSinkBuilder.withProjectName(java.lang.String)>
 calls method 
<org.apache.flink.util.Preconditions.checkNotNull(java.lang.Object)> in 
(PubSubSink.java:268)
+Method 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSink$PubSubSinkBuilder.withTopicName(java.lang.String)>
 calls method 
<org.apache.flink.util.Preconditions.checkNotNull(java.lang.Object)> in 
(PubSubSink.java:275)
+Method 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSource$PubSubSourceBuilder.build()>
 calls constructor 
<org.apache.flink.api.common.io.ratelimiting.GuavaFlinkConnectorRateLimiter.<init>()>
 in (PubSubSource.java:352)
+Method 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSource$PubSubSourceBuilder.withProjectName(java.lang.String)>
 calls method 
<org.apache.flink.util.Preconditions.checkNotNull(java.lang.Object)> in 
(PubSubSource.java:262)
+Method 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSource$PubSubSourceBuilder.withSubscriptionName(java.lang.String)>
 calls method 
<org.apache.flink.util.Preconditions.checkNotNull(java.lang.Object)> in 
(PubSubSource.java:269)
+Method 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSource.hasNoCheckpointingEnabled(org.apache.flink.api.common.functions.RuntimeContext)>
 calls method 
<org.apache.flink.streaming.api.operators.StreamingRuntimeContext.isCheckpointingEnabled()>
 in (PubSubSource.java:120)
+Method 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSource.hasNoCheckpointingEnabled(org.apache.flink.api.common.functions.RuntimeContext)>
 checks instanceof 
<org.apache.flink.streaming.api.operators.StreamingRuntimeContext> in 
(PubSubSource.java:119)
+Method 
<org.apache.flink.streaming.connectors.gcp.pubsub.PubSubSource.open(org.apache.flink.configuration.Configuration)>
 calls method 
<org.apache.flink.api.common.serialization.RuntimeContextInitializationContextAdapters.deserializationAdapter(org.apache.flink.api.common.functions.RuntimeContext,
 java.util.function.Function)> in (PubSubSource.java:111)
diff --git a/flink-connector-gcp-pubsub/archunit-violations/stored.rules 
b/flink-connector-gcp-pubsub/archunit-violations/stored.rules
index 1ccd176..89d737f 100644
--- a/flink-connector-gcp-pubsub/archunit-violations/stored.rules
+++ b/flink-connector-gcp-pubsub/archunit-violations/stored.rules
@@ -1,5 +1,5 @@
 #
-#Wed Dec 14 14:18:45 CET 2022
+#Thu May 09 16:39:51 BST 2024
 Return\ and\ argument\ types\ of\ methods\ annotated\ with\ @Public\ must\ be\ 
annotated\ with\ @Public.=523c7827-2ffa-4081-bbb3-d09679b7bbb8
 Connector\ production\ code\ must\ not\ depend\ on\ non-public\ API\ outside\ 
of\ connector\ packages=0e990728-73ca-4506-b7ad-bbcd6750ba22
 ITCASE\ tests\ should\ use\ a\ MiniCluster\ resource\ or\ 
extension=2f5a722a-a4be-46cf-977f-047a2a23bf90
@@ -8,3 +8,4 @@ Tests\ inheriting\ from\ AbstractTestBase\ should\ have\ name\ 
ending\ with\ ITC
 Options\ for\ connectors\ and\ formats\ should\ reside\ in\ a\ consistent\ 
package\ and\ be\ public\ API.=d8268f41-1f32-4ad5-ba10-1e9114c420f9
 Return\ and\ argument\ types\ of\ methods\ annotated\ with\ @PublicEvolving\ 
must\ be\ annotated\ with\ 
@Public(Evolving).=b15f3843-3603-447d-b9ac-f63abafb89e3
 Classes\ in\ API\ packages\ should\ have\ at\ least\ one\ API\ visibility\ 
annotation.=8bf89fe2-7522-424e-ac41-c271d0809c09
+Connector\ production\ code\ must\ depend\ only\ on\ public\ API\ when\ 
outside\ of\ connector\ packages=e67546b8-cd8f-4e55-ac55-83cf9f170610
diff --git a/flink-connector-gcp-pubsub/pom.xml 
b/flink-connector-gcp-pubsub/pom.xml
index 61df172..1c94c76 100644
--- a/flink-connector-gcp-pubsub/pom.xml
+++ b/flink-connector-gcp-pubsub/pom.xml
@@ -42,6 +42,20 @@ under the License.
                        <scope>provided</scope>
                </dependency>
 
+               <dependency>
+                       <groupId>org.apache.flink</groupId>
+                       <artifactId>flink-core</artifactId>
+                       <version>${flink.version}</version>
+                       <scope>provided</scope>
+               </dependency>
+
+               <dependency>
+                       <groupId>org.apache.flink</groupId>
+                       <artifactId>flink-connector-base</artifactId>
+                       <version>${flink.version}</version>
+                       <scope>provided</scope>
+               </dependency>
+
                <dependency>
                        <groupId>com.google.cloud</groupId>
                        <artifactId>google-cloud-core</artifactId>
@@ -68,6 +82,28 @@ under the License.
                        <type>test-jar</type>
                </dependency>
 
+               <dependency>
+                       <groupId>org.apache.flink</groupId>
+                       <artifactId>flink-connector-test-utils</artifactId>
+                       <version>${flink.version}</version>
+                       <scope>test</scope>
+                       <exclusions>
+                               <exclusion>
+                                       <groupId>org.apache.curator</groupId>
+                                       <artifactId>curator-test</artifactId>
+                               </exclusion>
+                       </exclusions>
+               </dependency>
+
+               <dependency>
+                       <groupId>org.apache.flink</groupId>
+                       <artifactId>flink-connector-base</artifactId>
+                       <version>${flink.version}</version>
+                       <type>test-jar</type>
+                       <scope>test</scope>
+               </dependency>
+
+
                <dependency>
                        <groupId>org.apache.flink</groupId>
                        <artifactId>flink-runtime</artifactId>
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/GcpWriterClient.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/GcpWriterClient.java
new file mode 100644
index 0000000..1962997
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/GcpWriterClient.java
@@ -0,0 +1,25 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.annotation.Internal;
+
+import com.google.api.core.ApiFuture;
+
+/**
+ * Abstract interface for GCP writer client to decouple dependency of writer 
on publisher
+ * implementation.
+ *
+ * @param <T> The type of the messages to write.
+ */
+@Internal
+public interface GcpWriterClient<T> {
+
+    ApiFuture<String> publish(T var1);
+
+    /**
+     * This method is called to flush all outstanding messages. This is used 
when client is
+     * configured with batching.
+     */
+    void flush();
+
+    void shutdown();
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubException.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubException.java
new file mode 100644
index 0000000..6c14dc3
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubException.java
@@ -0,0 +1,11 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.annotation.PublicEvolving;
+
+/** Exception thrown when an error occurs during PubSub message handling. */
+@PublicEvolving
+public class PubSubException extends RuntimeException {
+    public PubSubException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubExceptionClassifiers.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubExceptionClassifiers.java
new file mode 100644
index 0000000..df518d2
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubExceptionClassifiers.java
@@ -0,0 +1,30 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.connector.base.sink.throwable.FatalExceptionClassifier;
+
+/** Exception classifier to detect Fatal exceptions in PubSub publisher. */
+@Internal
+public class PubSubExceptionClassifiers {
+    private static FatalExceptionClassifier getTopicNotFoundClassifier() {
+        return new FatalExceptionClassifier(
+                e ->
+                        e instanceof io.grpc.StatusRuntimeException
+                                && e.getMessage().contains("NOT_FOUND"),
+                e -> new PubSubException("Topic not found", e));
+    }
+
+    private static FatalExceptionClassifier getProjectNotFoundClassifier() {
+        return new FatalExceptionClassifier(
+                e ->
+                        e instanceof io.grpc.StatusRuntimeException
+                                && e.getMessage()
+                                        .contains("NOT_FOUND: Requested 
project not found"),
+                e -> new PubSubException("Project not found", e));
+    }
+
+    static FatalExceptionClassifier getFatalExceptionClassifier() {
+        return FatalExceptionClassifier.createChain(
+                getProjectNotFoundClassifier(), getTopicNotFoundClassifier());
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2.java
new file mode 100644
index 0000000..2fe9cfe
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2.java
@@ -0,0 +1,96 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.api.connector.sink2.Sink;
+import org.apache.flink.api.connector.sink2.SinkWriter;
+import org.apache.flink.api.connector.sink2.WriterInitContext;
+import org.apache.flink.connector.gcp.pubsub.sink.config.GcpPublisherConfig;
+import org.apache.flink.util.Preconditions;
+
+import java.io.IOException;
+
+/**
+ * A {@link Sink} to produce data into Gcp PubSub. The sink uses the {@link 
GcpPublisherConfig} to
+ * create a PubSub {@link com.google.cloud.pubsub.v1.Publisher} to send 
messages to the specified
+ * topic under a specific project. The sink uses the provided {@link 
SerializationSchema} to
+ * serialize the input elements into PubSub messages.
+ *
+ * <p>The sink is stateless and blocks on completion of inflight requests on 
{@code flush()} and
+ * {@code close()}. The sink also uses {@code maxInFlightRequests} and blocks 
new writes if the
+ * number of inflight requests exceeds the specified limit.
+ *
+ * @param <T> input type for the sink.
+ */
+@PublicEvolving
+public class PubSubSinkV2<T> implements Sink<T> {
+
+    private final String projectId;
+    private final String topicId;
+
+    /** Serialization schema to serialize input elements into PubSub messages. 
*/
+    private final SerializationSchema<T> serializationSchema;
+
+    /** Configuration for the PubSub publisher. */
+    private final GcpPublisherConfig publisherConfig;
+
+    private final int maxInFlightRequests;
+
+    private final boolean failOnError;
+
+    /**
+     * Default PubSub Sink constructor that creates a new {@link PubSubSinkV2} 
with the provided
+     * configurations.
+     *
+     * @param projectId the GCP project ID.
+     * @param topicId the PubSub topic ID.
+     * @param serializationSchema the serialization schema to serialize input 
elements into PubSub
+     *     messages.
+     * @param publisherConfig the configuration for the PubSub publisher.
+     * @param maxInFlightRequests the maximum number of inflight requests.
+     * @param failOnError flag to indicate whether to fail on errors.
+     */
+    public PubSubSinkV2(
+            String projectId,
+            String topicId,
+            SerializationSchema<T> serializationSchema,
+            GcpPublisherConfig publisherConfig,
+            int maxInFlightRequests,
+            boolean failOnError) {
+        Preconditions.checkNotNull(projectId, "Project ID cannot be null.");
+        Preconditions.checkNotNull(topicId, "Topic ID cannot be null.");
+        Preconditions.checkNotNull(publisherConfig, "Connection configs cannot 
be null.");
+        Preconditions.checkNotNull(serializationSchema, "Serialization schema 
cannot be null.");
+        Preconditions.checkArgument(
+                maxInFlightRequests > 0, "Max inflight requests must be 
greater than 0.");
+
+        this.projectId = projectId;
+        this.topicId = topicId;
+        this.serializationSchema = serializationSchema;
+        this.publisherConfig = publisherConfig;
+        this.maxInFlightRequests = maxInFlightRequests;
+        this.failOnError = failOnError;
+    }
+
+    @Override
+    public SinkWriter<T> createWriter(InitContext initContext) throws 
IOException {
+        throw new UnsupportedOperationException(
+                "Deprecated method. Use createWriter(WriterInitContext) 
instead.");
+    }
+
+    @Override
+    public PubSubWriter<T> createWriter(WriterInitContext initContext) throws 
IOException {
+        return new PubSubWriter<>(
+                projectId,
+                topicId,
+                serializationSchema,
+                initContext,
+                publisherConfig,
+                maxInFlightRequests,
+                failOnError);
+    }
+
+    public static <T> PubSubSinkV2Builder<T> builder() {
+        return new PubSubSinkV2Builder<>();
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2Builder.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2Builder.java
new file mode 100644
index 0000000..cdf0132
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2Builder.java
@@ -0,0 +1,94 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.connector.gcp.pubsub.sink.config.GcpPublisherConfig;
+
+import java.util.Optional;
+
+/**
+ * A builder for creating a {@link PubSubSinkV2}.
+ *
+ * <p>The builder uses the following parameters to build a {@link 
PubSubSinkV2}:
+ *
+ * <ul>
+ *   <li>{@link GcpPublisherConfig} for the {@link 
com.google.cloud.pubsub.v1.Publisher}
+ *       configuration.
+ *   <li>{@link SerializationSchema} for serializing the input data.
+ *   <li>{@code projectId} for the name of the project where the topic is 
located.
+ *   <li>{@code topicId} for the name of the topic to send messages to.
+ *   <li>{@code maximumInflightMessages} for the maximum number of inflight 
messages.
+ *   <li>{@code failOnError} for whether to fail on an error.
+ * </ul>
+ *
+ * <p>It can be used as follows:
+ *
+ * <pre>{@code
+ * PubSubSinkV2Builder<String> pubSubSink = {@code 
PubSubSinkV2Builder}.<String>builder()
+ *     .setProjectId("project-id")
+ *     .setTopicId("topic-id)
+ *     .setGcpPublisherConfig(gcpPublisherConfig)
+ *     .setSerializationSchema(new SimpleStringSchema())
+ *     .setMaximumInflightMessages(10)
+ *     .setFailOnError(true)
+ *     .build();
+ *
+ * }</pre>
+ *
+ * @param <T>
+ */
+@PublicEvolving
+public class PubSubSinkV2Builder<T> {
+
+    public static final int DEFAULT_MAXIMUM_INFLIGHT_REQUESTS = 1000;
+
+    public static final boolean DEFAULT_FAIL_ON_ERROR = false;
+    private String projectId;
+    private String topicId;
+    private SerializationSchema<T> serializationSchema;
+    private GcpPublisherConfig gcpPublisherConfig;
+    private Integer numMaxInflightRequests;
+    private Boolean failOnError;
+
+    public PubSubSinkV2Builder<T> setProjectId(String projectId) {
+        this.projectId = projectId;
+        return this;
+    }
+
+    public PubSubSinkV2Builder<T> setTopicId(String topicId) {
+        this.topicId = topicId;
+        return this;
+    }
+
+    public PubSubSinkV2Builder<T> setSerializationSchema(
+            SerializationSchema<T> serializationSchema) {
+        this.serializationSchema = serializationSchema;
+        return this;
+    }
+
+    public PubSubSinkV2Builder<T> setGcpPublisherConfig(GcpPublisherConfig 
gcpPublisherConfig) {
+        this.gcpPublisherConfig = gcpPublisherConfig;
+        return this;
+    }
+
+    public PubSubSinkV2Builder<T> setNumMaxInflightRequests(int 
numMaxInflightRequests) {
+        this.numMaxInflightRequests = numMaxInflightRequests;
+        return this;
+    }
+
+    public PubSubSinkV2Builder<T> setFailOnError(boolean failOnError) {
+        this.failOnError = failOnError;
+        return this;
+    }
+
+    public PubSubSinkV2<T> build() {
+        return new PubSubSinkV2<>(
+                projectId,
+                topicId,
+                serializationSchema,
+                gcpPublisherConfig,
+                Optional.ofNullable(numMaxInflightRequests)
+                        .orElse(DEFAULT_MAXIMUM_INFLIGHT_REQUESTS),
+                
Optional.ofNullable(failOnError).orElse(DEFAULT_FAIL_ON_ERROR));
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriter.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriter.java
new file mode 100644
index 0000000..f42f42c
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriter.java
@@ -0,0 +1,208 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.annotation.Internal;
+import org.apache.flink.annotation.VisibleForTesting;
+import org.apache.flink.api.common.operators.MailboxExecutor;
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.api.connector.sink2.SinkWriter;
+import org.apache.flink.api.connector.sink2.WriterInitContext;
+import org.apache.flink.connector.gcp.pubsub.sink.config.GcpPublisherConfig;
+import org.apache.flink.metrics.Counter;
+import org.apache.flink.util.FlinkRuntimeException;
+import org.apache.flink.util.Preconditions;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutureCallback;
+import com.google.api.core.ApiFutures;
+import com.google.cloud.pubsub.v1.Publisher;
+import com.google.protobuf.ByteString;
+import com.google.pubsub.v1.PubsubMessage;
+import com.google.pubsub.v1.TopicName;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Optional;
+
+import static org.apache.flink.util.concurrent.Executors.directExecutor;
+
+/**
+ * A stateless {@link SinkWriter} that writes records to PubSub using generic 
{@link
+ * GcpWriterClient}. The writer blocks on completion of inflight requests on 
{@code flush()} and
+ * {@code close()}. The writer also uses {@code maxInFlightRequests} and 
blocks new writes if the
+ * number of inflight requests exceeds the specified limit.
+ *
+ * @param <T> The type of the records .
+ */
+@Internal
+public class PubSubWriter<T> implements SinkWriter<T> {
+    private static final Logger LOG = 
LoggerFactory.getLogger(PubSubWriter.class);
+
+    /** The PubSub generic client to publish messages. */
+    private final GcpWriterClient<PubsubMessage> publisher;
+
+    /**
+     * The maximum number of inflight requests, The writer blocks new writes 
if the number of
+     * inflight requests exceeds the specified limit.
+     */
+    private final int maximumInflightRequests;
+
+    /**
+     * Flag to indicate whether to fail on errors, if unset the writer will 
retry non-fatal request
+     * failures.
+     */
+    private final boolean failOnError;
+
+    private int inflightRequests = 0;
+
+    private final MailboxExecutor mailboxExecutor;
+    private final Counter numBytesOutCounter;
+    private final Counter numRecordsOutCounter;
+    private final Counter numRecordsOutErrorCounter;
+    private final SerializationSchema<T> serializationSchema;
+
+    PubSubWriter(
+            String projectId,
+            String topicId,
+            SerializationSchema<T> serializationSchema,
+            WriterInitContext context,
+            GcpPublisherConfig publisherConfig,
+            int maximumInflightRequests,
+            boolean failOnError)
+            throws IOException {
+        this(
+                createPublisher(projectId, topicId, publisherConfig),
+                context,
+                serializationSchema,
+                maximumInflightRequests,
+                failOnError);
+    }
+
+    @VisibleForTesting
+    PubSubWriter(
+            GcpWriterClient<PubsubMessage> publisher,
+            WriterInitContext context,
+            SerializationSchema<T> serializationSchema,
+            int maximumInflightRequests,
+            boolean failOnError) {
+        this.publisher = Preconditions.checkNotNull(publisher);
+        this.serializationSchema = 
Preconditions.checkNotNull(serializationSchema);
+        Preconditions.checkNotNull(context, "Context cannot be null.");
+
+        this.mailboxExecutor = context.getMailboxExecutor();
+        this.numBytesOutCounter = 
context.metricGroup().getIOMetricGroup().getNumBytesOutCounter();
+        this.numRecordsOutCounter =
+                
context.metricGroup().getIOMetricGroup().getNumRecordsOutCounter();
+        this.numRecordsOutErrorCounter = 
context.metricGroup().getNumRecordsOutErrorsCounter();
+        this.maximumInflightRequests = maximumInflightRequests;
+        this.failOnError = failOnError;
+    }
+
+    @Override
+    public void write(T t, SinkWriter.Context context) throws IOException, 
InterruptedException {
+        awaitMaxInflightRequestsBelow(maximumInflightRequests);
+        PubsubMessage message =
+                PubsubMessage.newBuilder()
+                        
.setData(ByteString.copyFrom(serializationSchema.serialize(t)))
+                        .build();
+        publishMessage(message);
+    }
+
+    @Override
+    public void flush(boolean b) throws IOException, InterruptedException {
+        publisher.flush();
+        awaitMaxInflightRequestsBelow(1);
+    }
+
+    private void awaitMaxInflightRequestsBelow(long maxInflightRequests)
+            throws InterruptedException {
+        /* Block until inflight callbacks are executed. We use this mechanism 
to introduce
+        backpressure when inflight requests exceed the maximum allowed and to 
ensure that
+        snapshot barriers are not sent before inflight requests are completed. 
*/
+        while (inflightRequests >= maxInflightRequests) {
+            mailboxExecutor.yield();
+        }
+    }
+
+    private void publishMessage(PubsubMessage message) {
+        ApiFuture<String> future = publisher.publish(message);
+        inflightRequests++;
+        LOG.debug("Publishing message with id {}", message.getMessageId());
+        ApiFutures.addCallback(
+                future,
+                new ApiFutureCallback<String>() {
+                    @Override
+                    public void onFailure(Throwable throwable) {
+                        mailboxExecutor.execute(
+                                () -> failMessage(message, throwable),
+                                "Handling failure of PubSub message 
publishing.");
+                    }
+
+                    @Override
+                    public void onSuccess(String ignored) {
+                        mailboxExecutor.execute(
+                                () -> completeMessage(message),
+                                "Handling success of PubSub message 
publishing.");
+                    }
+                },
+                directExecutor());
+    }
+
+    private void completeMessage(PubsubMessage message) {
+        LOG.debug("Delivered message with id {}", message.getMessageId());
+        inflightRequests--;
+        numBytesOutCounter.inc(message.getSerializedSize());
+        numRecordsOutCounter.inc();
+    }
+
+    private void failMessage(PubsubMessage message, Throwable throwable) {
+        LOG.warn(
+                String.format(
+                        "Failed to publish message with id %s to PubSub", 
message.getMessageId()),
+                throwable);
+        inflightRequests--;
+        numRecordsOutErrorCounter.inc();
+        raiseIfFatal(throwable);
+
+        if (failOnError) {
+            throw new FlinkRuntimeException("Failed to publish message to 
PubSub", throwable);
+        }
+
+        publishMessage(message);
+    }
+
+    @Override
+    public void close() throws Exception {
+        LOG.info("Closing PubSub writer.");
+        publisher.shutdown();
+    }
+
+    private void raiseIfFatal(Throwable throwable) {
+        PubSubExceptionClassifiers.getFatalExceptionClassifier()
+                .isFatal(
+                        throwable,
+                        e ->
+                                mailboxExecutor.execute(
+                                        () -> {
+                                            throw e;
+                                        },
+                                        "Raise fatal exception."));
+    }
+
+    private static PubSubWriterClient createPublisher(
+            String projectId, String topicId, GcpPublisherConfig 
publisherConfig)
+            throws IOException {
+        Publisher.Builder builder = 
Publisher.newBuilder(TopicName.of(projectId, topicId));
+        
builder.setCredentialsProvider(publisherConfig.getCredentialsProvider());
+        Optional.ofNullable(publisherConfig.getBatchingSettings())
+                .ifPresent(builder::setBatchingSettings);
+        Optional.ofNullable(publisherConfig.getRetrySettings())
+                .ifPresent(builder::setRetrySettings);
+        Optional.ofNullable(publisherConfig.getTransportChannelProvider())
+                .ifPresent(builder::setChannelProvider);
+        Optional.ofNullable(publisherConfig.getEnableCompression())
+                .ifPresent(builder::setEnableCompression);
+
+        return PubSubWriterClient.of(builder.build());
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriterClient.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriterClient.java
new file mode 100644
index 0000000..6b12103
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriterClient.java
@@ -0,0 +1,37 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.annotation.Internal;
+
+import com.google.api.core.ApiFuture;
+import com.google.cloud.pubsub.v1.Publisher;
+import com.google.pubsub.v1.PubsubMessage;
+
+/** A {@link GcpWriterClient} implementation for PubSub {@link Publisher}. */
+@Internal
+public class PubSubWriterClient implements GcpWriterClient<PubsubMessage> {
+
+    private final Publisher publisher;
+
+    private PubSubWriterClient(Publisher publisher) {
+        this.publisher = publisher;
+    }
+
+    @Override
+    public void flush() {
+        publisher.publishAllOutstanding();
+    }
+
+    @Override
+    public void shutdown() {
+        publisher.shutdown();
+    }
+
+    @Override
+    public ApiFuture<String> publish(PubsubMessage pubsubMessage) {
+        return publisher.publish(pubsubMessage);
+    }
+
+    public static PubSubWriterClient of(Publisher publisher) {
+        return new PubSubWriterClient(publisher);
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/config/GcpPublisherConfig.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/config/GcpPublisherConfig.java
new file mode 100644
index 0000000..e4bebd2
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/config/GcpPublisherConfig.java
@@ -0,0 +1,113 @@
+package org.apache.flink.connector.gcp.pubsub.sink.config;
+
+import org.apache.flink.annotation.PublicEvolving;
+import org.apache.flink.util.Preconditions;
+
+import com.google.api.gax.batching.BatchingSettings;
+import com.google.api.gax.core.CredentialsProvider;
+import com.google.api.gax.retrying.RetrySettings;
+import com.google.api.gax.rpc.TransportChannelProvider;
+
+import java.io.Serializable;
+
+/** Configuration keys for {@link com.google.cloud.pubsub.v1.Publisher}. */
+@PublicEvolving
+public class GcpPublisherConfig implements Serializable {
+
+    private final RetrySettings retrySettings;
+
+    private final BatchingSettings batchingSettings;
+
+    private final CredentialsProvider credentialsProvider;
+
+    private final SerializableTransportChannelProvider 
transportChannelProvider;
+
+    private final Boolean enableCompression;
+
+    public GcpPublisherConfig(
+            RetrySettings retrySettings,
+            BatchingSettings batchingSettings,
+            CredentialsProvider credentialsProvider,
+            SerializableTransportChannelProvider transportChannelProvider,
+            Boolean enableCompression) {
+        Preconditions.checkNotNull(credentialsProvider, "Credentials provider 
cannot be null");
+        this.retrySettings = retrySettings;
+        this.batchingSettings = batchingSettings;
+        this.credentialsProvider = credentialsProvider;
+        this.transportChannelProvider = transportChannelProvider;
+        this.enableCompression = enableCompression;
+    }
+
+    public RetrySettings getRetrySettings() {
+        return retrySettings;
+    }
+
+    public BatchingSettings getBatchingSettings() {
+        return batchingSettings;
+    }
+
+    public CredentialsProvider getCredentialsProvider() {
+        return credentialsProvider;
+    }
+
+    public TransportChannelProvider getTransportChannelProvider() {
+        return transportChannelProvider.getTransportChannelProvider();
+    }
+
+    public Boolean getEnableCompression() {
+        return enableCompression;
+    }
+
+    public static Builder builder() {
+        return new Builder();
+    }
+
+    /** Builder for {@link GcpPublisherConfig}. */
+    public static class Builder {
+
+        private RetrySettings retrySettings;
+
+        private BatchingSettings batchingSettings;
+
+        private CredentialsProvider credentialsProvider;
+
+        private SerializableTransportChannelProvider transportChannelProvider;
+
+        private Boolean enableCompression;
+
+        public Builder setRetrySettings(RetrySettings retrySettings) {
+            this.retrySettings = retrySettings;
+            return this;
+        }
+
+        public Builder setBatchingSettings(BatchingSettings batchingSettings) {
+            this.batchingSettings = batchingSettings;
+            return this;
+        }
+
+        public Builder setCredentialsProvider(CredentialsProvider 
credentialsProvider) {
+            this.credentialsProvider = credentialsProvider;
+            return this;
+        }
+
+        public Builder setTransportChannelProvider(
+                SerializableTransportChannelProvider transportChannelProvider) 
{
+            this.transportChannelProvider = transportChannelProvider;
+            return this;
+        }
+
+        public Builder setEnableCompression(boolean enableCompression) {
+            this.enableCompression = enableCompression;
+            return this;
+        }
+
+        public GcpPublisherConfig build() {
+            return new GcpPublisherConfig(
+                    retrySettings,
+                    batchingSettings,
+                    credentialsProvider,
+                    transportChannelProvider,
+                    enableCompression);
+        }
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/config/SerializableTransportChannelProvider.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/config/SerializableTransportChannelProvider.java
new file mode 100644
index 0000000..97fc189
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/connector/gcp/pubsub/sink/config/SerializableTransportChannelProvider.java
@@ -0,0 +1,26 @@
+package org.apache.flink.connector.gcp.pubsub.sink.config;
+
+import org.apache.flink.annotation.PublicEvolving;
+
+import com.google.api.gax.rpc.TransportChannelProvider;
+
+import java.io.Serializable;
+
+/**
+ * A serializable transport channel provider for {@link
+ * org.apache.flink.connector.gcp.pubsub.sink.GcpWriterClient}.
+ */
+@PublicEvolving
+public abstract class SerializableTransportChannelProvider implements 
Serializable {
+
+    protected transient TransportChannelProvider transportChannelProvider;
+
+    protected abstract void open();
+
+    public TransportChannelProvider getTransportChannelProvider() {
+        if (transportChannelProvider == null) {
+            open();
+        }
+        return transportChannelProvider;
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/streaming/connectors/gcp/pubsub/PubSubSink.java
 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/streaming/connectors/gcp/pubsub/PubSubSink.java
index a12feab..8c7adc3 100644
--- 
a/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/streaming/connectors/gcp/pubsub/PubSubSink.java
+++ 
b/flink-connector-gcp-pubsub/src/main/java/org/apache/flink/streaming/connectors/gcp/pubsub/PubSubSink.java
@@ -62,6 +62,7 @@ import static 
org.apache.flink.util.concurrent.Executors.directExecutor;
  *
  * @param <IN> type of PubSubSink messages to write
  */
+@Deprecated
 public class PubSubSink<IN> extends RichSinkFunction<IN> implements 
CheckpointedFunction {
     private static final Logger LOG = 
LoggerFactory.getLogger(PubSubSink.class);
 
diff --git 
a/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2BuilderTest.java
 
b/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2BuilderTest.java
new file mode 100644
index 0000000..acca81f
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubSinkV2BuilderTest.java
@@ -0,0 +1,150 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.api.common.serialization.SerializationSchema;
+import org.apache.flink.api.common.serialization.SimpleStringSchema;
+import org.apache.flink.connector.gcp.pubsub.sink.config.GcpPublisherConfig;
+
+import com.google.api.gax.core.NoCredentialsProvider;
+import org.junit.jupiter.api.Test;
+
+import static 
org.apache.flink.connector.gcp.pubsub.sink.PubSubSinkV2Builder.DEFAULT_FAIL_ON_ERROR;
+import static 
org.apache.flink.connector.gcp.pubsub.sink.PubSubSinkV2Builder.DEFAULT_MAXIMUM_INFLIGHT_REQUESTS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/** Tests for {@link PubSubSinkV2Builder}. */
+class PubSubSinkV2BuilderTest {
+
+    @Test
+    void builderBuildsSinkWithCorrectProperties() {
+        PubSubSinkV2Builder<String> builder = PubSubSinkV2.<String>builder();
+        GcpPublisherConfig gcpPublisherConfig =
+                GcpPublisherConfig.builder()
+                        .setCredentialsProvider(NoCredentialsProvider.create())
+                        .build();
+
+        SerializationSchema<String> serializationSchema = new 
SimpleStringSchema();
+
+        builder.setProjectId("test-project-id")
+                .setTopicId("test-topic-id")
+                .setGcpPublisherConfig(gcpPublisherConfig)
+                .setSerializationSchema(serializationSchema)
+                .setNumMaxInflightRequests(10)
+                .setFailOnError(true);
+        PubSubSinkV2<String> sink = builder.build();
+
+        assertThat(sink).hasFieldOrPropertyWithValue("projectId", 
"test-project-id");
+        assertThat(sink).hasFieldOrPropertyWithValue("topicId", 
"test-topic-id");
+        assertThat(sink).hasFieldOrPropertyWithValue("serializationSchema", 
serializationSchema);
+        assertThat(sink).hasFieldOrPropertyWithValue("publisherConfig", 
gcpPublisherConfig);
+        assertThat(sink).hasFieldOrPropertyWithValue("maxInFlightRequests", 
10);
+        assertThat(sink).hasFieldOrPropertyWithValue("failOnError", true);
+    }
+
+    @Test
+    void builderBuildsSinkWithDefaultProperties() {
+        PubSubSinkV2Builder<String> builder = PubSubSinkV2.<String>builder();
+        SerializationSchema<String> serializationSchema = new 
SimpleStringSchema();
+        GcpPublisherConfig gcpPublisherConfig =
+                GcpPublisherConfig.builder()
+                        .setCredentialsProvider(NoCredentialsProvider.create())
+                        .build();
+
+        builder.setProjectId("test-project-id")
+                .setTopicId("test-topic-id")
+                .setGcpPublisherConfig(gcpPublisherConfig)
+                .setSerializationSchema(serializationSchema);
+        PubSubSinkV2<String> sink = builder.build();
+
+        assertThat(sink)
+                .hasFieldOrPropertyWithValue(
+                        "maxInFlightRequests", 
DEFAULT_MAXIMUM_INFLIGHT_REQUESTS);
+        assertThat(sink).hasFieldOrPropertyWithValue("failOnError", 
DEFAULT_FAIL_ON_ERROR);
+    }
+
+    @Test
+    void builderThrowsNullPointerExceptionOnNullProjectId() {
+        PubSubSinkV2Builder<String> builder = new PubSubSinkV2Builder<>();
+        SerializationSchema<String> serializationSchema = new 
SimpleStringSchema();
+        GcpPublisherConfig gcpPublisherConfig =
+                GcpPublisherConfig.builder()
+                        .setCredentialsProvider(NoCredentialsProvider.create())
+                        .build();
+
+        builder.setTopicId("test-topic-id")
+                .setGcpPublisherConfig(gcpPublisherConfig)
+                .setSerializationSchema(serializationSchema);
+
+        assertThatExceptionOfType(NullPointerException.class)
+                .isThrownBy(builder::build)
+                .withMessage("Project ID cannot be null.");
+    }
+
+    @Test
+    void builderThrowsNullPointerExceptionOnNullTopicId() {
+        PubSubSinkV2Builder<String> builder = PubSubSinkV2.<String>builder();
+        SerializationSchema<String> serializationSchema = new 
SimpleStringSchema();
+        GcpPublisherConfig gcpPublisherConfig =
+                GcpPublisherConfig.builder()
+                        .setCredentialsProvider(NoCredentialsProvider.create())
+                        .build();
+
+        builder.setProjectId("test-project-id")
+                .setGcpPublisherConfig(gcpPublisherConfig)
+                .setSerializationSchema(serializationSchema);
+
+        assertThatExceptionOfType(NullPointerException.class)
+                .isThrownBy(builder::build)
+                .withMessage("Topic ID cannot be null.");
+    }
+
+    @Test
+    void builderThrowsNullPointerExceptionOnNullGcpPublisherConfig() {
+        PubSubSinkV2Builder<String> builder = PubSubSinkV2.<String>builder();
+        SerializationSchema<String> serializationSchema = new 
SimpleStringSchema();
+
+        builder.setProjectId("test-project-id")
+                .setTopicId("test-topic-id")
+                .setSerializationSchema(serializationSchema);
+
+        assertThatExceptionOfType(NullPointerException.class)
+                .isThrownBy(builder::build)
+                .withMessage("Connection configs cannot be null.");
+    }
+
+    @Test
+    void builderThrowsNullPointerExceptionOnNullSerializationSchema() {
+        PubSubSinkV2Builder<String> builder = PubSubSinkV2.<String>builder();
+        GcpPublisherConfig gcpPublisherConfig =
+                GcpPublisherConfig.builder()
+                        .setCredentialsProvider(NoCredentialsProvider.create())
+                        .build();
+
+        builder.setProjectId("test-project-id")
+                .setTopicId("test-topic-id")
+                .setGcpPublisherConfig(gcpPublisherConfig);
+
+        assertThatExceptionOfType(NullPointerException.class)
+                .isThrownBy(builder::build)
+                .withMessage("Serialization schema cannot be null.");
+    }
+
+    @Test
+    void 
builderThrowsIllegalArgumentExceptionWhenUsingNegativeInflightRequests() {
+        PubSubSinkV2Builder<String> builder = PubSubSinkV2.<String>builder();
+        GcpPublisherConfig gcpPublisherConfig =
+                GcpPublisherConfig.builder()
+                        .setCredentialsProvider(NoCredentialsProvider.create())
+                        .build();
+
+        builder.setProjectId("test-project-id")
+                .setTopicId("test-topic-id")
+                .setGcpPublisherConfig(gcpPublisherConfig)
+                .setSerializationSchema(new SimpleStringSchema())
+                .setNumMaxInflightRequests(-1);
+
+        assertThatExceptionOfType(IllegalArgumentException.class)
+                .isThrownBy(builder::build)
+                .withMessage("Max inflight requests must be greater than 0.");
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriterTest.java
 
b/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriterTest.java
new file mode 100644
index 0000000..52b4a64
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/PubSubWriterTest.java
@@ -0,0 +1,246 @@
+package org.apache.flink.connector.gcp.pubsub.sink;
+
+import org.apache.flink.api.common.serialization.SimpleStringSchema;
+import org.apache.flink.api.connector.sink2.WriterInitContext;
+import org.apache.flink.connector.base.sink.writer.TestSinkInitContext;
+import 
org.apache.flink.connector.base.sink.writer.TestSinkInitContextAnyThreadMailbox;
+import org.apache.flink.connector.gcp.pubsub.sink.util.TestGcpWriterClient;
+import org.apache.flink.metrics.Counter;
+import org.apache.flink.util.FlinkRuntimeException;
+
+import com.google.protobuf.ByteString;
+import com.google.pubsub.v1.PubsubMessage;
+import io.grpc.StatusRuntimeException;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+
+/** Tests for {@link PubSubWriter}. */
+class PubSubWriterTest {
+
+    private static final int MAXIMUM_INFLIGHT_MESSAGES = 3;
+
+    @Test
+    void writeMessageDeliversMessageUsingClient() throws IOException, 
InterruptedException {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+        PubSubWriter<String> writer =
+                getDefaultWriter(client, new TestSinkInitContext(), 
MAXIMUM_INFLIGHT_MESSAGES);
+        String message = "test-message";
+        writer.write(message, null);
+        client.deliverMessage(message);
+        writer.flush(false);
+
+        assertThat(client.getDeliveredMessages()).containsExactly(message);
+    }
+
+    @Test
+    void writeMessageIncrementsMetricsOnDelivery() throws IOException, 
InterruptedException {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+        WriterInitContext context = new TestSinkInitContext();
+
+        PubSubWriter<String> writer = getDefaultWriter(client, context, 
MAXIMUM_INFLIGHT_MESSAGES);
+        String message = "test-message";
+
+        // write message
+        writer.write(message, null);
+
+        // get metrics before delivery
+        Counter numBytesOutCounter = 
context.metricGroup().getNumBytesSendCounter();
+        Counter numRecordsSendCounter = 
context.metricGroup().getNumRecordsSendCounter();
+        long recordsSentBeforeDelivery = numRecordsSendCounter.getCount();
+        long bytesSentBeforeDelivery = numBytesOutCounter.getCount();
+
+        // deliver message
+        client.deliverMessage(message);
+        writer.flush(false);
+
+        long messageSize =
+                PubsubMessage.newBuilder()
+                        .setData(ByteString.copyFromUtf8(message))
+                        .build()
+                        .getSerializedSize();
+
+        assertThat(recordsSentBeforeDelivery).isEqualTo(0);
+        assertThat(bytesSentBeforeDelivery).isEqualTo(0);
+        assertThat(numRecordsSendCounter.getCount()).isEqualTo(1);
+        assertThat(numBytesOutCounter.getCount()).isEqualTo(messageSize);
+    }
+
+    @Test
+    void writerFlushesPublisherOnFlush() throws IOException, 
InterruptedException {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+        PubSubWriter<String> writer =
+                getDefaultWriter(client, new TestSinkInitContext(), 
MAXIMUM_INFLIGHT_MESSAGES);
+        writer.write("test-message", null);
+        writer.flush(false);
+        assertThat(client.isFlushed()).isTrue();
+    }
+
+    @Test
+    void writeMessageDoesNotBlockBeforeMaximumInflight() throws IOException, 
InterruptedException {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+
+        PubSubWriter<String> writer =
+                getDefaultWriter(client, new 
TestSinkInitContextAnyThreadMailbox(), 2);
+
+        String firstMessage = "first-message";
+        String secondMessage = "second-message";
+        CountDownLatch finsishedLatch1 = new CountDownLatch(1);
+        CountDownLatch hasStarted1 = new CountDownLatch(1);
+        CountDownLatch finsishedLatch2 = new CountDownLatch(1);
+        CountDownLatch hasStarted2 = new CountDownLatch(1);
+
+        writeMessageToWriterAsync(writer, firstMessage, hasStarted1, 
finsishedLatch1);
+        writeMessageToWriterAsync(writer, secondMessage, hasStarted2, 
finsishedLatch2);
+        Boolean firstMessageAttemptSent =
+                hasStarted1.await(1, java.util.concurrent.TimeUnit.SECONDS);
+        Boolean secondMessageAttemptSent =
+                hasStarted2.await(1, java.util.concurrent.TimeUnit.SECONDS);
+        Boolean firstMessageSent = finsishedLatch1.await(1, 
java.util.concurrent.TimeUnit.SECONDS);
+        Boolean secondMessageSent = finsishedLatch2.await(1, 
java.util.concurrent.TimeUnit.SECONDS);
+
+        client.deliverMessage(firstMessage);
+        client.deliverMessage(secondMessage);
+        writer.flush(false);
+
+        assertThat(firstMessageAttemptSent).isTrue();
+        assertThat(secondMessageAttemptSent).isTrue();
+        assertThat(firstMessageSent).isTrue();
+        assertThat(secondMessageSent).isTrue();
+        assertThat(client.getDeliveredMessages())
+                .containsExactlyInAnyOrder(firstMessage, secondMessage);
+    }
+
+    @Test
+    void writeMessageBlocksAfterMaximumInflight() throws IOException, 
InterruptedException {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+        PubSubWriter<String> writer =
+                getDefaultWriter(client, new 
TestSinkInitContextAnyThreadMailbox(), 1);
+
+        String firstMessage = "first-message";
+        String secondMessage = "second-message";
+        CountDownLatch latch1 = new CountDownLatch(1);
+        CountDownLatch hasStarted1 = new CountDownLatch(1);
+        CountDownLatch latch2 = new CountDownLatch(1);
+        CountDownLatch hasStarted2 = new CountDownLatch(1);
+
+        writeMessageToWriterAsync(writer, firstMessage, hasStarted1, latch1);
+        Boolean firstMessageAttemptSent =
+                hasStarted1.await(1, java.util.concurrent.TimeUnit.SECONDS);
+        Boolean firstMessageSent = latch1.await(1, 
java.util.concurrent.TimeUnit.SECONDS);
+
+        writeMessageToWriterAsync(writer, secondMessage, hasStarted2, latch2);
+        Boolean secondMessageAttemptSent =
+                hasStarted2.await(1, java.util.concurrent.TimeUnit.SECONDS);
+        Boolean secondMessageBlocked = !latch2.await(1, 
java.util.concurrent.TimeUnit.SECONDS);
+
+        client.deliverMessage(firstMessage);
+
+        Boolean secondMessageSent = latch2.await(1, 
java.util.concurrent.TimeUnit.SECONDS);
+
+        client.deliverMessage(secondMessage);
+        writer.flush(false);
+
+        assertThat(firstMessageAttemptSent).isTrue();
+        assertThat(secondMessageAttemptSent).isTrue();
+        assertThat(firstMessageSent).isTrue();
+        assertThat(secondMessageBlocked).isTrue();
+        assertThat(secondMessageSent).isTrue();
+        
assertThat(client.getDeliveredMessages()).containsExactly(firstMessage, 
secondMessage);
+    }
+
+    @Test
+    void writerRetriesOnFailureIfFailOnErrorIsUnset() throws IOException, 
InterruptedException {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+        client.setExceptionSupplier(message -> new 
StatusRuntimeException(io.grpc.Status.INTERNAL));
+
+        PubSubWriter<String> writer =
+                getDefaultWriter(client, new TestSinkInitContext(), 
MAXIMUM_INFLIGHT_MESSAGES);
+        String message = "fail-message";
+        writer.write(message, null);
+        client.deliverMessage(message);
+        writer.flush(false);
+
+        assertThat(client.failedInitially(message)).isTrue();
+        assertThat(client.getDeliveredMessages()).containsExactly(message);
+    }
+
+    @Test
+    void writerFailsOnErrorIfFailOnErrorIsSet() throws IOException, 
InterruptedException {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+        client.setExceptionSupplier(message -> new 
StatusRuntimeException(io.grpc.Status.INTERNAL));
+        PubSubWriter<String> writer =
+                new PubSubWriter<>(
+                        client,
+                        new TestSinkInitContext(),
+                        new SimpleStringSchema(),
+                        MAXIMUM_INFLIGHT_MESSAGES,
+                        true);
+        String message = "fail-message";
+        writer.write(message, null);
+
+        assertThatExceptionOfType(FlinkRuntimeException.class)
+                .isThrownBy(() -> writer.flush(false))
+                .withMessage("Failed to publish message to PubSub")
+                .withCauseInstanceOf(StatusRuntimeException.class);
+    }
+
+    @Test
+    void writerFailsOnProjectNotFoundExceptionWithFailOnErrorFalse()
+            throws IOException, InterruptedException {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+        client.setExceptionSupplier(
+                message -> new 
StatusRuntimeException(io.grpc.Status.NOT_FOUND));
+        PubSubWriter<String> writer =
+                new PubSubWriter<>(
+                        client,
+                        new TestSinkInitContext(),
+                        new SimpleStringSchema(),
+                        MAXIMUM_INFLIGHT_MESSAGES,
+                        false);
+        String message = "fail-message";
+        writer.write(message, null);
+
+        assertThatExceptionOfType(PubSubException.class)
+                .isThrownBy(() -> writer.flush(false))
+                .withMessage("Topic not found")
+                .withCauseInstanceOf(StatusRuntimeException.class);
+    }
+
+    @Test
+    void closeWriterClosesChannelAndConnection() throws Exception {
+        TestGcpWriterClient client = new TestGcpWriterClient();
+        PubSubWriter<String> writer =
+                getDefaultWriter(client, new TestSinkInitContext(), 
MAXIMUM_INFLIGHT_MESSAGES);
+        writer.close();
+        assertThat(client.isShutdown()).isTrue();
+    }
+
+    private void writeMessageToWriterAsync(
+            PubSubWriter<String> writer,
+            String message,
+            CountDownLatch hasStarted,
+            CountDownLatch finishedLatch) {
+        Thread thread =
+                new Thread(
+                        () -> {
+                            hasStarted.countDown();
+                            try {
+                                writer.write(message, null);
+                            } catch (IOException | InterruptedException 
ignored) {
+                            }
+                            finishedLatch.countDown();
+                        });
+        thread.start();
+    }
+
+    private PubSubWriter<String> getDefaultWriter(
+            TestGcpWriterClient client, WriterInitContext context, int 
maximumInflightRequests) {
+        return new PubSubWriter<>(
+                client, context, new SimpleStringSchema(), 
maximumInflightRequests, false);
+    }
+}
diff --git 
a/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/util/TestGcpWriterClient.java
 
b/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/util/TestGcpWriterClient.java
new file mode 100644
index 0000000..5a0378e
--- /dev/null
+++ 
b/flink-connector-gcp-pubsub/src/test/java/org/apache/flink/connector/gcp/pubsub/sink/util/TestGcpWriterClient.java
@@ -0,0 +1,185 @@
+package org.apache.flink.connector.gcp.pubsub.sink.util;
+
+import org.apache.flink.connector.gcp.pubsub.sink.GcpWriterClient;
+import org.apache.flink.util.concurrent.FutureUtils;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.pubsub.v1.PubsubMessage;
+import io.grpc.StatusRuntimeException;
+
+import java.util.HashSet;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Function;
+
+/**
+ * A test implementation of {@link GcpWriterClient} that allows for simulating 
the delivery of
+ * messages.
+ */
+public class TestGcpWriterClient implements GcpWriterClient<PubsubMessage> {
+
+    private final Executor executor = Executors.newSingleThreadExecutor();
+    private boolean isShutdown = false;
+
+    private boolean isFlushed = false;
+
+    private BlockingQueue<String> deliveredMessages = new 
ArrayBlockingQueue<>(100);
+
+    private final ConcurrentHashMap<String, CountDownLatch> messageLatchMap =
+            new ConcurrentHashMap<>();
+
+    private final ConcurrentHashMap<String, ApiFuture<String>> 
messageFutureMap =
+            new ConcurrentHashMap<>();
+
+    private final HashSet<String> previouslyFailedMessages = new HashSet<>();
+
+    private Function<String, StatusRuntimeException> exceptionSupplier;
+
+    @Override
+    public ApiFuture<String> publish(PubsubMessage message) {
+        String messageString = message.getData().toStringUtf8();
+        messageLatchMap.put(messageString, new CountDownLatch(1));
+
+        // if this is a retry succeed immediately
+        if (previouslyFailedMessages.contains(messageString)) {
+            deliveredMessages.add(messageString);
+            return ApiFutures.immediateFuture("OK");
+        }
+
+        TestApiFuture future = new TestApiFuture(message, exceptionSupplier);
+        messageFutureMap.put(messageString, future);
+        return future;
+    }
+
+    @Override
+    public void flush() {
+        isFlushed = true;
+        for (String message : messageLatchMap.keySet()) {
+            messageLatchMap.get(message).countDown();
+        }
+        for (String message : messageFutureMap.keySet()) {
+            awaitMessage(message);
+        }
+    }
+
+    @Override
+    public void shutdown() {
+        isShutdown = true;
+    }
+
+    public void deliverMessage(String message) {
+        messageLatchMap.get(message).countDown();
+    }
+
+    public void awaitMessage(String message) {
+        if (!messageLatchMap.containsKey(message)) {
+            return;
+        }
+
+        ApiFuture<String> future = messageFutureMap.get(message);
+        try {
+            future.get();
+        } catch (InterruptedException | ExecutionException ignored) {
+        }
+    }
+
+    public void setExceptionSupplier(Function<String, StatusRuntimeException> 
exceptionSupplier) {
+        this.exceptionSupplier = exceptionSupplier;
+    }
+
+    public boolean failedInitially(String message) {
+        return previouslyFailedMessages.contains(message);
+    }
+
+    public boolean isShutdown() {
+        return isShutdown;
+    }
+
+    public boolean isFlushed() {
+        return isFlushed;
+    }
+
+    public BlockingQueue<String> getDeliveredMessages() {
+        return deliveredMessages;
+    }
+
+    /**
+     * A test implementation of {@link ApiFuture} that allows for simulating 
the delivery of
+     * messages.
+     */
+    public class TestApiFuture implements ApiFuture<String> {
+
+        private final CompletableFuture<String> asyncOperation;
+
+        public TestApiFuture(
+                PubsubMessage message, Function<String, 
StatusRuntimeException> exceptionSupplier) {
+            asyncOperation =
+                    FutureUtils.supplyAsync(
+                            () -> {
+                                try {
+                                    CountDownLatch latch =
+                                            
messageLatchMap.get(message.getData().toStringUtf8());
+                                    if (latch != null) {
+                                        latch.await();
+                                    }
+
+                                    if 
(message.getData().toStringUtf8().startsWith("fail")) {
+                                        previouslyFailedMessages.add(
+                                                
message.getData().toStringUtf8());
+                                        throw exceptionSupplier.apply(
+                                                
message.getData().toStringUtf8());
+                                    }
+
+                                    
deliveredMessages.put(message.getData().toStringUtf8());
+                                    return "Ok";
+                                } catch (InterruptedException ignored) {
+                                }
+                                return null;
+                            },
+                            executor);
+        }
+
+        @Override
+        public boolean cancel(boolean mayInterruptIfRunning) {
+            return asyncOperation.cancel(mayInterruptIfRunning);
+        }
+
+        @Override
+        public boolean isCancelled() {
+            return asyncOperation.isCancelled();
+        }
+
+        @Override
+        public boolean isDone() {
+            return asyncOperation.isDone();
+        }
+
+        @Override
+        public String get() throws ExecutionException, InterruptedException {
+            return asyncOperation.get();
+        }
+
+        @Override
+        public String get(long timeout, TimeUnit unit)
+                throws ExecutionException, InterruptedException, 
TimeoutException {
+            return asyncOperation.get(timeout, unit);
+        }
+
+        @Override
+        public void addListener(Runnable runnable, Executor executor) {
+            asyncOperation.whenComplete(
+                    (result, throwable) -> {
+                        executor.execute(runnable);
+                    });
+        }
+    }
+}
diff --git a/pom.xml b/pom.xml
index df4536d..24db663 100644
--- a/pom.xml
+++ b/pom.xml
@@ -49,7 +49,7 @@ under the License.
        </modules>
 
        <properties>
-               <flink.version>1.18.0</flink.version>
+               <flink.version>1.19.0</flink.version>
                
<google-cloud-libraries-bom.version>26.37.0</google-cloud-libraries-bom.version>
 
                <junit5.version>5.10.2</junit5.version>

Reply via email to