deleteSchemaBundleC
}
@Override
- public final void close() {
+ public void close() {
stub.close();
}
diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/BigtableInstanceAdminClientV2.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/BigtableInstanceAdminClientV2.java
new file mode 100644
index 000000000000..cd31e38f68f5
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/BigtableInstanceAdminClientV2.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2;
+
+import com.google.cloud.bigtable.admin.v2.stub.BigtableInstanceAdminStub;
+import java.io.IOException;
+
+/**
+ * Modern Cloud Bigtable Instance Admin Client.
+ *
+ * This client extends the {@link BaseBigtableInstanceAdminClient} to provide a simplified and
+ * enhanced API surface for managing Cloud Bigtable instances and clusters.
+ */
+public class BigtableInstanceAdminClientV2 extends BaseBigtableInstanceAdminClient {
+
+ protected BigtableInstanceAdminClientV2(BaseBigtableInstanceAdminSettings settings)
+ throws IOException {
+ super(settings);
+ }
+
+ protected BigtableInstanceAdminClientV2(BigtableInstanceAdminStub stub) {
+ super(stub);
+ }
+
+ /** Constructs an instance of BigtableInstanceAdminClientV2 with the given settings. */
+ public static final BigtableInstanceAdminClientV2 create(
+ BaseBigtableInstanceAdminSettings settings) throws IOException {
+ return new BigtableInstanceAdminClientV2(settings);
+ }
+}
diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/BigtableTableAdminClientV2.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/BigtableTableAdminClientV2.java
new file mode 100644
index 000000000000..8d16e229d1e7
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/BigtableTableAdminClientV2.java
@@ -0,0 +1,432 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2;
+
+import com.google.api.core.ApiClock;
+import com.google.api.core.ApiFunction;
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.api.gax.grpc.GrpcCallContext;
+import com.google.api.gax.grpc.GrpcCallSettings;
+import com.google.api.gax.grpc.GrpcCallableFactory;
+import com.google.api.gax.grpc.ProtoOperationTransformers.MetadataTransformer;
+import com.google.api.gax.grpc.ProtoOperationTransformers.ResponseTransformer;
+import com.google.api.gax.longrunning.OperationSnapshot;
+import com.google.api.gax.longrunning.OperationTimedPollAlgorithm;
+import com.google.api.gax.retrying.RetrySettings;
+import com.google.api.gax.rpc.ApiExceptions;
+import com.google.api.gax.rpc.ClientContext;
+import com.google.api.gax.rpc.OperationCallSettings;
+import com.google.api.gax.rpc.OperationCallable;
+import com.google.api.gax.rpc.UnaryCallSettings;
+import com.google.api.gax.rpc.UnaryCallable;
+import com.google.bigtable.admin.v2.OptimizeRestoredTableMetadata;
+import com.google.bigtable.admin.v2.TableName;
+import com.google.cloud.bigtable.admin.v2.stub.AwaitConsistencyCallableV2;
+import com.google.cloud.bigtable.admin.v2.stub.BigtableTableAdminStubSettings;
+import com.google.cloud.bigtable.admin.v2.stub.GrpcBigtableTableAdminStub;
+import com.google.common.base.Strings;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.longrunning.Operation;
+import com.google.protobuf.Empty;
+import io.grpc.MethodDescriptor;
+import io.grpc.MethodDescriptor.Marshaller;
+import io.grpc.protobuf.ProtoUtils;
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import javax.annotation.Nullable;
+
+/**
+ * Modern Cloud Bigtable Table Admin Client.
+ *
+ *
This client extends the {@link BaseBigtableTableAdminClient} to provide enhanced convenience
+ * methods for table administration. It improves the user experience by handling chained Long
+ * Running Operations (such as seamlessly restoring and then optimizing a table) and provides
+ * built-in, automated polling for consistency tokens.
+ */
+public class BigtableTableAdminClientV2 extends BaseBigtableTableAdminClient {
+ private final AwaitConsistencyCallableV2 awaitConsistencyCallable;
+ private final OperationCallable
+ optimizeRestoredTableOperationBaseCallable;
+ private final ScheduledExecutorService backgroundExecutor;
+ private final boolean shouldAutoClose;
+
+ private static final RetrySettings AWAIT_CONSISTENCY_POLLING_SETTINGS_BASE =
+ RetrySettings.newBuilder()
+ .setInitialRetryDelayDuration(Duration.ofSeconds(10))
+ .setRetryDelayMultiplier(1.0)
+ .setMaxRetryDelayDuration(Duration.ofSeconds(10))
+ .setInitialRpcTimeoutDuration(Duration.ZERO)
+ .setMaxRpcTimeoutDuration(Duration.ZERO)
+ .setRpcTimeoutMultiplier(1.0)
+ .build();
+
+ private static final RetrySettings OPTIMIZE_RESTORED_TABLE_POLLING_SETTINGS =
+ RetrySettings.newBuilder()
+ .setInitialRetryDelayDuration(Duration.ofMillis(500L))
+ .setRetryDelayMultiplier(1.5)
+ .setMaxRetryDelayDuration(Duration.ofMillis(5000L))
+ .setInitialRpcTimeoutDuration(Duration.ZERO)
+ .setRpcTimeoutMultiplier(1.0)
+ .setMaxRpcTimeoutDuration(Duration.ZERO)
+ .setTotalTimeoutDuration(Duration.ofMillis(600000L))
+ .build();
+
+ /** Constructs an instance of BigtableTableAdminClientV2 with the given settings. */
+ public static final BigtableTableAdminClientV2 create(BaseBigtableTableAdminSettings settings)
+ throws IOException {
+ GrpcBigtableTableAdminStub stub =
+ (GrpcBigtableTableAdminStub)
+ ((BigtableTableAdminStubSettings) settings.getStubSettings()).createStub();
+ ScheduledExecutorService backgroundExecutor =
+ settings.getStubSettings().getBackgroundExecutorProvider().getExecutor();
+ boolean shouldAutoClose =
+ settings.getStubSettings().getBackgroundExecutorProvider().shouldAutoClose();
+
+ AwaitConsistencyCallableV2 awaitConsistencyCallable =
+ createAwaitConsistencyCallable(
+ stub,
+ (BigtableTableAdminStubSettings) settings.getStubSettings(),
+ settings.getStubSettings().getClock(),
+ backgroundExecutor);
+
+ OperationCallable
+ optimizeRestoredTableOperationBaseCallable =
+ createOptimizeRestoredTableOperationBaseCallable(stub, settings, backgroundExecutor);
+
+ return new BigtableTableAdminClientV2(
+ stub,
+ backgroundExecutor,
+ shouldAutoClose,
+ awaitConsistencyCallable,
+ optimizeRestoredTableOperationBaseCallable);
+ }
+
+ protected BigtableTableAdminClientV2(
+ GrpcBigtableTableAdminStub stub,
+ @Nullable ScheduledExecutorService backgroundExecutor,
+ boolean shouldAutoClose,
+ @Nullable AwaitConsistencyCallableV2 awaitConsistencyCallable,
+ @Nullable
+ OperationCallable
+ optimizeRestoredTableOperationBaseCallable) {
+ super(stub);
+ this.backgroundExecutor = backgroundExecutor;
+ this.shouldAutoClose = shouldAutoClose;
+ this.awaitConsistencyCallable = awaitConsistencyCallable;
+ this.optimizeRestoredTableOperationBaseCallable = optimizeRestoredTableOperationBaseCallable;
+ }
+
+ private static AwaitConsistencyCallableV2 createAwaitConsistencyCallable(
+ GrpcBigtableTableAdminStub stub,
+ BigtableTableAdminStubSettings settings,
+ ApiClock clock,
+ ScheduledExecutorService executor) {
+ // TODO(igorbernstein2): expose polling settings
+ RetrySettings pollingSettings =
+ AWAIT_CONSISTENCY_POLLING_SETTINGS_BASE.toBuilder()
+ .setTotalTimeoutDuration(
+ settings.checkConsistencySettings().getRetrySettings().getTotalTimeoutDuration())
+ .build();
+
+ return AwaitConsistencyCallableV2.create(
+ stub.generateConsistencyTokenCallable(),
+ stub.checkConsistencyCallable(),
+ clock,
+ executor,
+ pollingSettings);
+ }
+
+ private static OperationCallable
+ createOptimizeRestoredTableOperationBaseCallable(
+ GrpcBigtableTableAdminStub stub,
+ BaseBigtableTableAdminSettings settings,
+ ScheduledExecutorService backgroundExecutor)
+ throws IOException {
+
+ // Reusing getRestoreTableMethod() as a placeholder descriptor for LRO optimization tracking.
+ // Since there is no dedicated gRPC LRO method descriptor generated for OptimizeRestoredTable
+ // LRO, we reuse getRestoreTableMethod() (which is an LRO and returns a
+ // google.longrunning.Operation) and attach a throwing Marshaller for Void to satisfy the
+ // OperationCallable constructor requirements. Note: We do not plumb the gRPC ManagedChannel
+ // into the ClientContext below because this callable is only used for resumeFutureCall()
+ // (polling existing LROs via OperationsStub), which already encapsulates its own channel.
+ // The initial RPC is never called through this OperationCallable.
+ MethodDescriptor fakeDescriptor =
+ MethodDescriptor.newBuilder()
+ .setType(MethodDescriptor.MethodType.UNARY)
+ .setFullMethodName(
+ MethodDescriptor.generateFullMethodName(
+ "google.bigtable.admin.v2.BigtableTableAdmin", "RestoreTable"))
+ .setRequestMarshaller(
+ new Marshaller() {
+ @Override
+ public InputStream stream(Void value) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Void parse(InputStream stream) {
+ throw new UnsupportedOperationException();
+ }
+ })
+ .setResponseMarshaller(ProtoUtils.marshaller(Operation.getDefaultInstance()))
+ .build();
+
+ GrpcCallSettings unusedInitialCallSettings =
+ GrpcCallSettings.create(fakeDescriptor);
+
+ final MetadataTransformer protoMetadataTransformer =
+ MetadataTransformer.create(OptimizeRestoredTableMetadata.class);
+
+ final ResponseTransformer protoResponseTransformer =
+ ResponseTransformer.create(Empty.class);
+
+ OperationCallSettings operationCallSettings =
+ OperationCallSettings.newBuilder()
+ .setInitialCallSettings(
+ UnaryCallSettings.newUnaryCallSettingsBuilder()
+ .setSimpleTimeoutNoRetriesDuration(Duration.ZERO)
+ .build())
+ .setMetadataTransformer(
+ new ApiFunction() {
+ @Override
+ public OptimizeRestoredTableMetadata apply(OperationSnapshot input) {
+ return protoMetadataTransformer.apply(input);
+ }
+ })
+ .setResponseTransformer(
+ new ApiFunction() {
+ @Override
+ public Empty apply(OperationSnapshot input) {
+ return protoResponseTransformer.apply(input);
+ }
+ })
+ .setPollingAlgorithm(
+ OperationTimedPollAlgorithm.create(OPTIMIZE_RESTORED_TABLE_POLLING_SETTINGS))
+ .build();
+
+ // Note: The clientContext created here only contains the basic clock and executor settings
+ // required by the polling algorithm to schedule polling attempts. We do not need to populate
+ // the channel or call context details here because the operations stub
+ // (stub.getOperationsStub()) already encapsulates the fully-configured default call context
+ // (including channels, credentials, and headers) for executing the polling RPCs.
+ ClientContext clientContext =
+ ClientContext.newBuilder()
+ .setClock(settings.getStubSettings().getClock())
+ .setExecutor(backgroundExecutor)
+ .setDefaultCallContext(GrpcCallContext.createDefault())
+ .build();
+
+ return GrpcCallableFactory.createOperationCallable(
+ unusedInitialCallSettings, operationCallSettings, clientContext, stub.getOperationsStub());
+ }
+
+ /**
+ * Awaits the completion of the "Optimize Restored Table" operation.
+ *
+ * This method blocks until the restore operation is complete, extracts the optimization token,
+ * and returns an ApiFuture for the optimization phase.
+ *
+ * @param restoreFuture The future returned by restoreTableAsync().
+ * @return An ApiFuture that tracks the optimization progress.
+ */
+ public ApiFuture awaitOptimizeRestoredTable(ApiFuture restoreFuture) {
+ return ApiFutures.transformAsync(
+ restoreFuture,
+ result -> {
+ OptimizeRestoredTableOperationToken token =
+ result.getOptimizeRestoredTableOperationToken();
+
+ if (token == null || Strings.isNullOrEmpty(token.getOperationName())) {
+ return ApiFutures.immediateFuture(Empty.getDefaultInstance());
+ }
+
+ return getOptimizeRestoredTableCallable().resumeFutureCall(token.getOperationName());
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ /**
+ * Awaits a restored table is fully optimized.
+ *
+ * Sample code
+ *
+ *
{@code
+ * RestoredTableResult result =
+ * client.restoreTable(RestoreTableRequest.of(clusterId, backupId).setTableId(tableId));
+ * client.awaitOptimizeRestoredTable(result.getOptimizeRestoredTableOperationToken());
+ * }
+ */
+ public void awaitOptimizeRestoredTable(OptimizeRestoredTableOperationToken token) {
+ ApiExceptions.callAndTranslateApiException(awaitOptimizeRestoredTableAsync(token));
+ }
+
+ /**
+ * Awaits a restored table is fully optimized asynchronously.
+ *
+ * Sample code
+ *
+ *
{@code
+ * RestoredTableResult result =
+ * client.restoreTable(RestoreTableRequest.of(clusterId, backupId).setTableId(tableId));
+ * ApiFuture future = client.awaitOptimizeRestoredTableAsync(
+ * result.getOptimizeRestoredTableOperationToken());
+ *
+ * ApiFutures.addCallback(
+ * future,
+ * new ApiFutureCallback() {
+ * public void onSuccess(Void unused) {
+ * System.out.println("The optimization of the restored table is done.");
+ * }
+ *
+ * public void onFailure(Throwable t) {
+ * t.printStackTrace();
+ * }
+ * },
+ * MoreExecutors.directExecutor()
+ * );
+ * }
+ */
+ public ApiFuture awaitOptimizeRestoredTableAsync(
+ OptimizeRestoredTableOperationToken token) {
+ ApiFuture emptyFuture =
+ getOptimizeRestoredTableCallable().resumeFutureCall(token.getOperationName());
+ return ApiFutures.transform(emptyFuture, input -> null, MoreExecutors.directExecutor());
+ }
+
+ /**
+ * Polls an existing consistency token until table replication is consistent across all clusters.
+ * Useful for checking consistency of a token generated in a separate process. Blocks until
+ * completion.
+ *
+ * @param tableName The fully qualified table name to check.
+ * @param consistencyToken The token to poll.
+ */
+ public void waitForConsistency(String tableName, String consistencyToken) {
+ ApiExceptions.callAndTranslateApiException(
+ waitForConsistencyAsync(tableName, consistencyToken));
+ }
+
+ /**
+ * Polls an existing consistency token until table replication is consistent across all clusters.
+ * Useful for checking consistency of a token generated in a separate process. Blocks until
+ * completion.
+ *
+ * @param tableName The typesafe fully qualified table name to check.
+ * @param consistencyToken The token to poll.
+ */
+ public void waitForConsistency(TableName tableName, String consistencyToken) {
+ waitForConsistency(tableName.toString(), consistencyToken);
+ }
+
+ /**
+ * Asynchronously polls the consistency token. Returns a future that completes when table
+ * replication is consistent across all clusters.
+ *
+ * @param tableName The fully qualified table name to check.
+ * @param consistencyToken The token to poll.
+ */
+ public ApiFuture waitForConsistencyAsync(String tableName, String consistencyToken) {
+ return getAwaitConsistencyCallable()
+ .futureCall(ConsistencyRequest.forReplicationFromTableName(tableName, consistencyToken));
+ }
+
+ /**
+ * Asynchronously polls the consistency token. Returns a future that completes when table
+ * replication is consistent across all clusters.
+ *
+ * @param tableName The typesafe fully qualified table name to check.
+ * @param consistencyToken The token to poll.
+ */
+ public ApiFuture waitForConsistencyAsync(TableName tableName, String consistencyToken) {
+ return waitForConsistencyAsync(tableName.toString(), consistencyToken);
+ }
+
+ private UnaryCallable getAwaitConsistencyCallable() {
+ if (awaitConsistencyCallable != null) {
+ return awaitConsistencyCallable;
+ }
+ throw new IllegalStateException(
+ "AwaitConsistencyCallable not initialized. BigtableTableAdminClientV2 must be "
+ + "initialized via BigtableTableAdminClientV2.create(BaseBigtableTableAdminSettings) "
+ + "to use this functionality.");
+ }
+
+ private OperationCallable
+ getOptimizeRestoredTableCallable() {
+ if (optimizeRestoredTableOperationBaseCallable != null) {
+ return optimizeRestoredTableOperationBaseCallable;
+ }
+ throw new IllegalStateException(
+ "OptimizeRestoredTableCallable not initialized. BigtableTableAdminClientV2 must be "
+ + "initialized via BigtableTableAdminClientV2.create(BaseBigtableTableAdminSettings) "
+ + "to use this functionality.");
+ }
+
+ @Override
+ public void close() {
+ if (backgroundExecutor != null && shouldAutoClose) {
+ backgroundExecutor.shutdown();
+ }
+ super.close();
+ }
+
+ @Override
+ public void shutdown() {
+ if (backgroundExecutor != null && shouldAutoClose) {
+ backgroundExecutor.shutdown();
+ }
+ super.shutdown();
+ }
+
+ @Override
+ public void shutdownNow() {
+ if (backgroundExecutor != null && shouldAutoClose) {
+ backgroundExecutor.shutdownNow();
+ }
+ super.shutdownNow();
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return (backgroundExecutor == null || !shouldAutoClose || backgroundExecutor.isShutdown())
+ && super.isShutdown();
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return (backgroundExecutor == null || !shouldAutoClose || backgroundExecutor.isTerminated())
+ && super.isTerminated();
+ }
+
+ @Override
+ public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException {
+ long startNanos = System.nanoTime();
+ boolean terminated = true;
+ if (backgroundExecutor != null && shouldAutoClose) {
+ terminated = backgroundExecutor.awaitTermination(duration, unit);
+ }
+ if (!terminated) {
+ return false;
+ }
+ long remainingNanos = unit.toNanos(duration) - (System.nanoTime() - startNanos);
+ return super.awaitTermination(Math.max(0, remainingNanos), TimeUnit.NANOSECONDS);
+ }
+}
diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/ConsistencyRequest.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/ConsistencyRequest.java
new file mode 100644
index 000000000000..9714c825d836
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/ConsistencyRequest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2;
+
+import com.google.api.core.InternalApi;
+import com.google.auto.value.AutoValue;
+import com.google.bigtable.admin.v2.CheckConsistencyRequest;
+import com.google.bigtable.admin.v2.DataBoostReadLocalWrites;
+import com.google.bigtable.admin.v2.GenerateConsistencyTokenRequest;
+import com.google.bigtable.admin.v2.StandardReadRemoteWrites;
+import com.google.bigtable.admin.v2.TableName;
+import com.google.common.base.Preconditions;
+import javax.annotation.Nonnull;
+import javax.annotation.Nullable;
+
+@AutoValue
+@InternalApi
+public abstract class ConsistencyRequest {
+ @Nonnull
+ protected abstract String getTableName();
+
+ @Nonnull
+ protected abstract CheckConsistencyRequest.ModeCase getMode();
+
+ /**
+ * Internal accessor for the consistency token. Must be public to be accessible from the stub
+ * package.
+ */
+ @InternalApi
+ @Nullable
+ public abstract String getConsistencyToken();
+
+ public static ConsistencyRequest forReplicationFromTableName(String tableName) {
+ Preconditions.checkArgument(
+ TableName.isParsableFrom(tableName), "tableName must be a fully qualified table name");
+ return new AutoValue_ConsistencyRequest(
+ tableName, CheckConsistencyRequest.ModeCase.STANDARD_READ_REMOTE_WRITES, null);
+ }
+
+ public static ConsistencyRequest forReplicationFromTableName(
+ String tableName, String consistencyToken) {
+ Preconditions.checkArgument(
+ TableName.isParsableFrom(tableName), "tableName must be a fully qualified table name");
+ Preconditions.checkNotNull(consistencyToken, "consistencyToken must not be null");
+
+ return new AutoValue_ConsistencyRequest(
+ tableName, CheckConsistencyRequest.ModeCase.STANDARD_READ_REMOTE_WRITES, consistencyToken);
+ }
+
+ private CheckConsistencyRequest.Builder buildBaseRequest(String name, String token) {
+ CheckConsistencyRequest.Builder builder = CheckConsistencyRequest.newBuilder();
+
+ if (getMode().equals(CheckConsistencyRequest.ModeCase.STANDARD_READ_REMOTE_WRITES)) {
+ builder.setStandardReadRemoteWrites(StandardReadRemoteWrites.newBuilder().build());
+ } else {
+ builder.setDataBoostReadLocalWrites(DataBoostReadLocalWrites.newBuilder().build());
+ }
+
+ return builder.setName(name).setConsistencyToken(token);
+ }
+
+ /** Creates a CheckConsistencyRequest proto. */
+ @InternalApi
+ public CheckConsistencyRequest toCheckConsistencyProto(String token) {
+ return buildBaseRequest(getTableName(), token).build();
+ }
+
+ /** Creates a GenerateConsistencyTokenRequest proto. */
+ @InternalApi
+ public GenerateConsistencyTokenRequest toGenerateTokenProto() {
+ GenerateConsistencyTokenRequest.Builder builder = GenerateConsistencyTokenRequest.newBuilder();
+ return builder.setName(getTableName()).build();
+ }
+}
diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/OptimizeRestoredTableOperationToken.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/OptimizeRestoredTableOperationToken.java
new file mode 100644
index 000000000000..cc41762b5b57
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/OptimizeRestoredTableOperationToken.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2;
+
+import com.google.api.core.InternalApi;
+import com.google.common.base.Preconditions;
+
+/**
+ * OptimizeRestoredTableOperationToken is a wrapper for the name of OptimizeRestoredTable operation.
+ */
+public class OptimizeRestoredTableOperationToken {
+ private final String operationName;
+
+ @InternalApi
+ public static OptimizeRestoredTableOperationToken of(String operationName) {
+ return new OptimizeRestoredTableOperationToken(operationName);
+ }
+
+ private OptimizeRestoredTableOperationToken(String operationName) {
+ Preconditions.checkNotNull(operationName);
+ Preconditions.checkState(!operationName.isEmpty());
+ this.operationName = operationName;
+ }
+
+ @InternalApi
+ public String getOperationName() {
+ return operationName;
+ }
+}
diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/RestoredTableResult.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/RestoredTableResult.java
new file mode 100644
index 000000000000..bca76d6dbeb9
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/RestoredTableResult.java
@@ -0,0 +1,54 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2;
+
+import com.google.api.core.InternalApi;
+import com.google.bigtable.admin.v2.Table;
+import com.google.common.base.Strings;
+import javax.annotation.Nullable;
+
+/**
+ * A RestoredTableResult holds the restored table object and the {@link
+ * OptimizeRestoredTableOperationToken} object (if any).
+ */
+public class RestoredTableResult {
+
+ private final Table table;
+ private final OptimizeRestoredTableOperationToken optimizeRestoredTableOperationToken;
+
+ @InternalApi
+ public RestoredTableResult(
+ Table restoredTable, @Nullable String optimizeRestoredTableOperationName) {
+ this.table = restoredTable;
+ this.optimizeRestoredTableOperationToken =
+ Strings.isNullOrEmpty(optimizeRestoredTableOperationName)
+ ? null
+ : OptimizeRestoredTableOperationToken.of(optimizeRestoredTableOperationName);
+ }
+
+ public Table getTable() {
+ return table;
+ }
+
+ /**
+ * OptimizeRestoredTable operation may not be started when the table was restored from a backup
+ * stored in HDD clusters.
+ */
+ @Nullable
+ public OptimizeRestoredTableOperationToken getOptimizeRestoredTableOperationToken() {
+ return this.optimizeRestoredTableOperationToken;
+ }
+}
diff --git a/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/stub/AwaitConsistencyCallableV2.java b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/stub/AwaitConsistencyCallableV2.java
new file mode 100644
index 000000000000..d824b5338fe1
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/stub/AwaitConsistencyCallableV2.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2.stub;
+
+import com.google.api.core.ApiAsyncFunction;
+import com.google.api.core.ApiClock;
+import com.google.api.core.ApiFunction;
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.api.core.InternalApi;
+import com.google.api.gax.retrying.ExponentialPollAlgorithm;
+import com.google.api.gax.retrying.NonCancellableFuture;
+import com.google.api.gax.retrying.ResultRetryAlgorithmWithContext;
+import com.google.api.gax.retrying.RetryAlgorithm;
+import com.google.api.gax.retrying.RetrySettings;
+import com.google.api.gax.retrying.RetryingContext;
+import com.google.api.gax.retrying.RetryingExecutor;
+import com.google.api.gax.retrying.RetryingFuture;
+import com.google.api.gax.retrying.ScheduledRetryingExecutor;
+import com.google.api.gax.retrying.TimedAttemptSettings;
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.UnaryCallable;
+import com.google.bigtable.admin.v2.CheckConsistencyRequest;
+import com.google.bigtable.admin.v2.CheckConsistencyResponse;
+import com.google.bigtable.admin.v2.GenerateConsistencyTokenRequest;
+import com.google.bigtable.admin.v2.GenerateConsistencyTokenResponse;
+import com.google.cloud.bigtable.admin.v2.ConsistencyRequest;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.util.concurrent.MoreExecutors;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CancellationException;
+import java.util.concurrent.ScheduledExecutorService;
+
+/**
+ * Decoupled modern consistency polling callable for V2 client.
+ *
+ * This callable waits until either replication or Data Boost has caught up to the point it was
+ * called. It wraps GenerateConsistencyToken and CheckConsistency RPCs.
+ */
+@InternalApi
+public class AwaitConsistencyCallableV2 extends UnaryCallable {
+ private final UnaryCallable
+ generateCallable;
+ private final UnaryCallable checkCallable;
+ private final RetryingExecutor executor;
+
+ @InternalApi
+ public static AwaitConsistencyCallableV2 create(
+ UnaryCallable
+ generateCallable,
+ UnaryCallable checkCallable,
+ ApiClock clock,
+ ScheduledExecutorService executor,
+ RetrySettings pollingSettings) {
+
+ RetryAlgorithm retryAlgorithm =
+ new RetryAlgorithm<>(
+ new PollResultAlgorithm(), new ExponentialPollAlgorithm(pollingSettings, clock));
+
+ RetryingExecutor retryingExecutor =
+ new ScheduledRetryingExecutor<>(retryAlgorithm, executor);
+
+ return new AwaitConsistencyCallableV2(generateCallable, checkCallable, retryingExecutor);
+ }
+
+ @VisibleForTesting
+ AwaitConsistencyCallableV2(
+ UnaryCallable
+ generateCallable,
+ UnaryCallable checkCallable,
+ RetryingExecutor executor) {
+ this.generateCallable = generateCallable;
+ this.checkCallable = checkCallable;
+ this.executor = executor;
+ }
+
+ @Override
+ public ApiFuture futureCall(
+ final ConsistencyRequest consistencyRequest, final ApiCallContext apiCallContext) {
+
+ // If the token is already provided, skip generation and poll directly.
+ if (consistencyRequest.getConsistencyToken() != null) {
+ CheckConsistencyRequest request =
+ consistencyRequest.toCheckConsistencyProto(consistencyRequest.getConsistencyToken());
+ return pollToken(request, apiCallContext);
+ }
+
+ ApiFuture tokenFuture =
+ generateToken(consistencyRequest.toGenerateTokenProto(), apiCallContext);
+
+ return ApiFutures.transformAsync(
+ tokenFuture,
+ new ApiAsyncFunction() {
+ @Override
+ public ApiFuture apply(GenerateConsistencyTokenResponse input) {
+ CheckConsistencyRequest request =
+ consistencyRequest.toCheckConsistencyProto(input.getConsistencyToken());
+ return pollToken(request, apiCallContext);
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ private ApiFuture generateToken(
+ GenerateConsistencyTokenRequest generateRequest, ApiCallContext context) {
+ return generateCallable.futureCall(generateRequest, context);
+ }
+
+ private ApiFuture pollToken(CheckConsistencyRequest request, ApiCallContext context) {
+ AttemptCallable attemptCallable =
+ new AttemptCallable<>(checkCallable, request, context);
+ RetryingFuture retryingFuture =
+ executor.createFuture(attemptCallable);
+ attemptCallable.setExternalFuture(retryingFuture);
+ attemptCallable.call();
+
+ return ApiFutures.transform(
+ retryingFuture,
+ new ApiFunction() {
+ @Override
+ public Void apply(CheckConsistencyResponse input) {
+ return null;
+ }
+ },
+ MoreExecutors.directExecutor());
+ }
+
+ /** A callable representing an attempt to make an RPC call. */
+ private static class AttemptCallable implements Callable {
+ private final UnaryCallable callable;
+ private final RequestT request;
+
+ private volatile RetryingFuture externalFuture;
+ private final ApiCallContext callContext;
+
+ AttemptCallable(
+ UnaryCallable callable, RequestT request, ApiCallContext callContext) {
+ this.callable = callable;
+ this.request = request;
+ this.callContext = callContext;
+ }
+
+ void setExternalFuture(RetryingFuture externalFuture) {
+ this.externalFuture = externalFuture;
+ }
+
+ @Override
+ public ResponseT call() {
+ try {
+ // NOTE: unlike gax's AttemptCallable, this ignores rpc timeouts
+ externalFuture.setAttemptFuture(new NonCancellableFuture());
+ if (externalFuture.isDone()) {
+ return null;
+ }
+ ApiFuture internalFuture = callable.futureCall(request, callContext);
+ externalFuture.setAttemptFuture(internalFuture);
+ } catch (Throwable e) {
+ externalFuture.setAttemptFuture(ApiFutures.immediateFailedFuture(e));
+ }
+
+ return null;
+ }
+ }
+
+ /**
+ * A polling algorithm for waiting for a consistent {@link CheckConsistencyResponse}. Please note
+ * that this class doesn't handle retryable errors and expects the underlying callable chain to
+ * handle this.
+ */
+ private static class PollResultAlgorithm
+ implements ResultRetryAlgorithmWithContext {
+
+ @Override
+ public TimedAttemptSettings createNextAttempt(
+ Throwable prevThrowable,
+ CheckConsistencyResponse prevResponse,
+ TimedAttemptSettings prevSettings) {
+ return null;
+ }
+
+ @Override
+ public TimedAttemptSettings createNextAttempt(
+ RetryingContext context,
+ Throwable previousThrowable,
+ CheckConsistencyResponse previousResponse,
+ TimedAttemptSettings previousSettings) {
+ return null;
+ }
+
+ @Override
+ public boolean shouldRetry(
+ RetryingContext context, Throwable previousThrowable, CheckConsistencyResponse prevResponse)
+ throws CancellationException {
+ return prevResponse != null && !prevResponse.getConsistent();
+ }
+
+ @Override
+ public boolean shouldRetry(Throwable prevThrowable, CheckConsistencyResponse prevResponse)
+ throws CancellationException {
+ return prevResponse != null && !prevResponse.getConsistent();
+ }
+ }
+}
diff --git a/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/BigtableInstanceAdminClientV2Test.java b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/BigtableInstanceAdminClientV2Test.java
new file mode 100644
index 000000000000..5f7e311b2e5c
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/BigtableInstanceAdminClientV2Test.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.cloud.bigtable.admin.v2.stub.BigtableInstanceAdminStub;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+@RunWith(JUnit4.class)
+public class BigtableInstanceAdminClientV2Test {
+
+ @Test
+ public void testCreateWithStub() {
+ BigtableInstanceAdminStub mockStub = Mockito.mock(BigtableInstanceAdminStub.class);
+ BigtableInstanceAdminClientV2 client = new BigtableInstanceAdminClientV2(mockStub);
+
+ assertThat(client).isNotNull();
+ }
+
+ @Test
+ public void testCreateClientWithSettings() throws Exception {
+ BaseBigtableInstanceAdminSettings settings =
+ BaseBigtableInstanceAdminSettings.newBuilder()
+ .setCredentialsProvider(com.google.api.gax.core.NoCredentialsProvider.create())
+ .setEndpoint("localhost:8080")
+ .build();
+ try (BigtableInstanceAdminClientV2 settingsClient =
+ BigtableInstanceAdminClientV2.create(settings)) {
+ assertThat(settingsClient).isNotNull();
+ }
+ }
+}
diff --git a/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/BigtableTableAdminClientV2Test.java b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/BigtableTableAdminClientV2Test.java
new file mode 100644
index 000000000000..8507a8f14b94
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/BigtableTableAdminClientV2Test.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.api.gax.core.NoCredentialsProvider;
+import com.google.api.gax.longrunning.OperationFuture;
+import com.google.api.gax.rpc.OperationCallable;
+import com.google.bigtable.admin.v2.OptimizeRestoredTableMetadata;
+import com.google.bigtable.admin.v2.TableName;
+import com.google.cloud.bigtable.admin.v2.stub.AwaitConsistencyCallableV2;
+import com.google.cloud.bigtable.admin.v2.stub.EnhancedBigtableTableAdminStub;
+import com.google.cloud.bigtable.admin.v2.stub.GrpcBigtableTableAdminStub;
+import com.google.protobuf.Empty;
+import java.util.concurrent.Executor;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.mockito.stubbing.Answer;
+
+@RunWith(JUnit4.class)
+public class BigtableTableAdminClientV2Test {
+ @Rule public final MockitoRule mockitoRule = MockitoJUnit.rule();
+
+ private static final String TABLE_NAME =
+ "projects/my-project/instances/my-instance/tables/my-table";
+
+ @Mock private GrpcBigtableTableAdminStub mockStub;
+
+ @Mock private AwaitConsistencyCallableV2 mockAwaitConsistencyCallable;
+
+ @Mock
+ private OperationCallable
+ mockOptimizeRestoredTableCallable;
+
+ private BigtableTableAdminClientV2 client;
+
+ @Before
+ public void setUp() {
+ client =
+ new BigtableTableAdminClientV2(
+ mockStub, null, false, mockAwaitConsistencyCallable, mockOptimizeRestoredTableCallable);
+ }
+
+ @Test
+ public void testWaitForConsistencyWithToken() {
+ // Setup
+ String token = "my-token";
+ ConsistencyRequest expectedRequest =
+ ConsistencyRequest.forReplicationFromTableName(TABLE_NAME, token);
+
+ final AtomicBoolean wasCalled = new AtomicBoolean(false);
+
+ Mockito.when(mockAwaitConsistencyCallable.futureCall(expectedRequest))
+ .thenAnswer(
+ (Answer>)
+ invocationOnMock -> {
+ wasCalled.set(true);
+ return ApiFutures.immediateFuture(null);
+ });
+
+ // Execute
+ client.waitForConsistency(TABLE_NAME, token);
+
+ // Verify
+ assertThat(wasCalled.get()).isTrue();
+ }
+
+ @Test
+ public void testWaitForConsistencyWithTableName() {
+ // Setup
+ String token = "my-token";
+ TableName tableName = TableName.of("my-project", "my-instance", "my-table");
+ ConsistencyRequest expectedRequest =
+ ConsistencyRequest.forReplicationFromTableName(tableName.toString(), token);
+
+ final AtomicBoolean wasCalled = new AtomicBoolean(false);
+
+ Mockito.when(mockAwaitConsistencyCallable.futureCall(expectedRequest))
+ .thenAnswer(
+ (Answer>)
+ invocationOnMock -> {
+ wasCalled.set(true);
+ return ApiFutures.immediateFuture(null);
+ });
+
+ // Execute
+ client.waitForConsistency(tableName, token);
+
+ // Verify
+ assertThat(wasCalled.get()).isTrue();
+ }
+
+ @Test
+ public void testAwaitOptimizeRestoredTable() throws Exception {
+ // Setup
+ String optimizeToken = "my-optimization-token";
+
+ // 1. Mock the Token
+ OptimizeRestoredTableOperationToken mockToken =
+ Mockito.mock(OptimizeRestoredTableOperationToken.class);
+ Mockito.when(mockToken.getOperationName()).thenReturn(optimizeToken);
+
+ // 2. Mock the Result (wrapping the token)
+ RestoredTableResult mockResult = Mockito.mock(RestoredTableResult.class);
+ Mockito.when(mockResult.getOptimizeRestoredTableOperationToken()).thenReturn(mockToken);
+
+ // 3. Mock the Input Future (returning immediate result)
+ ApiFuture restoreFuture = ApiFutures.immediateFuture(mockResult);
+
+ // 4. Mock the Stub's behavior (resuming the Optimize Op)
+ OperationFuture mockOptimizeOp =
+ Mockito.mock(OperationFuture.class);
+ Mockito.when(mockOptimizeOp.get()).thenReturn(Empty.getDefaultInstance());
+ Mockito.doAnswer(
+ invocation -> {
+ Runnable runnable = invocation.getArgument(0);
+ Executor executor = invocation.getArgument(1);
+ executor.execute(runnable);
+ return null;
+ })
+ .when(mockOptimizeOp)
+ .addListener(Mockito.any(Runnable.class), Mockito.any(Executor.class));
+ Mockito.when(mockOptimizeRestoredTableCallable.resumeFutureCall(optimizeToken))
+ .thenReturn(mockOptimizeOp);
+
+ // Execute
+ ApiFuture result = client.awaitOptimizeRestoredTable(restoreFuture);
+
+ // Verify
+ assertThat(result.get()).isEqualTo(Empty.getDefaultInstance());
+ Mockito.verify(mockOptimizeRestoredTableCallable).resumeFutureCall(optimizeToken);
+ }
+
+ @Test
+ public void testAwaitOptimizeRestoredTable_NoOp() throws Exception {
+ // Setup: Result with NO optimization token (null or empty)
+ RestoredTableResult mockResult = Mockito.mock(RestoredTableResult.class);
+ Mockito.when(mockResult.getOptimizeRestoredTableOperationToken()).thenReturn(null);
+
+ // Mock the Input Future (returning immediate result)
+ ApiFuture restoreFuture = ApiFutures.immediateFuture(mockResult);
+
+ // Execute
+ ApiFuture result = client.awaitOptimizeRestoredTable(restoreFuture);
+
+ // Verify: Returns immediate success (Empty) without calling the stub
+ assertThat(result.get()).isEqualTo(Empty.getDefaultInstance());
+ }
+
+ @Test
+ public void testCreateClientWithSettings() throws Exception {
+ BaseBigtableTableAdminSettings settings =
+ BaseBigtableTableAdminSettings.newBuilder()
+ .setCredentialsProvider(NoCredentialsProvider.create())
+ .setEndpoint("localhost:8080")
+ .build();
+ try (BigtableTableAdminClientV2 settingsClient = BigtableTableAdminClientV2.create(settings)) {
+ // Verify that the underlying stub is NOT an Enhanced stub by default
+ // but the client has successfully initialized its own callables.
+ assertThat(settingsClient.getStub()).isNotInstanceOf(EnhancedBigtableTableAdminStub.class);
+ }
+ }
+
+ @Test
+ public void testAwaitConsistency_ThrowsWhenNotInitialized() {
+ BigtableTableAdminClientV2 uninitializedClient =
+ new BigtableTableAdminClientV2(mockStub, null, false, null, null);
+
+ try {
+ uninitializedClient.waitForConsistency(TABLE_NAME, "token");
+ org.junit.Assert.fail("Expected IllegalStateException");
+ } catch (IllegalStateException e) {
+ assertThat(e.getMessage())
+ .contains("BigtableTableAdminClientV2.create(BaseBigtableTableAdminSettings)");
+ }
+ }
+
+ @Test
+ public void testOptimizeRestoredTable_ThrowsWhenNotInitialized() {
+ BigtableTableAdminClientV2 uninitializedClient =
+ new BigtableTableAdminClientV2(mockStub, null, false, null, null);
+
+ OptimizeRestoredTableOperationToken mockToken =
+ Mockito.mock(OptimizeRestoredTableOperationToken.class);
+ Mockito.when(mockToken.getOperationName()).thenReturn("op-name");
+
+ try {
+ uninitializedClient.awaitOptimizeRestoredTableAsync(mockToken);
+ org.junit.Assert.fail("Expected IllegalStateException");
+ } catch (IllegalStateException e) {
+ assertThat(e.getMessage())
+ .contains("BigtableTableAdminClientV2.create(BaseBigtableTableAdminSettings)");
+ }
+ }
+}
diff --git a/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/stub/AwaitConsistencyCallableV2Test.java b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/stub/AwaitConsistencyCallableV2Test.java
new file mode 100644
index 000000000000..4fe205810d82
--- /dev/null
+++ b/java-bigtable/google-cloud-bigtable/src/test/java/com/google/cloud/bigtable/admin/v2/stub/AwaitConsistencyCallableV2Test.java
@@ -0,0 +1,295 @@
+/*
+ * Copyright 2026 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
+ *
+ * https://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.bigtable.admin.v2.stub;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.api.core.ApiFuture;
+import com.google.api.core.ApiFutures;
+import com.google.api.core.NanoClock;
+import com.google.api.gax.retrying.PollException;
+import com.google.api.gax.retrying.RetrySettings;
+import com.google.api.gax.rpc.ApiCallContext;
+import com.google.api.gax.rpc.StatusCode.Code;
+import com.google.api.gax.rpc.UnaryCallable;
+import com.google.api.gax.rpc.testing.FakeApiException;
+import com.google.api.gax.rpc.testing.FakeCallContext;
+import com.google.bigtable.admin.v2.CheckConsistencyRequest;
+import com.google.bigtable.admin.v2.CheckConsistencyResponse;
+import com.google.bigtable.admin.v2.GenerateConsistencyTokenRequest;
+import com.google.bigtable.admin.v2.GenerateConsistencyTokenResponse;
+import com.google.bigtable.admin.v2.StandardReadRemoteWrites;
+import com.google.bigtable.admin.v2.TableName;
+import com.google.cloud.bigtable.admin.v2.ConsistencyRequest;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.ArgumentMatchers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+import org.mockito.quality.Strictness;
+import org.threeten.bp.Duration;
+
+@RunWith(JUnit4.class)
+public class AwaitConsistencyCallableV2Test {
+ @Rule public MockitoRule mockitoRule = MockitoJUnit.rule().strictness(Strictness.WARN);
+
+ private static final String PROJECT_ID = "my-project";
+ private static final String INSTANCE_ID = "my-instance";
+ private static final String TABLE_ID = "my-table";
+ private static final TableName TABLE_NAME = TableName.of(PROJECT_ID, INSTANCE_ID, TABLE_ID);
+ private static final ApiCallContext CALL_CONTEXT = FakeCallContext.createDefault();
+
+ @Mock
+ private UnaryCallable
+ mockGenerateConsistencyTokenCallable;
+
+ @Mock
+ private UnaryCallable
+ mockCheckConsistencyCallable;
+
+ private AwaitConsistencyCallableV2 awaitConsistencyCallable;
+
+ @Before
+ public void setUp() {
+ RetrySettings retrySettings =
+ RetrySettings.newBuilder()
+ .setTotalTimeout(Duration.ofMillis(100))
+ // Delay settings: 1 ms const
+ .setInitialRetryDelay(Duration.ofMillis(1))
+ .setMaxRetryDelay(Duration.ofMillis(1))
+ .setRetryDelayMultiplier(1.0)
+ // RPC timeout: ignored const 1 s
+ .setInitialRpcTimeout(Duration.ofSeconds(1))
+ .setMaxRpcTimeout(Duration.ofSeconds(1))
+ .setRpcTimeoutMultiplier(1.0)
+ .build();
+
+ awaitConsistencyCallable =
+ AwaitConsistencyCallableV2.create(
+ mockGenerateConsistencyTokenCallable,
+ mockCheckConsistencyCallable,
+ NanoClock.getDefaultClock(),
+ java.util.concurrent.Executors.newSingleThreadScheduledExecutor(),
+ retrySettings);
+ }
+
+ @Test
+ public void testGenerateFailure() throws Exception {
+ GenerateConsistencyTokenRequest expectedRequest =
+ GenerateConsistencyTokenRequest.newBuilder().setName(TABLE_NAME.toString()).build();
+ FakeApiException fakeError = new FakeApiException("fake", null, Code.INTERNAL, false);
+
+ Mockito.when(mockGenerateConsistencyTokenCallable.futureCall(expectedRequest, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFailedFuture(fakeError));
+
+ ConsistencyRequest consistencyRequest =
+ ConsistencyRequest.forReplicationFromTableName(TABLE_NAME.toString());
+ ApiFuture future = awaitConsistencyCallable.futureCall(consistencyRequest, CALL_CONTEXT);
+
+ Throwable actualError = null;
+
+ try {
+ future.get();
+ } catch (ExecutionException e) {
+ actualError = e.getCause();
+ }
+
+ assertThat(actualError).isSameInstanceAs(fakeError);
+ }
+
+ @Test
+ public void testCheckFailure() throws Exception {
+ GenerateConsistencyTokenRequest expectedRequest =
+ GenerateConsistencyTokenRequest.newBuilder().setName(TABLE_NAME.toString()).build();
+ GenerateConsistencyTokenResponse expectedResponse =
+ GenerateConsistencyTokenResponse.newBuilder().setConsistencyToken("fake-token").build();
+
+ Mockito.when(mockGenerateConsistencyTokenCallable.futureCall(expectedRequest, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse));
+
+ CheckConsistencyRequest expectedRequest2 =
+ CheckConsistencyRequest.newBuilder()
+ .setName(TABLE_NAME.toString())
+ .setConsistencyToken("fake-token")
+ .setStandardReadRemoteWrites(StandardReadRemoteWrites.newBuilder().build())
+ .build();
+
+ FakeApiException expectedError = new FakeApiException("fake", null, Code.INTERNAL, false);
+
+ Mockito.when(mockCheckConsistencyCallable.futureCall(expectedRequest2, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFailedFuture(expectedError));
+
+ ConsistencyRequest consistencyRequest =
+ ConsistencyRequest.forReplicationFromTableName(TABLE_NAME.toString());
+ ApiFuture future = awaitConsistencyCallable.futureCall(consistencyRequest, CALL_CONTEXT);
+
+ Throwable actualError = null;
+
+ try {
+ future.get();
+ } catch (ExecutionException e) {
+ actualError = e.getCause();
+ }
+
+ assertThat(actualError).isSameInstanceAs(expectedError);
+ }
+
+ @Test
+ public void testImmediatelyConsistent() throws Exception {
+ GenerateConsistencyTokenRequest expectedRequest =
+ GenerateConsistencyTokenRequest.newBuilder().setName(TABLE_NAME.toString()).build();
+
+ GenerateConsistencyTokenResponse expectedResponse =
+ GenerateConsistencyTokenResponse.newBuilder().setConsistencyToken("fake-token").build();
+
+ Mockito.when(mockGenerateConsistencyTokenCallable.futureCall(expectedRequest, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse));
+
+ CheckConsistencyRequest expectedRequest2 =
+ CheckConsistencyRequest.newBuilder()
+ .setName(TABLE_NAME.toString())
+ .setConsistencyToken("fake-token")
+ .setStandardReadRemoteWrites(StandardReadRemoteWrites.newBuilder().build())
+ .build();
+ CheckConsistencyResponse expectedResponse2 =
+ CheckConsistencyResponse.newBuilder().setConsistent(true).build();
+
+ Mockito.when(mockCheckConsistencyCallable.futureCall(expectedRequest2, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse2));
+
+ ConsistencyRequest consistencyRequest =
+ ConsistencyRequest.forReplicationFromTableName(TABLE_NAME.toString());
+ ApiFuture consistentFuture =
+ awaitConsistencyCallable.futureCall(consistencyRequest, CALL_CONTEXT);
+
+ consistentFuture.get(1, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void testPolling() throws Exception {
+ GenerateConsistencyTokenRequest expectedRequest =
+ GenerateConsistencyTokenRequest.newBuilder().setName(TABLE_NAME.toString()).build();
+
+ GenerateConsistencyTokenResponse expectedResponse =
+ GenerateConsistencyTokenResponse.newBuilder().setConsistencyToken("fake-token").build();
+
+ Mockito.when(mockGenerateConsistencyTokenCallable.futureCall(expectedRequest, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse));
+
+ CheckConsistencyRequest expectedRequest2 =
+ CheckConsistencyRequest.newBuilder()
+ .setName(TABLE_NAME.toString())
+ .setConsistencyToken("fake-token")
+ .setStandardReadRemoteWrites(StandardReadRemoteWrites.newBuilder().build())
+ .build();
+
+ CheckConsistencyResponse expectedResponse2 =
+ CheckConsistencyResponse.newBuilder().setConsistent(false).build();
+
+ CheckConsistencyResponse expectedResponse3 =
+ CheckConsistencyResponse.newBuilder().setConsistent(true).build();
+
+ Mockito.when(mockCheckConsistencyCallable.futureCall(expectedRequest2, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse2))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse3));
+
+ ConsistencyRequest consistencyRequest =
+ ConsistencyRequest.forReplicationFromTableName(TABLE_NAME.toString());
+ ApiFuture consistentFuture =
+ awaitConsistencyCallable.futureCall(consistencyRequest, CALL_CONTEXT);
+
+ consistentFuture.get(2, TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void testPollingTimeout() throws Exception {
+ GenerateConsistencyTokenRequest expectedRequest =
+ GenerateConsistencyTokenRequest.newBuilder().setName(TABLE_NAME.toString()).build();
+
+ GenerateConsistencyTokenResponse expectedResponse =
+ GenerateConsistencyTokenResponse.newBuilder().setConsistencyToken("fake-token").build();
+
+ Mockito.when(mockGenerateConsistencyTokenCallable.futureCall(expectedRequest, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse));
+
+ CheckConsistencyRequest expectedRequest2 =
+ CheckConsistencyRequest.newBuilder()
+ .setName(TABLE_NAME.toString())
+ .setConsistencyToken("fake-token")
+ .setStandardReadRemoteWrites(StandardReadRemoteWrites.newBuilder().build())
+ .build();
+
+ CheckConsistencyResponse expectedResponse2 =
+ CheckConsistencyResponse.newBuilder().setConsistent(false).build();
+
+ Mockito.when(mockCheckConsistencyCallable.futureCall(expectedRequest2, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse2));
+
+ ConsistencyRequest consistencyRequest =
+ ConsistencyRequest.forReplicationFromTableName(TABLE_NAME.toString());
+ ApiFuture consistentFuture =
+ awaitConsistencyCallable.futureCall(consistencyRequest, CALL_CONTEXT);
+
+ Throwable actualError = null;
+ try {
+ consistentFuture.get(2, TimeUnit.SECONDS);
+ } catch (ExecutionException e) {
+ actualError = e.getCause();
+ }
+
+ assertThat(actualError).isInstanceOf(PollException.class);
+ }
+
+ @Test
+ public void testWithProvidedToken() throws Exception {
+ // 1. Setup: Request with a pre-existing token
+ String existingToken = "existing-token";
+ ConsistencyRequest consistencyRequest =
+ ConsistencyRequest.forReplicationFromTableName(TABLE_NAME.toString(), existingToken);
+
+ // 2. Setup: Mock the check operation to succeed immediately
+ CheckConsistencyRequest expectedCheckRequest =
+ CheckConsistencyRequest.newBuilder()
+ .setName(TABLE_NAME.toString())
+ .setConsistencyToken(existingToken)
+ .setStandardReadRemoteWrites(StandardReadRemoteWrites.newBuilder().build())
+ .build();
+ CheckConsistencyResponse expectedResponse =
+ CheckConsistencyResponse.newBuilder().setConsistent(true).build();
+
+ Mockito.when(mockCheckConsistencyCallable.futureCall(expectedCheckRequest, CALL_CONTEXT))
+ .thenReturn(ApiFutures.immediateFuture(expectedResponse));
+
+ // 3. Execute
+ ApiFuture future = awaitConsistencyCallable.futureCall(consistencyRequest, CALL_CONTEXT);
+ future.get(1, TimeUnit.SECONDS);
+
+ // 4. Verify: Generate was NEVER called, Check WAS called
+ Mockito.verify(mockGenerateConsistencyTokenCallable, Mockito.never())
+ .futureCall(
+ ArgumentMatchers.any(GenerateConsistencyTokenRequest.class),
+ ArgumentMatchers.any(ApiCallContext.class));
+ Mockito.verify(mockCheckConsistencyCallable, Mockito.times(1))
+ .futureCall(expectedCheckRequest, CALL_CONTEXT);
+ }
+}
diff --git a/java-bigtable/owlbot.py b/java-bigtable/owlbot.py
index de2ac32738c6..1c5670b95625 100644
--- a/java-bigtable/owlbot.py
+++ b/java-bigtable/owlbot.py
@@ -42,6 +42,14 @@ def make_internal_only(sources):
if os.path.exists('owl-bot-staging/v2/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/package-info.java'):
os.remove('owl-bot-staging/v2/google-cloud-bigtable/src/main/java/com/google/cloud/bigtable/admin/v2/package-info.java')
s.replace(f'{library}/**/BaseBigtable*AdminClient.java', 'public static final BaseBigtable(.*)AdminClient create\\(', 'protected static BaseBigtable\\1AdminClient create(')
+
+ # Remove the 'final' modifier from the close() method in the Base Admin clients
+ # This allows our handwritten wrappers to override close() and clean up custom executors.
+ s.replace(
+ f"{library}/**/BaseBigtable*AdminClient.java",
+ r"public final void close\(\) \{",
+ r"public void close() {"
+ )
s.move(library)
s.remove_staging_dirs()
java.common_templates(