diff --git a/google-cloud-pubsublite/clirr-ignored-differences.xml b/google-cloud-pubsublite/clirr-ignored-differences.xml index d90c7334b..47eb5b7e0 100644 --- a/google-cloud-pubsublite/clirr-ignored-differences.xml +++ b/google-cloud-pubsublite/clirr-ignored-differences.xml @@ -56,4 +56,79 @@ com/google/cloud/pubsublite/internal/** * - + + + 7013 + com/google/cloud/pubsublite/cloudpubsub/PublisherSettings + java.util.Optional kafkaProperties() + + + 7013 + com/google/cloud/pubsublite/cloudpubsub/PublisherSettings + com.google.cloud.pubsublite.cloudpubsub.MessagingBackend messagingBackend() + + + + 7013 + com/google/cloud/pubsublite/AdminClientSettings + int kafkaDefaultPartitions() + + + 7013 + com/google/cloud/pubsublite/AdminClientSettings + short kafkaDefaultReplicationFactor() + + + 7013 + com/google/cloud/pubsublite/AdminClientSettings + java.util.Optional kafkaProperties() + + + 7013 + com/google/cloud/pubsublite/AdminClientSettings + com.google.cloud.pubsublite.cloudpubsub.MessagingBackend messagingBackend() + + + + 7013 + com/google/cloud/pubsublite/AdminClientSettings$Builder + com.google.cloud.pubsublite.AdminClientSettings$Builder setKafkaDefaultPartitions(int) + + + 7013 + com/google/cloud/pubsublite/AdminClientSettings$Builder + com.google.cloud.pubsublite.AdminClientSettings$Builder setKafkaDefaultReplicationFactor(short) + + + 7013 + com/google/cloud/pubsublite/AdminClientSettings$Builder + com.google.cloud.pubsublite.AdminClientSettings$Builder setKafkaProperties(java.util.Map) + + + 7013 + com/google/cloud/pubsublite/AdminClientSettings$Builder + com.google.cloud.pubsublite.AdminClientSettings$Builder setMessagingBackend(com.google.cloud.pubsublite.cloudpubsub.MessagingBackend) + + + + 7013 + com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings + java.util.Optional kafkaProperties() + + + 7013 + com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings + com.google.cloud.pubsublite.cloudpubsub.MessagingBackend messagingBackend() + + + + 7013 + com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings$Builder + com.google.cloud.pubsublite.cloudpubsub.SubscriberSettings$Builder setKafkaProperties(java.util.Map) + + + 7013 + com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings$Builder + com.google.cloud.pubsublite.cloudpubsub.SubscriberSettings$Builder setMessagingBackend(com.google.cloud.pubsublite.cloudpubsub.MessagingBackend) + + \ No newline at end of file diff --git a/google-cloud-pubsublite/pom.xml b/google-cloud-pubsublite/pom.xml index 669a55c59..f096dd065 100644 --- a/google-cloud-pubsublite/pom.xml +++ b/google-cloud-pubsublite/pom.xml @@ -3,13 +3,13 @@ com.google.cloud google-cloud-pubsublite-parent - 1.15.19 + 1.15.15-SNAPSHOT ../pom.xml 4.0.0 com.google.cloud google-cloud-pubsublite - 1.15.19 + 1.15.15-SNAPSHOT jar Google Cloud Pub/Sub Lite https://github.com/googleapis/java-pubsublite @@ -38,7 +38,7 @@ com.google.cloud google-cloud-pubsub - 1.143.1 + 1.141.3 com.google.api.grpc @@ -48,12 +48,12 @@ com.google.api.grpc proto-google-cloud-pubsublite-v1 - 1.15.19 + 1.15.15-SNAPSHOT com.google.api.grpc grpc-google-cloud-pubsublite-v1 - 1.15.19 + 1.15.15-SNAPSHOT com.google.flogger @@ -119,6 +119,48 @@ 0.8 + + + org.apache.kafka + kafka-clients + 3.7.0 + true + + + + + com.google.cloud.hosted.kafka + managed-kafka-auth-login-handler + 1.0.6 + true + + + io.confluent + * + + + + + + + org.testcontainers + testcontainers + 1.19.3 + test + + + org.testcontainers + kafka + 1.19.3 + test + + + org.testcontainers + junit-jupiter + 1.19.3 + test + + com.google.truth @@ -164,8 +206,12 @@ org.hamcrest:hamcrest com.google.flogger:flogger-system-backend javax.annotation:javax.annotation-api + com.google.cloud.hosted.kafka:managed-kafka-auth-login-handler - org.hamcrest:hamcrest-core + + org.hamcrest:hamcrest-core + org.testcontainers:testcontainers + @@ -211,4 +257,4 @@ - + \ No newline at end of file diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/AdminClientSettings.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/AdminClientSettings.java index b002b0fab..c57f0e575 100755 --- a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/AdminClientSettings.java +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/AdminClientSettings.java @@ -21,10 +21,13 @@ import com.google.api.gax.retrying.RetrySettings; import com.google.api.gax.rpc.ApiException; import com.google.auto.value.AutoValue; +import com.google.cloud.pubsublite.cloudpubsub.MessagingBackend; import com.google.cloud.pubsublite.internal.AdminClientImpl; import com.google.cloud.pubsublite.internal.ExtractStatus; +import com.google.cloud.pubsublite.internal.KafkaAdminClient; import com.google.cloud.pubsublite.v1.AdminServiceClient; import com.google.cloud.pubsublite.v1.AdminServiceSettings; +import java.util.Map; import java.util.Optional; /** Settings for construction a Pub/Sub Lite AdminClient. */ @@ -44,9 +47,24 @@ public abstract class AdminClientSettings { /** A stub to use to connect. */ abstract Optional serviceClient(); + /** The backend messaging system to use (e.g., PUBSUB_LITE or MANAGED_KAFKA). */ + public abstract MessagingBackend messagingBackend(); + + /** Kafka-specific properties for when using MANAGED_KAFKA backend. */ + public abstract Optional> kafkaProperties(); + + /** Default number of partitions for new Kafka topics. */ + public abstract int kafkaDefaultPartitions(); + + /** Default replication factor for new Kafka topics. */ + public abstract short kafkaDefaultReplicationFactor(); + public static Builder newBuilder() { return new AutoValue_AdminClientSettings.Builder() - .setRetrySettings(Constants.DEFAULT_RETRY_SETTINGS); + .setRetrySettings(Constants.DEFAULT_RETRY_SETTINGS) + .setMessagingBackend(MessagingBackend.PUBSUB_LITE) + .setKafkaDefaultPartitions(3) + .setKafkaDefaultReplicationFactor((short) 1); } @AutoValue.Builder @@ -64,11 +82,37 @@ public abstract static class Builder { /** A service client to use to connect. */ public abstract Builder setServiceClient(AdminServiceClient serviceClient); + /** Set the backend messaging system to use (e.g., PUBSUB_LITE or MANAGED_KAFKA). */ + public abstract Builder setMessagingBackend(MessagingBackend backend); + + /** Set Kafka-specific properties for when using MANAGED_KAFKA backend. */ + public abstract Builder setKafkaProperties(Map kafkaProperties); + + /** Set default number of partitions for new Kafka topics. */ + public abstract Builder setKafkaDefaultPartitions(int partitions); + + /** Set default replication factor for new Kafka topics. */ + public abstract Builder setKafkaDefaultReplicationFactor(short replicationFactor); + /** Build the settings object. */ public abstract AdminClientSettings build(); } AdminClient instantiate() throws ApiException { + // For Kafka backend, use KafkaAdminClient + if (messagingBackend() == MessagingBackend.MANAGED_KAFKA) { + if (!kafkaProperties().isPresent()) { + throw new IllegalStateException( + "kafkaProperties must be set when using MANAGED_KAFKA backend"); + } + return new KafkaAdminClient( + region(), + kafkaProperties().get(), + kafkaDefaultPartitions(), + kafkaDefaultReplicationFactor()); + } + + // For Pub/Sub Lite backend, use AdminClientImpl AdminServiceClient serviceClient; if (serviceClient().isPresent()) { serviceClient = serviceClient().get(); diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/GmkUtils.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/GmkUtils.java new file mode 100644 index 000000000..708520e37 --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/GmkUtils.java @@ -0,0 +1,135 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.cloudpubsub; + +import java.net.InetAddress; +import java.util.Map; + +/** Utility methods for Google Managed Kafka (GMK) integration. */ +public class GmkUtils { + + /** + * Validates GMK bootstrap server connectivity and configuration. + * + * @param kafkaProperties The Kafka properties containing bootstrap.servers + * @return Validation result with suggestions if issues are found + */ + public static ValidationResult validateGmkConfiguration(Map kafkaProperties) { + String bootstrapServers = (String) kafkaProperties.get("bootstrap.servers"); + + if (bootstrapServers == null || bootstrapServers.trim().isEmpty()) { + return ValidationResult.error("bootstrap.servers property is missing or empty"); + } + + // Extract hostname from bootstrap servers + String[] servers = bootstrapServers.split(","); + for (String server : servers) { + String hostname = server.trim().split(":")[0]; + + // Check if it looks like a GMK hostname + if (hostname.contains("managedkafka") && hostname.contains(".cloud.goog")) { + try { + // Test DNS resolution + InetAddress.getByName(hostname); + return ValidationResult.success( + "GMK bootstrap server resolved successfully: " + hostname); + } catch (Exception e) { + return ValidationResult.error( + "Failed to resolve GMK hostname: " + + hostname + + ". Please verify:\n" + + "1. The GMK cluster exists and is running\n" + + "2. You have the correct project ID, region, and cluster name\n" + + "3. Your network allows DNS resolution to *.cloud.goog domains\n" + + "Use: gcloud managed-kafka clusters list --location=" + + " --project="); + } + } + } + + return ValidationResult.warning( + "Bootstrap servers don't appear to be GMK endpoints: " + bootstrapServers); + } + + /** + * Constructs a GMK bootstrap server URL from cluster details. + * + * @param projectId GCP project ID + * @param region GCP region (e.g., "us-central1") + * @param clusterId GMK cluster ID + * @param port Port number + * @return Formatted bootstrap server URL + */ + public static String buildGmkBootstrapServer( + String projectId, String region, String clusterId, int port) { + return String.format( + "bootstrap.%s.%s.managedkafka.%s.cloud.goog:%d", clusterId, region, projectId, port); + } + + /** Constructs a GMK bootstrap server URL with default port 9092. */ + public static String buildGmkBootstrapServer(String projectId, String region, String clusterId) { + return buildGmkBootstrapServer(projectId, region, clusterId, 9092); + } + + /** Result of configuration validation. */ + public static class ValidationResult { + private final boolean success; + private final String message; + private final Level level; + + private ValidationResult(boolean success, String message, Level level) { + this.success = success; + this.message = message; + this.level = level; + } + + public static ValidationResult success(String message) { + return new ValidationResult(true, message, Level.SUCCESS); + } + + public static ValidationResult error(String message) { + return new ValidationResult(false, message, Level.ERROR); + } + + public static ValidationResult warning(String message) { + return new ValidationResult(false, message, Level.WARNING); + } + + public boolean isSuccess() { + return success; + } + + public String getMessage() { + return message; + } + + public Level getLevel() { + return level; + } + + public enum Level { + SUCCESS, + WARNING, + ERROR + } + + @Override + public String toString() { + return level + ": " + message; + } + } +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/MessagingBackend.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/MessagingBackend.java new file mode 100644 index 000000000..2f22c2922 --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/MessagingBackend.java @@ -0,0 +1,32 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.cloudpubsub; + +/** Specifies the messaging backend to use for Publisher and Subscriber clients. */ +public enum MessagingBackend { + /** + * Use Google Cloud Pub/Sub Lite (default). This is the traditional backend with zonal storage and + * predictable pricing. + */ + PUBSUB_LITE, + + /** + * Use Google Cloud Managed Service for Apache Kafka. Provides Kafka-compatible API with Google + * Cloud management. + */ + MANAGED_KAFKA +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/PublisherSettings.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/PublisherSettings.java index 3062a5f81..d6f779988 100755 --- a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/PublisherSettings.java +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/PublisherSettings.java @@ -34,6 +34,7 @@ import com.google.cloud.pubsublite.MessageTransformer; import com.google.cloud.pubsublite.Partition; import com.google.cloud.pubsublite.TopicPath; +import com.google.cloud.pubsublite.cloudpubsub.internal.KafkaPartitionPublisherFactory; import com.google.cloud.pubsublite.cloudpubsub.internal.WrappingPublisher; import com.google.cloud.pubsublite.internal.CheckedApiException; import com.google.cloud.pubsublite.internal.wire.PartitionCountWatchingPublisherSettings; @@ -52,6 +53,7 @@ import com.google.pubsub.v1.PubsubMessage; import io.grpc.CallOptions; import java.time.Duration; +import java.util.Map; import java.util.Optional; /** @@ -70,7 +72,7 @@ public abstract class PublisherSettings { // Required parameters. /** The topic path to publish to. */ - abstract TopicPath topicPath(); + public abstract TopicPath topicPath(); // Optional parameters. /** A KeyExtractor for getting the routing key from a message. */ @@ -80,16 +82,16 @@ public abstract class PublisherSettings { abstract Optional> messageTransformer(); /** Batching settings for this publisher to use. Apply per-partition. */ - abstract BatchingSettings batchingSettings(); + public abstract BatchingSettings batchingSettings(); /** * Whether idempotence is enabled, where the server will ensure that unique messages within a * single publisher session are stored only once. Default true. */ - abstract boolean enableIdempotence(); + public abstract boolean enableIdempotence(); /** Whether request compression is enabled. Default true. */ - abstract boolean enableCompression(); + public abstract boolean enableCompression(); /** A provider for credentials. */ abstract CredentialsProvider credentialsProvider(); @@ -111,6 +113,17 @@ public abstract class PublisherSettings { // For testing. abstract SinglePartitionPublisherBuilder.Builder underlyingBuilder(); + /** The messaging backend to use. Defaults to PUBSUB_LITE for backward compatibility. */ + public abstract MessagingBackend messagingBackend(); + + /** + * Kafka-specific configuration properties. Only used when messagingBackend is MANAGED_KAFKA. + * Common properties include: - "bootstrap.servers": Kafka broker addresses - "compression.type": + * Compression algorithm (e.g., "snappy", "gzip") - "max.in.flight.requests.per.connection": + * Pipelining configuration + */ + public abstract Optional> kafkaProperties(); + /** Get a new builder for a PublisherSettings. */ public static Builder newBuilder() { return new AutoValue_PublisherSettings.Builder() @@ -120,7 +133,8 @@ public static Builder newBuilder() { .setBatchingSettings(DEFAULT_BATCHING_SETTINGS) .setEnableIdempotence(true) .setEnableCompression(true) - .setUnderlyingBuilder(SinglePartitionPublisherBuilder.newBuilder()); + .setUnderlyingBuilder(SinglePartitionPublisherBuilder.newBuilder()) + .setMessagingBackend(MessagingBackend.PUBSUB_LITE); } @AutoValue.Builder @@ -169,6 +183,12 @@ public abstract Builder setMessageTransformer( abstract Builder setUnderlyingBuilder( SinglePartitionPublisherBuilder.Builder underlyingBuilder); + /** Sets the messaging backend. Defaults to PUBSUB_LITE. */ + public abstract Builder setMessagingBackend(MessagingBackend backend); + + /** Sets Kafka-specific properties. Only used when backend is MANAGED_KAFKA. */ + public abstract Builder setKafkaProperties(Map properties); + public abstract PublisherSettings build(); } @@ -185,6 +205,12 @@ private PublisherServiceClient newServiceClient() throws ApiException { } private PartitionPublisherFactory getPartitionPublisherFactory() { + // Check backend and return appropriate factory + if (messagingBackend() == MessagingBackend.MANAGED_KAFKA) { + return new KafkaPartitionPublisherFactory(this); + } + + // Existing Pub/Sub Lite implementation PublisherServiceClient client = newServiceClient(); ByteString publisherClientId = UuidBuilder.toByteString(UuidBuilder.generate()); return new PartitionPublisherFactory() { @@ -241,6 +267,11 @@ private AdminClient getAdminClient() throws ApiException { @SuppressWarnings("CheckReturnValue") Publisher instantiate() throws ApiException { + // For Kafka backend, use simpler publisher that doesn't need partition watching + if (messagingBackend() == MessagingBackend.MANAGED_KAFKA) { + return new com.google.cloud.pubsublite.cloudpubsub.internal.KafkaPublisher(this); + } + if (batchingSettings().getFlowControlSettings().getMaxOutstandingElementCount() != null || batchingSettings().getFlowControlSettings().getMaxOutstandingRequestBytes() != null) { throw new CheckedApiException( diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings.java index 80dac1c8a..2bf2ba781 100755 --- a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings.java +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/SubscriberSettings.java @@ -57,6 +57,7 @@ import com.google.pubsub.v1.PubsubMessage; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Optional; /** @@ -70,16 +71,16 @@ public abstract class SubscriberSettings { * any connected partition will be outstanding at a time, and blocking in this receiver callback * will block forward progress. */ - abstract MessageReceiver receiver(); + public abstract MessageReceiver receiver(); /** The subscription to use to receive messages. */ - abstract SubscriptionPath subscriptionPath(); + public abstract SubscriptionPath subscriptionPath(); /** * The per-partition flow control settings. Because these apply per-partition, if you are using * them to bound memory usage, keep in mind the number of partitions in the associated topic. */ - abstract FlowControlSettings perPartitionFlowControlSettings(); + public abstract FlowControlSettings perPartitionFlowControlSettings(); // Optional parameters. @@ -125,13 +126,20 @@ public abstract class SubscriberSettings { /** A handler that will be notified when partition assignments change from the backend. */ abstract ReassignmentHandler reassignmentHandler(); + /** The backend messaging system to use (e.g., PUBSUB_LITE or MANAGED_KAFKA). */ + public abstract MessagingBackend messagingBackend(); + + /** Kafka-specific properties for when using MANAGED_KAFKA backend. */ + public abstract Optional> kafkaProperties(); + public static Builder newBuilder() { return new AutoValue_SubscriberSettings.Builder() .setFramework(Framework.of("CLOUD_PUBSUB_SHIM")) .setPartitions(ImmutableList.of()) .setCredentialsProvider( SubscriberServiceSettings.defaultCredentialsProviderBuilder().build()) - .setReassignmentHandler((before, after) -> {}); + .setReassignmentHandler((before, after) -> {}) + .setMessagingBackend(MessagingBackend.PUBSUB_LITE); } @AutoValue.Builder @@ -199,6 +207,12 @@ public abstract Builder setTransformer( /** A handler that will be notified when partition assignments change from the backend. */ public abstract Builder setReassignmentHandler(ReassignmentHandler reassignmentHandler); + /** Set the backend messaging system to use (e.g., PUBSUB_LITE or MANAGED_KAFKA). */ + public abstract Builder setMessagingBackend(MessagingBackend backend); + + /** Set Kafka-specific properties for when using MANAGED_KAFKA backend. */ + public abstract Builder setKafkaProperties(Map kafkaProperties); + public abstract SubscriberSettings build(); } @@ -301,6 +315,11 @@ private PartitionAssignmentServiceClient getAssignmentServiceClient() throws Api @SuppressWarnings("CheckReturnValue") Subscriber instantiate() throws ApiException { + // For Kafka backend, use simpler subscriber that doesn't need partition watching + if (messagingBackend() == MessagingBackend.MANAGED_KAFKA) { + return new com.google.cloud.pubsublite.cloudpubsub.internal.KafkaSubscriber(this); + } + PartitionSubscriberFactory partitionSubscriberFactory = getPartitionSubscriberFactory(); if (partitions().isEmpty()) { diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPartitionPublisher.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPartitionPublisher.java new file mode 100644 index 000000000..868fec5a3 --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPartitionPublisher.java @@ -0,0 +1,158 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.cloudpubsub.internal; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.api.core.SettableApiFuture; +import com.google.api.gax.rpc.StatusCode.Code; +import com.google.cloud.pubsublite.MessageMetadata; +import com.google.cloud.pubsublite.Offset; +import com.google.cloud.pubsublite.Partition; +import com.google.cloud.pubsublite.cloudpubsub.PublisherSettings; +import com.google.cloud.pubsublite.internal.CheckedApiException; +import com.google.cloud.pubsublite.internal.ProxyService; +import com.google.cloud.pubsublite.internal.Publisher; +import com.google.cloud.pubsublite.proto.PubSubMessage; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; + +/** Adapts a Kafka producer to the internal Publisher interface for a specific partition. */ +public class KafkaPartitionPublisher extends ProxyService implements Publisher { + + private final KafkaProducer producer; + private final String topicName; + private final Partition partition; + private final PublisherSettings settings; + private final ConcurrentLinkedQueue> pendingFutures; + + public KafkaPartitionPublisher( + KafkaProducer producer, + String topicName, + Partition partition, + PublisherSettings settings) { + this.producer = producer; + this.topicName = topicName; + this.partition = partition; + this.settings = settings; + this.pendingFutures = new ConcurrentLinkedQueue<>(); + } + + @Override + public ApiFuture publish(PubSubMessage message) { + if (state() == State.FAILED) { + return ApiFutures.immediateFailedFuture( + new CheckedApiException("Publisher has failed", Code.FAILED_PRECONDITION).underlying); + } + + try { + // Convert to Kafka ProducerRecord + ProducerRecord record = convertToKafkaRecord(message); + + // Create future for response + SettableApiFuture future = SettableApiFuture.create(); + pendingFutures.add(future); + + // Send to Kafka + producer.send( + record, + (metadata, exception) -> { + pendingFutures.remove(future); + + if (exception != null) { + CheckedApiException apiException = new CheckedApiException(exception, Code.INTERNAL); + future.setException(apiException.underlying); + + // If this is a permanent error, fail the publisher + if (isPermanentError(exception)) { + onPermanentError(apiException); + } + } else { + // Convert Kafka metadata to MessageMetadata + MessageMetadata messageMetadata = + MessageMetadata.of( + Partition.of(metadata.partition()), Offset.of(metadata.offset())); + future.set(messageMetadata); + } + }); + + return future; + + } catch (Exception e) { + CheckedApiException apiException = new CheckedApiException(e, Code.INTERNAL); + onPermanentError(apiException); + return ApiFutures.immediateFailedFuture(apiException.underlying); + } + } + + @Override + public void flush() { + producer.flush(); + } + + @Override + public void cancelOutstandingPublishes() { + CheckedApiException exception = + new CheckedApiException("Publisher is shutting down", Code.CANCELLED); + + pendingFutures.forEach(future -> future.setException(exception.underlying)); + pendingFutures.clear(); + } + + private ProducerRecord convertToKafkaRecord(PubSubMessage message) { + // Extract key - use ordering key if available + byte[] key = message.getKey().isEmpty() ? null : message.getKey().toByteArray(); + + // Create record with explicit partition + ProducerRecord record = + new ProducerRecord( + topicName, + Integer.valueOf((int) partition.value()), + key, + message.getData().toByteArray()); + + // Convert attributes to headers + List
headers = new ArrayList<>(); + message.getAttributesMap().forEach((k, v) -> headers.add(new RecordHeader(k, v.toByteArray()))); + + // Add event time as header if present + if (message.hasEventTime()) { + headers.add( + new RecordHeader( + "pubsublite.event_time", + String.valueOf(message.getEventTime().getSeconds()).getBytes())); + } + + headers.forEach(record.headers()::add); + + return record; + } + + private boolean isPermanentError(Exception e) { + // Determine if error is permanent and should fail the publisher + String message = e.getMessage(); + return message != null + && (message.contains("InvalidTopicException") + || message.contains("AuthorizationException") + || message.contains("SecurityDisabledException")); + } +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPartitionPublisherFactory.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPartitionPublisherFactory.java new file mode 100644 index 000000000..e7625f476 --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPartitionPublisherFactory.java @@ -0,0 +1,133 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.cloudpubsub.internal; + +import com.google.api.gax.rpc.ApiException; +import com.google.cloud.pubsublite.MessageMetadata; +import com.google.cloud.pubsublite.Partition; +import com.google.cloud.pubsublite.cloudpubsub.PublisherSettings; +import com.google.cloud.pubsublite.internal.Publisher; +import com.google.cloud.pubsublite.internal.wire.PartitionPublisherFactory; +import java.util.Properties; +import java.util.concurrent.ConcurrentHashMap; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.ByteArraySerializer; + +/** + * Factory for creating Kafka-based partition publishers. Manages a single KafkaProducer instance + * shared across all partitions. + */ +public class KafkaPartitionPublisherFactory implements PartitionPublisherFactory { + private final KafkaProducer kafkaProducer; + private final PublisherSettings settings; + private final ConcurrentHashMap> publishers; + private final String topicName; + + public KafkaPartitionPublisherFactory(PublisherSettings settings) throws ApiException { + this.settings = settings; + this.publishers = new ConcurrentHashMap<>(); + this.topicName = extractKafkaTopicName(settings.topicPath()); + + Properties props = new Properties(); + + // Configure Kafka connection + configureKafkaConnection(props); + + // Configure producer settings + configureProducerSettings(props); + + // Apply user-provided properties (override defaults) + if (settings.kafkaProperties().isPresent()) { + settings.kafkaProperties().get().forEach((key, value) -> props.put(key, value)); + } + + try { + this.kafkaProducer = new KafkaProducer<>(props); + } catch (Exception e) { + throw new ApiException(e, null, false); + } + } + + private void configureKafkaConnection(Properties props) { + if (settings.kafkaProperties().isPresent() + && settings.kafkaProperties().get().containsKey("bootstrap.servers")) { + props.put( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, + settings.kafkaProperties().get().get("bootstrap.servers")); + } else { + throw new IllegalArgumentException( + "Kafka bootstrap servers must be specified in kafkaProperties"); + } + } + + private void configureProducerSettings(Properties props) { + // Serialization + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class.getName()); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, ByteArraySerializer.class.getName()); + + // Performance settings aligned with Pub/Sub Lite defaults + props.put( + ProducerConfig.BATCH_SIZE_CONFIG, + Math.toIntExact(settings.batchingSettings().getRequestByteThreshold())); + props.put( + ProducerConfig.LINGER_MS_CONFIG, + settings.batchingSettings().getDelayThresholdDuration().toMillis()); + + // Compression + if (settings.enableCompression()) { + props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "gzip"); + } + + // Idempotence + if (settings.enableIdempotence()) { + props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); + props.put(ProducerConfig.ACKS_CONFIG, "all"); + props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5); + } + + // Reliability settings + props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE); + props.put(ProducerConfig.MAX_BLOCK_MS_CONFIG, 30000); + props.put(ProducerConfig.REQUEST_TIMEOUT_MS_CONFIG, 30000); + } + + @Override + public Publisher newPublisher(Partition partition) throws ApiException { + return publishers.computeIfAbsent( + partition, p -> new KafkaPartitionPublisher(kafkaProducer, topicName, partition, settings)); + } + + @Override + public void close() { + publishers + .values() + .forEach( + publisher -> { + try { + publisher.stopAsync().awaitTerminated(); + } catch (Exception e) { + // Log but don't throw - best effort cleanup + } + }); + kafkaProducer.close(); + } + + private String extractKafkaTopicName(com.google.cloud.pubsublite.TopicPath topicPath) { + return topicPath.name().value(); + } +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPublisher.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPublisher.java new file mode 100644 index 000000000..39201640b --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaPublisher.java @@ -0,0 +1,146 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.cloudpubsub.internal; + +import com.google.api.core.AbstractApiService; +import com.google.api.core.ApiFuture; +import com.google.api.core.SettableApiFuture; +import com.google.cloud.pubsublite.cloudpubsub.Publisher; +import com.google.cloud.pubsublite.cloudpubsub.PublisherSettings; +import com.google.pubsub.v1.PubsubMessage; +import java.util.HashMap; +import java.util.Map; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.serialization.ByteArraySerializer; + +/** + * A simple Kafka publisher that directly uses KafkaProducer without the complex partition watching + * infrastructure needed for Pub/Sub Lite. + */ +public class KafkaPublisher extends AbstractApiService implements Publisher { + + private final String topicName; + private final KafkaProducer kafkaProducer; + + public KafkaPublisher(PublisherSettings settings) { + this.topicName = settings.topicPath().name().value(); + + // Set up Kafka producer configuration + Map kafkaProps = + new HashMap<>(settings.kafkaProperties().orElse(new HashMap<>())); + + // Ensure key and value serializers are set + kafkaProps.putIfAbsent("key.serializer", ByteArraySerializer.class.getName()); + kafkaProps.putIfAbsent("value.serializer", ByteArraySerializer.class.getName()); + + try { + this.kafkaProducer = new KafkaProducer<>(kafkaProps); + } catch (Exception e) { + + String bootstrapServers = (String) kafkaProps.get("bootstrap.servers"); + if (e.getCause() instanceof org.apache.kafka.common.config.ConfigException + && e.getMessage().contains("No resolvable bootstrap urls")) { + throw new RuntimeException( + "Failed to resolve Kafka bootstrap servers: " + + bootstrapServers + + ". This could indicate:\n" + + "1. The Google Managed Kafka cluster doesn't exist or isn't accessible\n" + + "2. Network/DNS resolution issues\n" + + "3. Incorrect bootstrap server URL format\n" + + "Please verify the cluster exists with: gcloud managed-kafka clusters describe" + + " --location= --project=", + e); + } + throw new RuntimeException("Failed to initialize Kafka producer: " + e.getMessage(), e); + } + } + + @Override + public ApiFuture publish(PubsubMessage message) { + SettableApiFuture future = SettableApiFuture.create(); + + try { + // Convert PubsubMessage to Kafka ProducerRecord + ProducerRecord record = convertToKafkaRecord(message); + + // Send to Kafka + kafkaProducer.send( + record, + (RecordMetadata metadata, Exception exception) -> { + if (exception != null) { + future.setException(exception); + } else { + // Return partition:offset format like Pub/Sub Lite message IDs + String messageId = metadata.partition() + ":" + metadata.offset(); + future.set(messageId); + } + }); + + } catch (Exception e) { + future.setException(e); + } + + return future; + } + + private ProducerRecord convertToKafkaRecord(PubsubMessage message) { + // Use ordering key as Kafka key for partitioning + byte[] key = null; + if (!message.getOrderingKey().isEmpty()) { + key = message.getOrderingKey().getBytes(); + } + + // Message data becomes Kafka value + byte[] value = message.getData().toByteArray(); + + // Convert attributes to Kafka headers + ProducerRecord record = new ProducerRecord<>(topicName, key, value); + + // Add all attributes as headers + for (Map.Entry attr : message.getAttributesMap().entrySet()) { + record.headers().add(attr.getKey(), attr.getValue().getBytes()); + } + + // Add event time as special header if present + if (message.hasPublishTime()) { + record + .headers() + .add( + "pubsublite.publish_time", + String.valueOf(message.getPublishTime().getSeconds()).getBytes()); + } + + return record; + } + + @Override + protected void doStart() { + notifyStarted(); + } + + @Override + protected void doStop() { + try { + kafkaProducer.close(); + notifyStopped(); + } catch (Exception e) { + notifyFailed(e); + } + } +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaSubscriber.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaSubscriber.java new file mode 100644 index 000000000..a6189ee67 --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/cloudpubsub/internal/KafkaSubscriber.java @@ -0,0 +1,359 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.cloudpubsub.internal; + +import com.google.api.core.AbstractApiService; +import com.google.api.core.ApiService; +import com.google.cloud.pubsub.v1.AckReplyConsumer; +import com.google.cloud.pubsub.v1.MessageReceiver; +import com.google.cloud.pubsublite.cloudpubsub.Subscriber; +import com.google.cloud.pubsublite.cloudpubsub.SubscriberSettings; +import com.google.protobuf.ByteString; +import com.google.protobuf.Timestamp; +import com.google.pubsub.v1.PubsubMessage; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.common.TopicPartition; +import org.apache.kafka.common.errors.WakeupException; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.serialization.ByteArrayDeserializer; + +/** + * A Kafka-based subscriber that uses KafkaConsumer to consume messages from Kafka topics. This + * implementation is designed to work with Google Managed Kafka (GMK) clusters. + */ +public class KafkaSubscriber extends AbstractApiService implements Subscriber { + private static final Logger log = Logger.getLogger(KafkaSubscriber.class.getName()); + + private final String topicName; + private final String groupId; + private final MessageReceiver receiver; + private final KafkaConsumer kafkaConsumer; + private final ExecutorService pollExecutor; + private final AtomicBoolean isPolling = new AtomicBoolean(false); + private final Map pendingAcks = new ConcurrentHashMap<>(); + + // Track offset info for each message + private static class OffsetInfo { + final TopicPartition partition; + final long offset; + final long timestamp; + + OffsetInfo(TopicPartition partition, long offset, long timestamp) { + this.partition = partition; + this.offset = offset; + this.timestamp = timestamp; + } + } + + public KafkaSubscriber(SubscriberSettings settings) { + this.topicName = settings.subscriptionPath().name().value(); + this.groupId = settings.subscriptionPath().toString().replace('/', '-'); + this.receiver = settings.receiver(); + this.pollExecutor = + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "kafka-subscriber-poll-" + topicName); + t.setDaemon(true); + return t; + }); + + // Set up Kafka consumer configuration + Map kafkaProps = + new HashMap<>(settings.kafkaProperties().orElse(new HashMap<>())); + + // Set required properties + kafkaProps.putIfAbsent("key.deserializer", ByteArrayDeserializer.class.getName()); + kafkaProps.putIfAbsent("value.deserializer", ByteArrayDeserializer.class.getName()); + kafkaProps.putIfAbsent("group.id", groupId); + kafkaProps.putIfAbsent("enable.auto.commit", "false"); // Manual offset management + kafkaProps.putIfAbsent("auto.offset.reset", "earliest"); + kafkaProps.putIfAbsent("max.poll.records", "500"); + kafkaProps.putIfAbsent("session.timeout.ms", "30000"); + + try { + this.kafkaConsumer = new KafkaConsumer<>(kafkaProps); + } catch (Exception e) { + String bootstrapServers = (String) kafkaProps.get("bootstrap.servers"); + if (e.getCause() instanceof org.apache.kafka.common.config.ConfigException + && e.getMessage().contains("No resolvable bootstrap urls")) { + throw new RuntimeException( + "Failed to resolve Kafka bootstrap servers: " + + bootstrapServers + + ". This could indicate:\n" + + "1. The Google Managed Kafka cluster doesn't exist or isn't accessible\n" + + "2. Network/DNS resolution issues\n" + + "3. Incorrect bootstrap server URL format\n" + + "Please verify the cluster exists with: gcloud managed-kafka clusters describe" + + " --location= --project=", + e); + } + throw new RuntimeException("Failed to initialize Kafka consumer: " + e.getMessage(), e); + } + } + + private void startPolling() { + if (!isPolling.compareAndSet(false, true)) { + return; + } + + // Subscribe to the topic + kafkaConsumer.subscribe(Collections.singletonList(topicName)); + + // Start the polling loop + pollExecutor.submit( + () -> { + try { + while (isPolling.get() && !Thread.currentThread().isInterrupted()) { + try { + ConsumerRecords records = + kafkaConsumer.poll(Duration.ofMillis(100)); + + for (ConsumerRecord record : records) { + if (!isPolling.get()) break; + + try { + // Convert Kafka record to PubsubMessage + PubsubMessage message = convertToPubsubMessage(record); + + // Generate a unique message ID + String messageId = + String.format( + "%s:%d:%d", record.topic(), record.partition(), record.offset()); + + // Store offset info for later acknowledgment + pendingAcks.put( + messageId, + new OffsetInfo( + new TopicPartition(record.topic(), record.partition()), + record.offset(), + record.timestamp())); + + // Create AckReplyConsumer for this message + AckReplyConsumer ackReplyConsumer = + new AckReplyConsumer() { + private final AtomicBoolean acked = new AtomicBoolean(false); + + @Override + public void ack() { + if (acked.compareAndSet(false, true)) { + commitOffset(messageId); + } + } + + @Override + public void nack() { + if (acked.compareAndSet(false, true)) { + // In Kafka, nack typically means we don't commit the offset + // The message will be redelivered after session timeout + log.info("Message nacked, will be redelivered: " + messageId); + pendingAcks.remove(messageId); + } + } + }; + + // Deliver message to receiver + receiver.receiveMessage(message, ackReplyConsumer); + + } catch (Exception e) { + log.log(Level.WARNING, "Error processing message from Kafka", e); + } + } + + } catch (WakeupException e) { + // This is expected when consumer.wakeup() is called + break; + } catch (Exception e) { + log.log(Level.SEVERE, "Error in Kafka poll loop", e); + if (!isPolling.get()) break; + + // Sleep briefly before retrying + try { + Thread.sleep(1000); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } finally { + isPolling.set(false); + } + }); + } + + /** + * Converts a Kafka ConsumerRecord to a PubsubMessage. + * + *

Translation rules: - Data: Bytes -> Bytes (direct pass-through) - Key: Kafka key -> PubSub + * orderingKey (preserves ordering logic) - Headers: Kafka headers -> PubSub attributes + * (multi-value headers are flattened) - Timestamp: Kafka timestamp (Unix epoch millis) -> + * Protobuf Timestamp + */ + private PubsubMessage convertToPubsubMessage(ConsumerRecord record) { + PubsubMessage.Builder builder = PubsubMessage.newBuilder(); + + // Data: direct bytes pass-through + if (record.value() != null) { + builder.setData(ByteString.copyFrom(record.value())); + } + + // Key: Kafka key becomes ordering key (preserves partitioning/ordering) + if (record.key() != null) { + builder.setOrderingKey(new String(record.key(), java.nio.charset.StandardCharsets.UTF_8)); + } + + // Convert Kafka timestamp (Unix epoch milliseconds) to Protobuf Timestamp + long timestampMs = record.timestamp(); + if (timestampMs > 0) { + long seconds = timestampMs / 1000; + int nanos = (int) ((timestampMs % 1000) * 1_000_000); + builder.setPublishTime(Timestamp.newBuilder().setSeconds(seconds).setNanos(nanos).build()); + } + + // Headers: Convert Kafka headers to PubSub attributes + // For multi-value headers with the same key, we use indexed suffixes + Map attributes = new HashMap<>(); + Map headerCounts = new HashMap<>(); + + for (Header header : record.headers()) { + if (header.value() != null) { + String key = header.key(); + String value = new String(header.value(), java.nio.charset.StandardCharsets.UTF_8); + + // Handle special headers that map to PubsubMessage fields + if (key.equals("pubsublite.publish_time")) { + // Already handled via record.timestamp() + continue; + } + + // Handle multi-value attributes by appending index suffix + int count = headerCounts.getOrDefault(key, 0); + if (count == 0) { + attributes.put(key, value); + } else { + attributes.put(key + "." + count, value); + } + headerCounts.put(key, count + 1); + } + } + + // Add Kafka-specific metadata as special attributes + attributes.put("x-kafka-topic", record.topic()); + attributes.put("x-kafka-partition", String.valueOf(record.partition())); + attributes.put("x-kafka-offset", String.valueOf(record.offset())); + attributes.put("x-kafka-timestamp-ms", String.valueOf(record.timestamp())); + attributes.put("x-kafka-timestamp-type", record.timestampType().name()); + + builder.putAllAttributes(attributes); + + // Set message ID in format: topic:partition:offset + builder.setMessageId( + String.format("%s:%d:%d", record.topic(), record.partition(), record.offset())); + + return builder.build(); + } + + private void commitOffset(String messageId) { + OffsetInfo info = pendingAcks.remove(messageId); + if (info == null) { + return; + } + + // Skip commit if we're shutting down + if (!isPolling.get()) { + log.fine("Skipping offset commit during shutdown for message: " + messageId); + return; + } + + try { + // Commit the offset for this message + Map offsets = new HashMap<>(); + offsets.put(info.partition, new OffsetAndMetadata(info.offset + 1)); + kafkaConsumer.commitSync(offsets); + + log.fine("Committed offset for message: " + messageId); + } catch (WakeupException e) { + // This is expected during shutdown - consumer.wakeup() was called + log.fine("Offset commit interrupted by shutdown for message: " + messageId); + } catch (Exception e) { + log.log(Level.WARNING, "Failed to commit offset for message: " + messageId, e); + } + } + + public String getSubscriptionNameString() { + return topicName + "/" + groupId; + } + + @Override + public ApiService startAsync() { + // Start parent service first + super.startAsync(); + return this; + } + + @Override + protected void doStart() { + try { + startPolling(); + notifyStarted(); + } catch (Exception e) { + notifyFailed(e); + } + } + + @Override + protected void doStop() { + try { + // Stop polling + isPolling.set(false); + + // Wake up the consumer if it's blocked in poll() + kafkaConsumer.wakeup(); + + // Shutdown executor + pollExecutor.shutdown(); + try { + if (!pollExecutor.awaitTermination(10, TimeUnit.SECONDS)) { + pollExecutor.shutdownNow(); + } + } catch (InterruptedException e) { + pollExecutor.shutdownNow(); + Thread.currentThread().interrupt(); + } + + kafkaConsumer.close(); + + notifyStopped(); + } catch (Exception e) { + notifyFailed(e); + } + } +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaAdminClient.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaAdminClient.java new file mode 100644 index 000000000..fedcd4e27 --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaAdminClient.java @@ -0,0 +1,491 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.internal; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.api.gax.longrunning.OperationFuture; +import com.google.api.gax.rpc.StatusCode; +import com.google.cloud.pubsublite.AdminClient; +import com.google.cloud.pubsublite.BacklogLocation; +import com.google.cloud.pubsublite.CloudRegion; +import com.google.cloud.pubsublite.LocationPath; +import com.google.cloud.pubsublite.ReservationPath; +import com.google.cloud.pubsublite.SeekTarget; +import com.google.cloud.pubsublite.SubscriptionPath; +import com.google.cloud.pubsublite.TopicPath; +import com.google.cloud.pubsublite.proto.OperationMetadata; +import com.google.cloud.pubsublite.proto.Reservation; +import com.google.cloud.pubsublite.proto.SeekSubscriptionResponse; +import com.google.cloud.pubsublite.proto.Subscription; +import com.google.cloud.pubsublite.proto.Topic; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.protobuf.FieldMask; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Logger; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.admin.TopicDescription; +import org.apache.kafka.common.KafkaFuture; +import org.apache.kafka.common.errors.TopicExistsException; +import org.apache.kafka.common.errors.UnknownTopicOrPartitionException; + +/** + * An AdminClient implementation that wraps Kafka's AdminClient. + * + *

This maps Pub/Sub Lite admin operations to Kafka admin operations: - Topics: + * Create/Delete/List/Get mapped to Kafka topic operations - Subscriptions: Mapped to Kafka consumer + * groups - Reservations: No-ops (PSL-specific feature that doesn't exist in Kafka) + */ +public class KafkaAdminClient implements AdminClient { + private static final Logger log = Logger.getLogger(KafkaAdminClient.class.getName()); + + private final CloudRegion region; + private final org.apache.kafka.clients.admin.AdminClient kafkaAdmin; + private final int defaultPartitions; + private final short defaultReplicationFactor; + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + private final AtomicBoolean isTerminated = new AtomicBoolean(false); + + /** + * Creates a new KafkaAdminClient. + * + * @param region The cloud region for this client. + * @param kafkaProperties Kafka connection properties (must include bootstrap.servers). + * @param defaultPartitions Default number of partitions for new topics. + * @param defaultReplicationFactor Default replication factor for new topics. + */ + public KafkaAdminClient( + CloudRegion region, + Map kafkaProperties, + int defaultPartitions, + short defaultReplicationFactor) { + this.region = region; + this.defaultPartitions = defaultPartitions; + this.defaultReplicationFactor = defaultReplicationFactor; + + Properties props = new Properties(); + props.putAll(kafkaProperties); + this.kafkaAdmin = org.apache.kafka.clients.admin.AdminClient.create(props); + } + + @Override + public CloudRegion region() { + return region; + } + + // Topic Operations + + @Override + public ApiFuture createTopic(Topic topic) { + String topicName = extractTopicName(topic.getName()); + int partitions = + topic.getPartitionConfig().getCount() > 0 + ? (int) topic.getPartitionConfig().getCount() + : defaultPartitions; + + NewTopic newTopic = new NewTopic(topicName, partitions, defaultReplicationFactor); + + return toApiFuture( + kafkaAdmin.createTopics(Collections.singleton(newTopic)).all(), + v -> topic, + e -> { + if (e instanceof TopicExistsException) { + throw new CheckedApiException( + "Topic already exists: " + topicName, StatusCode.Code.ALREADY_EXISTS) + .underlying; + } + throw new RuntimeException("Failed to create topic: " + topicName, e); + }); + } + + @Override + public ApiFuture getTopic(TopicPath path) { + String topicName = path.name().value(); + + return toApiFuture( + kafkaAdmin.describeTopics(Collections.singleton(topicName)).allTopicNames(), + descriptions -> { + TopicDescription desc = descriptions.get(topicName); + if (desc == null) { + throw new CheckedApiException( + "Topic not found: " + topicName, StatusCode.Code.NOT_FOUND) + .underlying; + } + return buildTopic(path, desc); + }, + e -> { + if (e instanceof UnknownTopicOrPartitionException) { + throw new CheckedApiException( + "Topic not found: " + topicName, StatusCode.Code.NOT_FOUND) + .underlying; + } + throw new RuntimeException("Failed to get topic: " + topicName, e); + }); + } + + @Override + public ApiFuture getTopicPartitionCount(TopicPath path) { + String topicName = path.name().value(); + + return toApiFuture( + kafkaAdmin.describeTopics(Collections.singleton(topicName)).allTopicNames(), + descriptions -> { + TopicDescription desc = descriptions.get(topicName); + if (desc == null) { + throw new CheckedApiException( + "Topic not found: " + topicName, StatusCode.Code.NOT_FOUND) + .underlying; + } + return (long) desc.partitions().size(); + }, + e -> { + if (e instanceof UnknownTopicOrPartitionException) { + throw new CheckedApiException( + "Topic not found: " + topicName, StatusCode.Code.NOT_FOUND) + .underlying; + } + throw new RuntimeException("Failed to get partition count: " + topicName, e); + }); + } + + @Override + public ApiFuture> listTopics(LocationPath path) { + return toApiFuture( + kafkaAdmin.listTopics().names(), + topicNames -> { + List topics = new ArrayList<>(); + for (String name : topicNames) { + // Skip internal Kafka topics + if (!name.startsWith("__")) { + TopicPath topicPath = + TopicPath.newBuilder() + .setProject(path.project()) + .setLocation(path.location()) + .setName(com.google.cloud.pubsublite.TopicName.of(name)) + .build(); + topics.add(Topic.newBuilder().setName(topicPath.toString()).build()); + } + } + return topics; + }, + e -> { + throw new RuntimeException("Failed to list topics", e); + }); + } + + @Override + public ApiFuture updateTopic(Topic topic, FieldMask mask) { + // Kafka doesn't support most topic updates without recreation + // For now, just return the topic as-is (no-op for updates) + log.warning( + "Topic updates are not fully supported in Kafka backend. " + + "Some fields may not be updated: " + + mask); + return ApiFutures.immediateFuture(topic); + } + + @Override + public ApiFuture deleteTopic(TopicPath path) { + String topicName = path.name().value(); + + return toApiFuture( + kafkaAdmin.deleteTopics(Collections.singleton(topicName)).all(), + v -> null, + e -> { + if (e instanceof UnknownTopicOrPartitionException) { + throw new CheckedApiException( + "Topic not found: " + topicName, StatusCode.Code.NOT_FOUND) + .underlying; + } + throw new RuntimeException("Failed to delete topic: " + topicName, e); + }); + } + + @Override + public ApiFuture> listTopicSubscriptions(TopicPath path) { + // In Kafka, "subscriptions" are consumer groups + // This lists consumer groups that have committed offsets for this topic + String topicName = path.name().value(); + + return toApiFuture( + kafkaAdmin.listConsumerGroups().all(), + groups -> { + List subscriptions = new ArrayList<>(); + for (org.apache.kafka.clients.admin.ConsumerGroupListing group : groups) { + // Create a subscription path from the consumer group + SubscriptionPath subPath = + SubscriptionPath.newBuilder() + .setProject(path.project()) + .setLocation(path.location()) + .setName(com.google.cloud.pubsublite.SubscriptionName.of(group.groupId())) + .build(); + subscriptions.add(subPath); + } + return subscriptions; + }, + e -> { + throw new RuntimeException("Failed to list subscriptions for topic: " + topicName, e); + }); + } + + // Subscription Operations + // Subscriptions map to Kafka consumer groups + + @Override + public ApiFuture createSubscription( + Subscription subscription, BacklogLocation startingOffset) { + // In Kafka, consumer groups are created implicitly when a consumer joins + // We just validate the topic exists and return the subscription + String topicName = extractTopicName(subscription.getTopic()); + + return toApiFuture( + kafkaAdmin.describeTopics(Collections.singleton(topicName)).allTopicNames(), + descriptions -> { + if (!descriptions.containsKey(topicName)) { + throw new CheckedApiException( + "Topic not found: " + topicName, StatusCode.Code.NOT_FOUND) + .underlying; + } + return subscription; + }, + e -> { + if (e instanceof UnknownTopicOrPartitionException) { + throw new CheckedApiException( + "Topic not found: " + topicName, StatusCode.Code.NOT_FOUND) + .underlying; + } + throw new RuntimeException("Failed to create subscription", e); + }); + } + + @Override + public ApiFuture createSubscription(Subscription subscription, SeekTarget target) { + // Seek target is not directly supported in Kafka consumer group creation + // The seek would need to be done when the consumer connects + return createSubscription(subscription, BacklogLocation.END); + } + + @Override + public ApiFuture getSubscription(SubscriptionPath path) { + String groupId = path.name().value(); + + return toApiFuture( + kafkaAdmin.describeConsumerGroups(Collections.singleton(groupId)).all(), + descriptions -> { + if (!descriptions.containsKey(groupId)) { + throw new CheckedApiException( + "Subscription (consumer group) not found: " + groupId, + StatusCode.Code.NOT_FOUND) + .underlying; + } + return Subscription.newBuilder().setName(path.toString()).build(); + }, + e -> { + throw new RuntimeException("Failed to get subscription: " + groupId, e); + }); + } + + @Override + public ApiFuture> listSubscriptions(LocationPath path) { + return toApiFuture( + kafkaAdmin.listConsumerGroups().all(), + groups -> { + List subscriptions = new ArrayList<>(); + for (org.apache.kafka.clients.admin.ConsumerGroupListing group : groups) { + SubscriptionPath subPath = + SubscriptionPath.newBuilder() + .setProject(path.project()) + .setLocation(path.location()) + .setName(com.google.cloud.pubsublite.SubscriptionName.of(group.groupId())) + .build(); + subscriptions.add(Subscription.newBuilder().setName(subPath.toString()).build()); + } + return subscriptions; + }, + e -> { + throw new RuntimeException("Failed to list subscriptions", e); + }); + } + + @Override + public ApiFuture updateSubscription(Subscription subscription, FieldMask mask) { + // Consumer group configuration updates are limited in Kafka + log.warning("Subscription updates are limited in Kafka backend"); + return ApiFutures.immediateFuture(subscription); + } + + @Override + public OperationFuture seekSubscription( + SubscriptionPath path, SeekTarget target) { + // Kafka consumer group offset seeking is done via consumer API, not admin API + // This would require resetting consumer group offsets + throw new UnsupportedOperationException( + "Seek subscription is not directly supported in Kafka backend. " + + "Use consumer API to seek to specific offsets."); + } + + @Override + public ApiFuture deleteSubscription(SubscriptionPath path) { + String groupId = path.name().value(); + + return toApiFuture( + kafkaAdmin.deleteConsumerGroups(Collections.singleton(groupId)).all(), + v -> null, + e -> { + throw new RuntimeException( + "Failed to delete subscription (consumer group): " + groupId, e); + }); + } + + // Reservation Operations + // Reservations are PSL-specific and don't exist in Kafka - all are no-ops + + @Override + public ApiFuture createReservation(Reservation reservation) { + log.info("Reservations are not supported in Kafka backend. Operation is a no-op."); + return ApiFutures.immediateFuture(reservation); + } + + @Override + public ApiFuture getReservation(ReservationPath path) { + log.info("Reservations are not supported in Kafka backend. Returning empty reservation."); + return ApiFutures.immediateFuture(Reservation.newBuilder().setName(path.toString()).build()); + } + + @Override + public ApiFuture> listReservations(LocationPath path) { + log.info("Reservations are not supported in Kafka backend. Returning empty list."); + return ApiFutures.immediateFuture(Collections.emptyList()); + } + + @Override + public ApiFuture updateReservation(Reservation reservation, FieldMask mask) { + log.info("Reservations are not supported in Kafka backend. Operation is a no-op."); + return ApiFutures.immediateFuture(reservation); + } + + @Override + public ApiFuture deleteReservation(ReservationPath path) { + log.info("Reservations are not supported in Kafka backend. Operation is a no-op."); + return ApiFutures.immediateFuture(null); + } + + @Override + public ApiFuture> listReservationTopics(ReservationPath path) { + log.info("Reservations are not supported in Kafka backend. Returning empty list."); + return ApiFutures.immediateFuture(Collections.emptyList()); + } + + // Lifecycle + + @Override + public void close() { + shutdown(); + } + + @Override + public void shutdown() { + if (isShutdown.compareAndSet(false, true)) { + kafkaAdmin.close(); + isTerminated.set(true); + } + } + + @Override + public boolean isShutdown() { + return isShutdown.get(); + } + + @Override + public boolean isTerminated() { + return isTerminated.get(); + } + + @Override + public void shutdownNow() { + shutdown(); + } + + @Override + public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException { + return isTerminated.get(); + } + + // Helper Methods + + private String extractTopicName(String fullPath) { + int lastSlash = fullPath.lastIndexOf('/'); + return lastSlash >= 0 ? fullPath.substring(lastSlash + 1) : fullPath; + } + + private Topic buildTopic(TopicPath path, TopicDescription desc) { + return Topic.newBuilder() + .setName(path.toString()) + .setPartitionConfig( + com.google.cloud.pubsublite.proto.Topic.PartitionConfig.newBuilder() + .setCount(desc.partitions().size()) + .build()) + .build(); + } + + private ApiFuture toApiFuture( + KafkaFuture kafkaFuture, + java.util.function.Function successMapper, + java.util.function.Function errorMapper) { + return ApiFutures.transform( + ApiFutures.catching( + new KafkaFutureAdapter<>(kafkaFuture).toApiFuture(), + Throwable.class, + t -> { + throw errorMapper.apply(t); + }, + MoreExecutors.directExecutor()), + successMapper::apply, + MoreExecutors.directExecutor()); + } + + /** Adapter to convert KafkaFuture to ApiFuture. */ + private static class KafkaFutureAdapter { + private final KafkaFuture kafkaFuture; + + KafkaFutureAdapter(KafkaFuture kafkaFuture) { + this.kafkaFuture = kafkaFuture; + } + + ApiFuture toApiFuture() { + com.google.api.core.SettableApiFuture apiFuture = + com.google.api.core.SettableApiFuture.create(); + + kafkaFuture.whenComplete( + (result, error) -> { + if (error != null) { + apiFuture.setException(error); + } else { + apiFuture.set(result); + } + }); + + return apiFuture; + } + } +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaCursorClient.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaCursorClient.java new file mode 100644 index 000000000..f4dc2fc6b --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaCursorClient.java @@ -0,0 +1,287 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.internal; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.api.gax.rpc.StatusCode; +import com.google.cloud.pubsublite.CloudRegion; +import com.google.cloud.pubsublite.Offset; +import com.google.cloud.pubsublite.Partition; +import com.google.cloud.pubsublite.SubscriptionPath; +import com.google.cloud.pubsublite.proto.Cursor; +import com.google.cloud.pubsublite.proto.PartitionCursor; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.common.TopicPartition; + +/** + * A cursor client implementation for Kafka backend. + * + *

In Pub/Sub Lite, cursors are a first-class service with specific RPCs (commit, list, streaming + * commit). In Kafka, cursor management is handled via consumer group offsets. + * + *

This implementation provides helper functions that interact with Kafka's consumer group offset + * management: + * + *

    + *
  • {@link #commitOffset}: Save the position of the consumer for a partition + *
  • {@link #readCommittedOffsets}: Retrieve the saved position per partition + *
+ */ +public class KafkaCursorClient implements ApiBackgroundResource { + private static final Logger log = Logger.getLogger(KafkaCursorClient.class.getName()); + + private final CloudRegion region; + private final AdminClient kafkaAdmin; + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + private final AtomicBoolean isTerminated = new AtomicBoolean(false); + + /** + * Creates a new KafkaCursorClient. + * + * @param region The cloud region for this client. + * @param kafkaProperties Kafka connection properties (must include bootstrap.servers). + */ + public KafkaCursorClient(CloudRegion region, Map kafkaProperties) { + this.region = region; + Properties props = new Properties(); + props.putAll(kafkaProperties); + this.kafkaAdmin = AdminClient.create(props); + } + + /** The Google Cloud region this client operates on. */ + public CloudRegion region() { + return region; + } + + /** + * Commits an offset for a specific partition within a consumer group. + * + *

This maps to Kafka's consumer group offset commit functionality. + * + * @param subscriptionPath The subscription path (used to derive consumer group ID). + * @param topicName The Kafka topic name. + * @param partition The partition to commit offset for. + * @param offset The offset to commit (next message to be consumed). + * @return A future that completes when the offset is committed. + */ + public ApiFuture commitOffset( + SubscriptionPath subscriptionPath, String topicName, Partition partition, Offset offset) { + String groupId = deriveGroupId(subscriptionPath); + TopicPartition tp = new TopicPartition(topicName, (int) partition.value()); + Map offsets = new HashMap<>(); + offsets.put(tp, new OffsetAndMetadata(offset.value())); + + try { + kafkaAdmin.alterConsumerGroupOffsets(groupId, offsets).all().get(); + return ApiFutures.immediateFuture(null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException("Interrupted while committing offset", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to commit offset", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to commit offset: " + e.getCause().getMessage(), StatusCode.Code.INTERNAL) + .underlying); + } + } + + /** + * Reads the committed offsets for all partitions of a subscription (consumer group). + * + *

This maps to Kafka's listConsumerGroupOffsets functionality. + * + * @param subscriptionPath The subscription path (used to derive consumer group ID). + * @param topicName The Kafka topic name. + * @return A future containing a list of partition cursors with their committed offsets. + */ + public ApiFuture> readCommittedOffsets( + SubscriptionPath subscriptionPath, String topicName) { + String groupId = deriveGroupId(subscriptionPath); + + try { + ListConsumerGroupOffsetsResult result = kafkaAdmin.listConsumerGroupOffsets(groupId); + Map offsets = result.partitionsToOffsetAndMetadata().get(); + + List cursors = new ArrayList<>(); + for (Map.Entry entry : offsets.entrySet()) { + TopicPartition tp = entry.getKey(); + OffsetAndMetadata oam = entry.getValue(); + + // Filter to only the requested topic + if (tp.topic().equals(topicName) && oam != null) { + cursors.add( + PartitionCursor.newBuilder() + .setPartition(tp.partition()) + .setCursor(Cursor.newBuilder().setOffset(oam.offset()).build()) + .build()); + } + } + + return ApiFutures.immediateFuture(cursors); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Interrupted while reading committed offsets", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to read committed offsets", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to read committed offsets: " + e.getCause().getMessage(), + StatusCode.Code.INTERNAL) + .underlying); + } + } + + /** + * Gets the committed offset for a specific partition. + * + * @param subscriptionPath The subscription path (used to derive consumer group ID). + * @param topicName The Kafka topic name. + * @param partition The partition to get offset for. + * @return A future containing the cursor with the committed offset, or empty if not committed. + */ + public ApiFuture getCommittedOffset( + SubscriptionPath subscriptionPath, String topicName, Partition partition) { + String groupId = deriveGroupId(subscriptionPath); + TopicPartition tp = new TopicPartition(topicName, (int) partition.value()); + + try { + ListConsumerGroupOffsetsResult result = kafkaAdmin.listConsumerGroupOffsets(groupId); + Map offsets = result.partitionsToOffsetAndMetadata().get(); + + OffsetAndMetadata oam = offsets.get(tp); + if (oam != null) { + return ApiFutures.immediateFuture(Cursor.newBuilder().setOffset(oam.offset()).build()); + } else { + // No committed offset, return offset 0 + return ApiFutures.immediateFuture(Cursor.newBuilder().setOffset(0).build()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Interrupted while getting committed offset", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to get committed offset", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to get committed offset: " + e.getCause().getMessage(), + StatusCode.Code.INTERNAL) + .underlying); + } + } + + /** + * Resets offsets for a consumer group to a specific position. + * + * @param subscriptionPath The subscription path (used to derive consumer group ID). + * @param topicName The Kafka topic name. + * @param partitionOffsets Map of partition to target offset. + * @return A future that completes when offsets are reset. + */ + public ApiFuture resetOffsets( + SubscriptionPath subscriptionPath, + String topicName, + Map partitionOffsets) { + String groupId = deriveGroupId(subscriptionPath); + Map offsets = new HashMap<>(); + + for (Map.Entry entry : partitionOffsets.entrySet()) { + TopicPartition tp = new TopicPartition(topicName, (int) entry.getKey().value()); + offsets.put(tp, new OffsetAndMetadata(entry.getValue().value())); + } + + try { + kafkaAdmin.alterConsumerGroupOffsets(groupId, offsets).all().get(); + return ApiFutures.immediateFuture(null); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException("Interrupted while resetting offsets", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to reset offsets", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to reset offsets: " + e.getCause().getMessage(), StatusCode.Code.INTERNAL) + .underlying); + } + } + + /** + * Derives a Kafka consumer group ID from a subscription path. + * + *

The group ID is derived by replacing slashes with dashes to create a valid Kafka group ID. + */ + private String deriveGroupId(SubscriptionPath subscriptionPath) { + return subscriptionPath.toString().replace('/', '-'); + } + + // Lifecycle + + @Override + public void close() { + shutdown(); + } + + @Override + public void shutdown() { + if (isShutdown.compareAndSet(false, true)) { + kafkaAdmin.close(); + isTerminated.set(true); + } + } + + @Override + public boolean isShutdown() { + return isShutdown.get(); + } + + @Override + public boolean isTerminated() { + return isTerminated.get(); + } + + @Override + public void shutdownNow() { + shutdown(); + } + + @Override + public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException { + return isTerminated.get(); + } +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaTopicStatsClient.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaTopicStatsClient.java new file mode 100644 index 000000000..5400f18f5 --- /dev/null +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/KafkaTopicStatsClient.java @@ -0,0 +1,435 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.internal; + +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.api.gax.rpc.StatusCode; +import com.google.cloud.pubsublite.CloudRegion; +import com.google.cloud.pubsublite.Offset; +import com.google.cloud.pubsublite.Partition; +import com.google.cloud.pubsublite.SubscriptionPath; +import com.google.cloud.pubsublite.TopicPath; +import com.google.cloud.pubsublite.proto.ComputeMessageStatsResponse; +import com.google.cloud.pubsublite.proto.Cursor; +import com.google.protobuf.Timestamp; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.Properties; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.ListOffsetsResult; +import org.apache.kafka.clients.admin.ListOffsetsResult.ListOffsetsResultInfo; +import org.apache.kafka.clients.admin.OffsetSpec; +import org.apache.kafka.clients.consumer.OffsetAndMetadata; +import org.apache.kafka.common.TopicPartition; + +/** + * A topic stats client implementation for Kafka backend. + * + *

Pub/Sub Lite has specific RPCs to query topic statistics. Kafka does not support these RPCs + * directly, so this implementation provides helper methods that calculate stats based on Kafka + * primitives: + * + *

    + *
  • {@link #getEarliestOffset}: The oldest available message offset in the partition + *
  • {@link #getLatestOffset}: The newest message offset in the partition (head cursor) + *
  • {@link #computeBacklogBytes}: Approximate backlog calculated by comparing consumer offset + * against latest offset and estimating based on average message size + *
  • {@link #computeMessageStats}: Approximate message stats between two offsets + *
+ */ +public class KafkaTopicStatsClient implements TopicStatsClient { + private static final Logger log = Logger.getLogger(KafkaTopicStatsClient.class.getName()); + + // Default average message size estimate (in bytes) when we can't calculate it + private static final long DEFAULT_AVG_MESSAGE_SIZE = 1024; // 1KB + + private final CloudRegion region; + private final AdminClient kafkaAdmin; + private final AtomicBoolean isShutdown = new AtomicBoolean(false); + private final AtomicBoolean isTerminated = new AtomicBoolean(false); + + /** + * Creates a new KafkaTopicStatsClient. + * + * @param region The cloud region for this client. + * @param kafkaProperties Kafka connection properties (must include bootstrap.servers). + */ + public KafkaTopicStatsClient(CloudRegion region, Map kafkaProperties) { + this.region = region; + Properties props = new Properties(); + props.putAll(kafkaProperties); + this.kafkaAdmin = AdminClient.create(props); + } + + @Override + public CloudRegion region() { + return region; + } + + /** + * Gets the earliest (oldest) available offset for a partition. + * + *

This is the offset of the oldest message still available in the partition (messages before + * this have been deleted due to retention policies). + * + * @param topicName The Kafka topic name. + * @param partition The partition to query. + * @return A future containing the earliest offset. + */ + public ApiFuture getEarliestOffset(String topicName, Partition partition) { + TopicPartition tp = new TopicPartition(topicName, (int) partition.value()); + Map request = new HashMap<>(); + request.put(tp, OffsetSpec.earliest()); + + try { + ListOffsetsResult result = kafkaAdmin.listOffsets(request); + ListOffsetsResultInfo info = result.partitionResult(tp).get(); + return ApiFutures.immediateFuture(Offset.of(info.offset())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Interrupted while getting earliest offset", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to get earliest offset", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to get earliest offset: " + e.getCause().getMessage(), + StatusCode.Code.INTERNAL) + .underlying); + } + } + + /** + * Gets the latest (newest) offset for a partition. + * + *

This is the offset that will be assigned to the next message published to the partition. + * + * @param topicName The Kafka topic name. + * @param partition The partition to query. + * @return A future containing the latest offset. + */ + public ApiFuture getLatestOffset(String topicName, Partition partition) { + TopicPartition tp = new TopicPartition(topicName, (int) partition.value()); + Map request = new HashMap<>(); + request.put(tp, OffsetSpec.latest()); + + try { + ListOffsetsResult result = kafkaAdmin.listOffsets(request); + ListOffsetsResultInfo info = result.partitionResult(tp).get(); + return ApiFutures.immediateFuture(Offset.of(info.offset())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Interrupted while getting latest offset", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to get latest offset", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to get latest offset: " + e.getCause().getMessage(), + StatusCode.Code.INTERNAL) + .underlying); + } + } + + /** + * Gets the offset for a specific timestamp. + * + *

Returns the earliest offset whose timestamp is greater than or equal to the given timestamp. + * + * @param topicName The Kafka topic name. + * @param partition The partition to query. + * @param timestampMs The target timestamp in milliseconds since epoch. + * @return A future containing the offset, or empty if no message exists at or after the + * timestamp. + */ + public ApiFuture> getOffsetForTimestamp( + String topicName, Partition partition, long timestampMs) { + TopicPartition tp = new TopicPartition(topicName, (int) partition.value()); + Map request = new HashMap<>(); + request.put(tp, OffsetSpec.forTimestamp(timestampMs)); + + try { + ListOffsetsResult result = kafkaAdmin.listOffsets(request); + ListOffsetsResultInfo info = result.partitionResult(tp).get(); + + if (info.offset() >= 0) { + return ApiFutures.immediateFuture(Optional.of(Offset.of(info.offset()))); + } else { + return ApiFutures.immediateFuture(Optional.empty()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Interrupted while getting offset for timestamp", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to get offset for timestamp", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to get offset for timestamp: " + e.getCause().getMessage(), + StatusCode.Code.INTERNAL) + .underlying); + } + } + + /** + * Computes the approximate backlog in bytes for a consumer group on a partition. + * + *

The backlog is calculated by: + * + *

    + *
  1. Getting the consumer's committed offset + *
  2. Getting the latest offset (log end offset) + *
  3. Calculating the difference (number of unconsumed messages) + *
  4. Multiplying by the estimated average message size + *
+ * + *

Note: This is an approximation. For exact values, Kafka would need to sum the actual sizes + * of all unconsumed messages, which is not efficiently supported. + * + * @param topicName The Kafka topic name. + * @param partition The partition to query. + * @param subscriptionPath The subscription path (used to derive consumer group ID). + * @param estimatedAvgMessageSize The estimated average message size in bytes (use 0 for default). + * @return A future containing the BacklogInfo with message count and byte estimate. + */ + public ApiFuture computeBacklogBytes( + String topicName, + Partition partition, + SubscriptionPath subscriptionPath, + long estimatedAvgMessageSize) { + String groupId = subscriptionPath.toString().replace('/', '-'); + TopicPartition tp = new TopicPartition(topicName, (int) partition.value()); + + try { + // Get committed offset for the consumer group + Map committedOffsets = + kafkaAdmin.listConsumerGroupOffsets(groupId).partitionsToOffsetAndMetadata().get(); + + // Get latest offset + Map latestRequest = new HashMap<>(); + latestRequest.put(tp, OffsetSpec.latest()); + ListOffsetsResultInfo latestInfo = + kafkaAdmin.listOffsets(latestRequest).partitionResult(tp).get(); + + long latestOffset = latestInfo.offset(); + long committedOffset = 0; + + OffsetAndMetadata oam = committedOffsets.get(tp); + if (oam != null) { + committedOffset = oam.offset(); + } else { + // No committed offset - use earliest offset as the starting point + Map earliestRequest = new HashMap<>(); + earliestRequest.put(tp, OffsetSpec.earliest()); + ListOffsetsResultInfo earliestInfo = + kafkaAdmin.listOffsets(earliestRequest).partitionResult(tp).get(); + committedOffset = earliestInfo.offset(); + } + + long messageCount = Math.max(0, latestOffset - committedOffset); + long avgSize = + estimatedAvgMessageSize > 0 ? estimatedAvgMessageSize : DEFAULT_AVG_MESSAGE_SIZE; + long estimatedBytes = messageCount * avgSize; + + return ApiFutures.immediateFuture(new BacklogInfo(messageCount, estimatedBytes)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException("Interrupted while computing backlog", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to compute backlog", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to compute backlog: " + e.getCause().getMessage(), + StatusCode.Code.INTERNAL) + .underlying); + } + } + + /** Container for backlog information. */ + public static class BacklogInfo { + private final long messageCount; + private final long estimatedBytes; + + public BacklogInfo(long messageCount, long estimatedBytes) { + this.messageCount = messageCount; + this.estimatedBytes = estimatedBytes; + } + + /** The number of unconsumed messages. */ + public long getMessageCount() { + return messageCount; + } + + /** The estimated size in bytes of unconsumed messages. */ + public long getEstimatedBytes() { + return estimatedBytes; + } + + @Override + public String toString() { + return String.format( + "BacklogInfo{messages=%d, estimatedBytes=%d}", messageCount, estimatedBytes); + } + } + + // TopicStatsClient Interface Implementation + + @Override + public ApiFuture computeMessageStats( + TopicPath path, Partition partition, Offset start, Offset end) { + // Kafka doesn't provide detailed message stats like PSL does. + // We can only provide an approximation based on offset difference. + long messageCount = Math.max(0, end.value() - start.value()); + + // Approximate byte count using default message size + long estimatedBytes = messageCount * DEFAULT_AVG_MESSAGE_SIZE; + + // For minimum publish time, we'd need to fetch actual messages which is expensive. + // Return a response with what we can calculate. + return ApiFutures.immediateFuture( + ComputeMessageStatsResponse.newBuilder() + .setMessageCount(messageCount) + .setMessageBytes(estimatedBytes) + // We cannot efficiently compute min publish time without reading messages + .build()); + } + + @Override + public ApiFuture computeHeadCursor(TopicPath path, Partition partition) { + String topicName = path.name().value(); + TopicPartition tp = new TopicPartition(topicName, (int) partition.value()); + Map request = new HashMap<>(); + request.put(tp, OffsetSpec.latest()); + + try { + ListOffsetsResult result = kafkaAdmin.listOffsets(request); + ListOffsetsResultInfo info = result.partitionResult(tp).get(); + return ApiFutures.immediateFuture(Cursor.newBuilder().setOffset(info.offset()).build()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Interrupted while computing head cursor", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to compute head cursor", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to compute head cursor: " + e.getCause().getMessage(), + StatusCode.Code.INTERNAL) + .underlying); + } + } + + @Override + public ApiFuture> computeCursorForPublishTime( + TopicPath path, Partition partition, Timestamp publishTime) { + String topicName = path.name().value(); + long timestampMs = publishTime.getSeconds() * 1000 + publishTime.getNanos() / 1_000_000; + + TopicPartition tp = new TopicPartition(topicName, (int) partition.value()); + Map request = new HashMap<>(); + request.put(tp, OffsetSpec.forTimestamp(timestampMs)); + + try { + ListOffsetsResult result = kafkaAdmin.listOffsets(request); + ListOffsetsResultInfo info = result.partitionResult(tp).get(); + + if (info.offset() >= 0) { + return ApiFutures.immediateFuture( + Optional.of(Cursor.newBuilder().setOffset(info.offset()).build())); + } else { + return ApiFutures.immediateFuture(Optional.empty()); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Interrupted while computing cursor for publish time", StatusCode.Code.ABORTED) + .underlying); + } catch (ExecutionException e) { + log.log(Level.WARNING, "Failed to compute cursor for publish time", e); + return ApiFutures.immediateFailedFuture( + new CheckedApiException( + "Failed to compute cursor for publish time: " + e.getCause().getMessage(), + StatusCode.Code.INTERNAL) + .underlying); + } + } + + @Override + public ApiFuture> computeCursorForEventTime( + TopicPath path, Partition partition, Timestamp eventTime) { + // Kafka doesn't have a concept of event time in the same way as PSL. + // We can only use the message timestamp, which corresponds to publish time. + // For event time queries, we fall back to publish time behavior. + log.warning( + "Kafka does not support event time queries. " + + "Falling back to publish time behavior for cursor computation."); + return computeCursorForPublishTime(path, partition, eventTime); + } + + // Lifecycle + + @Override + public void close() { + shutdown(); + } + + @Override + public void shutdown() { + if (isShutdown.compareAndSet(false, true)) { + kafkaAdmin.close(); + isTerminated.set(true); + } + } + + @Override + public boolean isShutdown() { + return isShutdown.get(); + } + + @Override + public boolean isTerminated() { + return isTerminated.get(); + } + + @Override + public void shutdownNow() { + shutdown(); + } + + @Override + public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException { + return isTerminated.get(); + } +} diff --git a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/TopicStatsClientSettings.java b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/TopicStatsClientSettings.java index adbc5aa96..c412d7eb7 100644 --- a/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/TopicStatsClientSettings.java +++ b/google-cloud-pubsublite/src/main/java/com/google/cloud/pubsublite/internal/TopicStatsClientSettings.java @@ -21,8 +21,10 @@ import com.google.api.gax.rpc.ApiException; import com.google.auto.value.AutoValue; import com.google.cloud.pubsublite.CloudRegion; +import com.google.cloud.pubsublite.cloudpubsub.MessagingBackend; import com.google.cloud.pubsublite.v1.TopicStatsServiceClient; import com.google.cloud.pubsublite.v1.TopicStatsServiceSettings; +import java.util.Map; import java.util.Optional; @AutoValue @@ -34,8 +36,15 @@ public abstract class TopicStatsClientSettings { // Optional parameters. abstract Optional serviceClient(); + /** The backend messaging system to use (e.g., PUBSUB_LITE or MANAGED_KAFKA). */ + public abstract MessagingBackend messagingBackend(); + + /** Kafka-specific properties for when using MANAGED_KAFKA backend. */ + public abstract Optional> kafkaProperties(); + public static Builder newBuilder() { - return new AutoValue_TopicStatsClientSettings.Builder(); + return new AutoValue_TopicStatsClientSettings.Builder() + .setMessagingBackend(MessagingBackend.PUBSUB_LITE); } @AutoValue.Builder @@ -47,10 +56,26 @@ public abstract static class Builder { // Optional parameters. public abstract Builder setServiceClient(TopicStatsServiceClient stub); + /** Set the backend messaging system to use (e.g., PUBSUB_LITE or MANAGED_KAFKA). */ + public abstract Builder setMessagingBackend(MessagingBackend backend); + + /** Set Kafka-specific properties for when using MANAGED_KAFKA backend. */ + public abstract Builder setKafkaProperties(Map kafkaProperties); + public abstract TopicStatsClientSettings build(); } TopicStatsClient instantiate() throws ApiException { + // For Kafka backend, use KafkaTopicStatsClient + if (messagingBackend() == MessagingBackend.MANAGED_KAFKA) { + if (!kafkaProperties().isPresent()) { + throw new IllegalStateException( + "kafkaProperties must be set when using MANAGED_KAFKA backend"); + } + return new KafkaTopicStatsClient(region(), kafkaProperties().get()); + } + + // For Pub/Sub Lite backend, use TopicStatsClientImpl TopicStatsServiceClient serviceClient; if (serviceClient().isPresent()) { serviceClient = serviceClient().get(); diff --git a/google-cloud-pubsublite/src/test/java/com/google/cloud/pubsublite/cloudpubsub/KafkaBackendTest.java b/google-cloud-pubsublite/src/test/java/com/google/cloud/pubsublite/cloudpubsub/KafkaBackendTest.java new file mode 100644 index 000000000..f3603c139 --- /dev/null +++ b/google-cloud-pubsublite/src/test/java/com/google/cloud/pubsublite/cloudpubsub/KafkaBackendTest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.cloudpubsub; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.cloud.pubsublite.CloudRegion; +import com.google.cloud.pubsublite.CloudZone; +import com.google.cloud.pubsublite.ProjectNumber; +import com.google.cloud.pubsublite.TopicName; +import com.google.cloud.pubsublite.TopicPath; +import com.google.cloud.pubsublite.cloudpubsub.internal.KafkaPartitionPublisherFactory; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public class KafkaBackendTest { + + private static final TopicPath TOPIC_PATH = + TopicPath.newBuilder() + .setProject(ProjectNumber.of(123456789L)) + .setLocation(CloudZone.of(CloudRegion.of("us-central1"), 'a')) + .setName(TopicName.of("test-topic")) + .build(); + + @Test + public void testDefaultBackendIsPubSubLite() { + PublisherSettings settings = PublisherSettings.newBuilder().setTopicPath(TOPIC_PATH).build(); + + assertThat(settings.messagingBackend()).isEqualTo(MessagingBackend.PUBSUB_LITE); + } + + @Test + public void testKafkaBackendSelection() { + Map kafkaProps = new HashMap<>(); + kafkaProps.put("bootstrap.servers", "localhost:9092"); + + PublisherSettings settings = + PublisherSettings.newBuilder() + .setTopicPath(TOPIC_PATH) + .setMessagingBackend(MessagingBackend.MANAGED_KAFKA) + .setKafkaProperties(kafkaProps) + .build(); + + assertThat(settings.messagingBackend()).isEqualTo(MessagingBackend.MANAGED_KAFKA); + assertThat(settings.kafkaProperties()).isPresent(); + assertThat(settings.kafkaProperties().get()) + .containsEntry("bootstrap.servers", "localhost:9092"); + } + + @Test + public void testKafkaFactoryCreation() throws Exception { + Map kafkaProps = new HashMap<>(); + kafkaProps.put("bootstrap.servers", "localhost:9092"); + + PublisherSettings settings = + PublisherSettings.newBuilder() + .setTopicPath(TOPIC_PATH) + .setMessagingBackend(MessagingBackend.MANAGED_KAFKA) + .setKafkaProperties(kafkaProps) + .build(); + + // This should create a Kafka factory successfully + // (connection is only attempted when actually publishing) + KafkaPartitionPublisherFactory factory = new KafkaPartitionPublisherFactory(settings); + assertThat(factory).isNotNull(); + factory.close(); // Clean up + } + + @Test + public void testKafkaPropertiesOptional() { + PublisherSettings settings = + PublisherSettings.newBuilder() + .setTopicPath(TOPIC_PATH) + .setMessagingBackend(MessagingBackend.PUBSUB_LITE) + .build(); + + assertThat(settings.kafkaProperties()).isEmpty(); + } + + @Test + public void testBackwardCompatibility() { + // Test that existing code without backend specification still works + PublisherSettings settings = + PublisherSettings.newBuilder() + .setTopicPath(TOPIC_PATH) + .setBatchingSettings(PublisherSettings.DEFAULT_BATCHING_SETTINGS) + .setEnableIdempotence(true) + .build(); + + // Should default to Pub/Sub Lite + assertThat(settings.messagingBackend()).isEqualTo(MessagingBackend.PUBSUB_LITE); + assertThat(settings.kafkaProperties()).isEmpty(); + } +} diff --git a/google-cloud-pubsublite/src/test/java/com/google/cloud/pubsublite/cloudpubsub/ManagedKafkaSubscriberTest.java b/google-cloud-pubsublite/src/test/java/com/google/cloud/pubsublite/cloudpubsub/ManagedKafkaSubscriberTest.java new file mode 100644 index 000000000..37562b856 --- /dev/null +++ b/google-cloud-pubsublite/src/test/java/com/google/cloud/pubsublite/cloudpubsub/ManagedKafkaSubscriberTest.java @@ -0,0 +1,275 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed 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 com.google.cloud.pubsublite.cloudpubsub; + +import com.google.api.core.ApiFuture; +import com.google.cloud.pubsub.v1.AckReplyConsumer; +import com.google.cloud.pubsub.v1.MessageReceiver; +import com.google.cloud.pubsublite.CloudRegion; +import com.google.cloud.pubsublite.CloudZone; +import com.google.cloud.pubsublite.ProjectNumber; +import com.google.cloud.pubsublite.SubscriptionName; +import com.google.cloud.pubsublite.SubscriptionPath; +import com.google.cloud.pubsublite.TopicName; +import com.google.cloud.pubsublite.TopicPath; +import com.google.protobuf.ByteString; +import com.google.pubsub.v1.PubsubMessage; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Test for Google Managed Kafka subscriber. + * + *

This test publishes messages to a Kafka topic, then subscribes to receive them. + * + *

Run with: ./test-managed-kafka-subscriber.sh BOOTSTRAP_SERVER TOPIC_NAME [NUM_MESSAGES] + * + *

Example: ./test-managed-kafka-subscriber.sh + * bootstrap.test-kafka.us-central1.managedkafka.myproject.cloud.goog:9092 test-topic 5 + */ +public class ManagedKafkaSubscriberTest { + + public static void main(String[] args) throws Exception { + // Get config from args + String bootstrapServers = args.length > 0 ? args[0] : null; + String topicName = args.length > 1 ? args[1] : null; + int numMessages = args.length > 2 ? Integer.parseInt(args[2]) : 5; + + if (bootstrapServers == null || topicName == null) { + System.out.println( + "Usage: ManagedKafkaSubscriberTest [NUM_MESSAGES]"); + System.out.println(); + System.out.println("Example:"); + System.out.println( + " ./test-managed-kafka-subscriber.sh" + + " bootstrap.test-kafka.us-central1.managedkafka.myproject.cloud.goog:9092" + + " test-topic 5"); + System.exit(1); + } + + System.out.println("=== Google Managed Kafka Publisher + Subscriber Test ==="); + System.out.println(); + System.out.println("Configuration:"); + System.out.println(" Bootstrap: " + bootstrapServers); + System.out.println(" Topic: " + topicName); + System.out.println(" NumMessages: " + numMessages); + System.out.println(); + + // Build paths (project/zone not used for Kafka, but required by API) + TopicPath topicPath = + TopicPath.newBuilder() + .setProject(ProjectNumber.of(1L)) + .setLocation(CloudZone.of(CloudRegion.of("us-central1"), 'a')) + .setName(TopicName.of(topicName)) + .build(); + + SubscriptionPath subscriptionPath = + SubscriptionPath.newBuilder() + .setProject(ProjectNumber.of(1L)) + .setLocation(CloudZone.of(CloudRegion.of("us-central1"), 'a')) + .setName(SubscriptionName.of(topicName)) + .build(); + + // Kafka properties for Google Managed Kafka + Map kafkaProps = new HashMap<>(); + kafkaProps.put("bootstrap.servers", bootstrapServers); + kafkaProps.put("security.protocol", "SASL_SSL"); + kafkaProps.put("sasl.mechanism", "OAUTHBEARER"); + kafkaProps.put( + "sasl.login.callback.handler.class", + "com.google.cloud.hosted.kafka.auth.GcpLoginCallbackHandler"); + kafkaProps.put( + "sasl.jaas.config", + "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required;"); + + // Publisher reliability settings + kafkaProps.put("acks", "all"); + kafkaProps.put("retries", 3); + kafkaProps.put("request.timeout.ms", 30000); + + // Test state + String testId = "test-" + System.currentTimeMillis(); + Map sentMessages = new ConcurrentHashMap<>(); + Map receivedMessages = new ConcurrentHashMap<>(); + AtomicInteger receivedCount = new AtomicInteger(0); + CountDownLatch receiveLatch = new CountDownLatch(numMessages); + + // Create message receiver + MessageReceiver receiver = + new MessageReceiver() { + @Override + public void receiveMessage(PubsubMessage message, AckReplyConsumer consumer) { + String data = message.getData().toStringUtf8(); + String messageId = message.getMessageId(); + + System.out.println(" Received: " + data); + System.out.println(" Message ID: " + messageId); + System.out.println(" Ordering Key: " + message.getOrderingKey()); + System.out.println(" Attributes: " + message.getAttributesMap()); + System.out.println(" Publish Time: " + message.getPublishTime()); + System.out.println(); + + receivedMessages.put(data, message); + receivedCount.incrementAndGet(); + receiveLatch.countDown(); + + // Acknowledge the message + consumer.ack(); + } + }; + + // ========== Phase 1: Publish messages ========== + System.out.println("=== Phase 1: Publishing Messages ==="); + System.out.println(); + + PublisherSettings publisherSettings = + PublisherSettings.newBuilder() + .setTopicPath(topicPath) + .setMessagingBackend(MessagingBackend.MANAGED_KAFKA) + .setKafkaProperties(kafkaProps) + .build(); + + Publisher publisher = Publisher.create(publisherSettings); + + try { + System.out.println("Starting publisher..."); + publisher.startAsync().awaitRunning(); + System.out.println("Publisher started successfully!"); + System.out.println(); + + // Send test messages + for (int i = 1; i <= numMessages; i++) { + String payload = testId + ":message-" + i; + + PubsubMessage message = + PubsubMessage.newBuilder() + .setData(ByteString.copyFromUtf8(payload)) + .putAttributes("test_id", testId) + .putAttributes("index", String.valueOf(i)) + .putAttributes("total", String.valueOf(numMessages)) + .setOrderingKey("test-ordering-key") + .build(); + + System.out.println("Publishing: " + payload); + ApiFuture future = publisher.publish(message); + + String messageId = future.get(30, TimeUnit.SECONDS); + System.out.println(" -> Published with ID: " + messageId); + sentMessages.put(payload, true); + } + + System.out.println(); + System.out.println("All " + numMessages + " messages published successfully!"); + System.out.println(); + + } finally { + System.out.println("Stopping publisher..."); + publisher.stopAsync().awaitTerminated(); + } + + // ========== Phase 2: Subscribe and receive messages ========== + System.out.println(); + System.out.println("=== Phase 2: Subscribing to Messages ==="); + System.out.println(); + + // Add consumer-specific properties + Map consumerProps = new HashMap<>(kafkaProps); + consumerProps.put("group.id", "test-consumer-" + testId); + consumerProps.put("auto.offset.reset", "earliest"); // Read from beginning + + SubscriberSettings subscriberSettings = + SubscriberSettings.newBuilder() + .setSubscriptionPath(subscriptionPath) + .setReceiver(receiver) + .setPerPartitionFlowControlSettings( + FlowControlSettings.builder() + .setMessagesOutstanding(1000) + .setBytesOutstanding(100 * 1024 * 1024) // 100MB + .build()) + .setMessagingBackend(MessagingBackend.MANAGED_KAFKA) + .setKafkaProperties(consumerProps) + .build(); + + Subscriber subscriber = Subscriber.create(subscriberSettings); + + try { + System.out.println("Starting subscriber..."); + subscriber.startAsync().awaitRunning(); + System.out.println("Subscriber started successfully!"); + System.out.println(); + System.out.println("Waiting to receive " + numMessages + " messages (timeout: 60s)..."); + System.out.println(); + + // Wait for messages + boolean allReceived = receiveLatch.await(60, TimeUnit.SECONDS); + + System.out.println(); + if (allReceived) { + System.out.println("=== TEST RESULTS ==="); + System.out.println(); + System.out.println("Messages sent: " + numMessages); + System.out.println("Messages received: " + receivedCount.get()); + System.out.println(); + + // Verify received messages + int matched = 0; + for (String sentPayload : sentMessages.keySet()) { + if (receivedMessages.containsKey(sentPayload)) { + matched++; + System.out.println(" [MATCHED] " + sentPayload); + } else { + System.out.println(" [MISSING] " + sentPayload); + } + } + + System.out.println(); + if (matched == numMessages) { + System.out.println("=== TEST PASSED ==="); + System.out.println("All messages published and received successfully!"); + } else { + System.out.println("=== TEST PARTIAL ==="); + System.out.println("Only " + matched + " of " + numMessages + " messages matched."); + } + } else { + System.out.println("=== TEST TIMEOUT ==="); + System.out.println( + "Only received " + + receivedCount.get() + + " of " + + numMessages + + " messages before timeout."); + System.exit(1); + } + + } catch (Exception e) { + System.out.println(); + System.out.println("=== TEST FAILED ==="); + System.out.println("Error: " + e.getMessage()); + e.printStackTrace(); + System.exit(1); + } finally { + System.out.println(); + System.out.println("Stopping subscriber..."); + subscriber.stopAsync().awaitTerminated(); + System.out.println("Done."); + } + } +}