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

stankiewicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 112739c3fea [SpannerIO] Add withWriteResults() to SpannerIO.Write to 
emit commit metadata (#40480)
112739c3fea is described below

commit 112739c3fea2469377e3a9a34b271824f47b28d1
Author: RadosÅ‚aw Stankiewicz <[email protected]>
AuthorDate: Mon Oct 12 20:09:41 2026 +0200

    [SpannerIO] Add withWriteResults() to SpannerIO.Write to emit commit 
metadata (#40480)
---
 .../apache/beam/sdk/io/gcp/spanner/SpannerIO.java  | 156 ++++++++++++++--
 .../sdk/io/gcp/spanner/SpannerWriteResult.java     |  26 ++-
 .../beam/sdk/io/gcp/spanner/WriteResults.java      | 122 +++++++++++++
 .../com/google/cloud/spanner/OptionsImposter.java  |   8 +
 .../sdk/io/gcp/spanner/SpannerIOWriteTest.java     | 199 +++++++++++++++++++++
 .../beam/sdk/io/gcp/spanner/SpannerWriteIT.java    |  38 ++++
 6 files changed, 532 insertions(+), 17 deletions(-)

diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java
index 1d892fb11e4..d2ea020c471 100644
--- 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java
@@ -30,6 +30,7 @@ import static 
org.apache.beam.sdk.io.gcp.spanner.changestreams.ChangeStreamsCons
 import static 
org.apache.beam.sdk.io.gcp.spanner.changestreams.ChangeStreamsConstants.LOW_LATENCY_REAL_TIME_CHECKPOINT_INTERVAL;
 import static 
org.apache.beam.sdk.io.gcp.spanner.changestreams.ChangeStreamsConstants.MAX_INCLUSIVE_END_AT;
 import static 
org.apache.beam.sdk.io.gcp.spanner.changestreams.ChangeStreamsConstants.THROUGHPUT_WINDOW_SECONDS;
+import static org.apache.beam.sdk.util.Preconditions.checkStateNotNull;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkArgument;
 import static 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions.checkNotNull;
 
@@ -41,6 +42,7 @@ import com.google.cloud.NoCredentials;
 import com.google.cloud.ServiceFactory;
 import com.google.cloud.Timestamp;
 import com.google.cloud.spanner.AbortedException;
+import com.google.cloud.spanner.CommitResponse;
 import com.google.cloud.spanner.DatabaseClient;
 import com.google.cloud.spanner.DatabaseId;
 import com.google.cloud.spanner.Dialect;
@@ -529,6 +531,7 @@ public class SpannerIO {
         .setMaxNumMutations(DEFAULT_MAX_NUM_MUTATIONS)
         .setMaxNumRows(DEFAULT_MAX_NUM_ROWS)
         .setFailureMode(FailureMode.FAIL_FAST)
+        .setCommitStats(false)
         .build();
   }
 
@@ -1427,6 +1430,8 @@ public class SpannerIO {
 
     abstract @Nullable PCollectionView<Dialect> getDialectView();
 
+    abstract boolean getCommitStats();
+
     abstract Builder toBuilder();
 
     @AutoValue.Builder
@@ -1448,6 +1453,8 @@ public class SpannerIO {
 
       abstract Builder setDialectView(PCollectionView<Dialect> dialect);
 
+      abstract Builder setCommitStats(boolean commitStats);
+
       abstract Write build();
     }
 
@@ -1693,6 +1700,14 @@ public class SpannerIO {
       return withSpannerConfig(config.withRpcPriority(RpcPriority.HIGH));
     }
 
+    /**
+     * Returns a {@link WriteWithResults} transform that enables {@link 
Options#commitStats()} and
+     * emits a {@link WriteResults} for each batch of mutations written to 
Cloud Spanner.
+     */
+    public WriteWithResults withWriteResults() {
+      return new WriteWithResults(toBuilder().setCommitStats(true).build());
+    }
+
     @Override
     public SpannerWriteResult expand(PCollection<Mutation> input) {
       getSpannerConfig().validate();
@@ -1731,6 +1746,65 @@ public class SpannerIO {
     }
   }
 
+  /**
+   * A {@link PTransform} that writes {@link Mutation} objects to Google Cloud 
Spanner with {@link
+   * Options#commitStats()} enabled and emits a {@link WriteResults} for each 
committed batch.
+   */
+  public static class WriteWithResults
+      extends PTransform<PCollection<Mutation>, PCollection<WriteResults>> {
+
+    private final Write spec;
+
+    WriteWithResults(Write spec) {
+      this.spec = spec;
+    }
+
+    /** Same transform but can be applied to {@link PCollection} of {@link 
MutationGroup}. */
+    public WriteGroupedWithResults grouped() {
+      return new WriteGroupedWithResults(spec);
+    }
+
+    @Override
+    public PCollection<WriteResults> expand(PCollection<Mutation> input) {
+      spec.getSpannerConfig().validate();
+
+      return input
+          .apply("To mutation group", ParDo.of(new ToMutationGroupFn()))
+          .apply("Write mutations to Cloud Spanner", new 
WriteGroupedWithResults(spec));
+    }
+
+    @Override
+    public void populateDisplayData(DisplayData.Builder builder) {
+      super.populateDisplayData(builder);
+      spec.populateDisplayDataWithParamaters(builder);
+    }
+  }
+
+  /** Same as {@link WriteWithResults} but supports grouped mutations. */
+  public static class WriteGroupedWithResults
+      extends PTransform<PCollection<MutationGroup>, 
PCollection<WriteResults>> {
+
+    private final Write spec;
+
+    public WriteGroupedWithResults(Write spec) {
+      this.spec = spec;
+    }
+
+    @Override
+    public PCollection<WriteResults> expand(PCollection<MutationGroup> input) {
+      SpannerWriteResult result = input.apply(new WriteGrouped(spec));
+      return checkStateNotNull(
+          result.getWriteResults(),
+          "WriteResults PCollection must be present when commitStats is 
enabled.");
+    }
+
+    @Override
+    public void populateDisplayData(DisplayData.Builder builder) {
+      super.populateDisplayData(builder);
+      spec.populateDisplayDataWithParamaters(builder);
+    }
+  }
+
   static class WriteRows extends PTransform<PCollection<Row>, PDone> {
 
     private final Write write;
@@ -1769,6 +1843,8 @@ public class SpannerIO {
         new TupleTag<Iterable<MutationGroup>>("unbatchableMutations") {};
 
     private static final TupleTag<Void> MAIN_OUT_TAG = new 
TupleTag<Void>("mainOut") {};
+    private static final TupleTag<WriteResults> WRITE_RESULTS_TAG =
+        new TupleTag<WriteResults>("writeResults") {};
     private static final TupleTag<MutationGroup> FAILED_MUTATIONS_TAG =
         new TupleTag<MutationGroup>("failedMutations") {};
     private static final SerializableCoder<MutationGroup> CODER =
@@ -1778,6 +1854,15 @@ public class SpannerIO {
       this.spec = spec;
     }
 
+    /**
+     * Returns a {@link WriteGroupedWithResults} transform that enables {@link
+     * Options#commitStats()} and emits a {@link WriteResults} for each batch 
of mutations written
+     * to Cloud Spanner.
+     */
+    public WriteGroupedWithResults withWriteResults() {
+      return new 
WriteGroupedWithResults(spec.toBuilder().setCommitStats(true).build());
+    }
+
     @Override
     public void populateDisplayData(DisplayData.Builder builder) {
       super.populateDisplayData(builder);
@@ -1876,12 +1961,19 @@ public class SpannerIO {
               "Write batches to Spanner",
               ParDo.of(
                       new WriteToSpannerFn(
-                          spec.getSpannerConfig(), spec.getFailureMode(), 
FAILED_MUTATIONS_TAG))
-                  .withOutputTags(MAIN_OUT_TAG, 
TupleTagList.of(FAILED_MUTATIONS_TAG)));
+                          spec.getSpannerConfig(),
+                          spec.getFailureMode(),
+                          FAILED_MUTATIONS_TAG,
+                          spec.getCommitStats(),
+                          WRITE_RESULTS_TAG))
+                  .withOutputTags(
+                      MAIN_OUT_TAG, 
TupleTagList.of(FAILED_MUTATIONS_TAG).and(WRITE_RESULTS_TAG)));
 
       return new SpannerWriteResult(
           input.getPipeline(),
           result.get(MAIN_OUT_TAG),
+          spec.getCommitStats() ? result.get(WRITE_RESULTS_TAG) : null,
+          spec.getCommitStats() ? WRITE_RESULTS_TAG : null,
           result.get(FAILED_MUTATIONS_TAG),
           FAILED_MUTATIONS_TAG);
     }
@@ -2501,6 +2593,8 @@ public class SpannerIO {
     private long sortableNumCells = 0;
     // total number of rows mutated in mutationsToSort
     private long sortableNumRows = 0;
+    // minimum timestamp of elements currently in mutationsToSort
+    private @Nullable Instant minBundleTimestamp = null;
 
     GatherSortCreateBatchesFn(
         long maxBatchSizeBytes,
@@ -2529,11 +2623,13 @@ public class SpannerIO {
       sortableSizeBytes = 0;
       sortableNumCells = 0;
       sortableNumRows = 0;
+      minBundleTimestamp = null;
     }
 
     @FinishBundle
     public synchronized void finishBundle(FinishBundleContext c) throws 
Exception {
-      sortAndOutputBatches(new OutputReceiverForFinishBundle(c));
+      Instant outputTimestamp = minBundleTimestamp != null ? 
minBundleTimestamp : Instant.now();
+      sortAndOutputBatches(new OutputReceiverForFinishBundle(c, 
outputTimestamp));
     }
 
     private synchronized void 
sortAndOutputBatches(OutputReceiver<Iterable<MutationGroup>> out)
@@ -2617,6 +2713,12 @@ public class SpannerIO {
           sortAndOutputBatches(out);
         }
 
+        Instant elementTimestamp = c.timestamp();
+        if (elementTimestamp != null
+            && (minBundleTimestamp == null || 
elementTimestamp.isBefore(minBundleTimestamp))) {
+          minBundleTimestamp = elementTimestamp;
+        }
+
         mutationsToSort.add(
             new MutationGroupContainer(
                 mg, groupSize, groupCells, groupRows, 
encoder.encodeTableNameAndKey(mg.primary())));
@@ -2663,7 +2765,7 @@ public class SpannerIO {
       private final OutputBuilderSupplier outputBuilderSupplier;
       private final DoFn<MutationGroup, 
Iterable<MutationGroup>>.FinishBundleContext context;
 
-      OutputReceiverForFinishBundle(FinishBundleContext context) {
+      OutputReceiverForFinishBundle(FinishBundleContext context, Instant 
timestamp) {
         this.context = context;
         this.outputBuilderSupplier =
             new OutputBuilderSupplier() {
@@ -2671,7 +2773,7 @@ public class SpannerIO {
               public <OutputT> WindowedValues.Builder<OutputT> builder(OutputT 
value) {
                 return WindowedValues.<OutputT>builder()
                     .setValue(value)
-                    .setTimestamp(Instant.now())
+                    .setTimestamp(timestamp)
                     .setPaneInfo(PaneInfo.NO_FIRING)
                     .setWindow(GlobalWindow.INSTANCE);
               }
@@ -2799,6 +2901,8 @@ public class SpannerIO {
         Metrics.counter(WriteGrouped.class, "spanner_write_retries");
 
     private final TupleTag<MutationGroup> failedTag;
+    private final boolean commitStats;
+    private final @Nullable TupleTag<WriteResults> writeResultsTag;
 
     // Fluent Backoff is not serializable so create at runtime in setup().
     private transient FluentBackoff bundleWriteBackoff;
@@ -2806,9 +2910,20 @@ public class SpannerIO {
 
     WriteToSpannerFn(
         SpannerConfig spannerConfig, FailureMode failureMode, 
TupleTag<MutationGroup> failedTag) {
+      this(spannerConfig, failureMode, failedTag, false, null);
+    }
+
+    WriteToSpannerFn(
+        SpannerConfig spannerConfig,
+        FailureMode failureMode,
+        TupleTag<MutationGroup> failedTag,
+        boolean commitStats,
+        @Nullable TupleTag<WriteResults> writeResultsTag) {
       this.spannerConfig = spannerConfig;
       this.failureMode = failureMode;
       this.failedTag = failedTag;
+      this.commitStats = commitStats;
+      this.writeResultsTag = writeResultsTag;
     }
 
     @Setup
@@ -2851,9 +2966,12 @@ public class SpannerIO {
         mutationGroupBatchesReceived.inc();
         mutationGroupsReceived.inc(mutations.size());
         Iterable<Mutation> batch = Iterables.concat(mutations);
-        writeMutations(batch);
+        CommitResponse response = writeMutations(batch);
         mutationGroupBatchesWriteSuccess.inc();
         mutationGroupsWriteSuccess.inc(mutations.size());
+        if (commitStats && writeResultsTag != null) {
+          c.output(writeResultsTag, WriteResults.fromCommitResponse(response));
+        }
         return;
       } catch (SpannerException e) {
         mutationGroupBatchesWriteFail.inc();
@@ -2872,8 +2990,11 @@ public class SpannerIO {
       for (MutationGroup mg : mutations) {
         try {
           spannerWriteRetries.inc();
-          writeMutations(mg);
+          CommitResponse response = writeMutations(mg);
           mutationGroupsWriteSuccess.inc();
+          if (commitStats && writeResultsTag != null) {
+            c.output(writeResultsTag, 
WriteResults.fromCommitResponse(response));
+          }
         } catch (SpannerException e) {
           mutationGroupsWriteFail.inc();
           LOG.warn("Failed to write the mutation group: {}", mg, e);
@@ -2886,13 +3007,15 @@ public class SpannerIO {
      Spanner aborts all inflight transactions during a schema change. Client 
is expected
      to retry silently. These must not be counted against retry backoff.
     */
-    private void spannerWriteWithRetryIfSchemaChange(List<Mutation> batch) 
throws SpannerException {
+    private CommitResponse spannerWriteWithRetryIfSchemaChange(List<Mutation> 
batch)
+        throws SpannerException {
       Set<String> tableNames = 
batch.stream().map(Mutation::getTable).collect(Collectors.toSet());
       for (int retry = 1; ; retry++) {
         try {
-          spannerAccessor
-              .getDatabaseClient()
-              .writeAtLeastOnceWithOptions(batch, getTransactionOptions());
+          CommitResponse response =
+              spannerAccessor
+                  .getDatabaseClient()
+                  .writeAtLeastOnceWithOptions(batch, getTransactionOptions());
           // Get names of all tables in batch of mutations.
           reportServiceCallMetricsForBatch(tableNames, "ok");
           for (String tableName : tableNames) {
@@ -2906,7 +3029,7 @@ public class SpannerIO {
                         spannerConfig.getDatabaseId().get(),
                         tableName));
           }
-          return;
+          return response;
         } catch (AbortedException e) {
           reportServiceCallMetricsForBatch(
               tableNames, e.getErrorCode().getGrpcStatusCode().toString());
@@ -2937,7 +3060,8 @@ public class SpannerIO {
                   ? Options.maxCommitDelay(
                       java.time.Duration.ofMillis(
                           spannerConfig.getMaxCommitDelay().get().getMillis()))
-                  : null)
+                  : null,
+              commitStats ? Options.commitStats() : null)
           .filter(Objects::nonNull)
           .toArray(Options.TransactionOption[]::new);
     }
@@ -2964,7 +3088,7 @@ public class SpannerIO {
     }
 
     /** Write the Mutations to Spanner, handling DEADLINE_EXCEEDED with 
backoff/retries. */
-    private void writeMutations(Iterable<Mutation> mutationIterable)
+    private CommitResponse writeMutations(Iterable<Mutation> mutationIterable)
         throws SpannerException, IOException {
       BackOff backoff = bundleWriteBackoff.backoff();
       List<Mutation> mutations = ImmutableList.copyOf(mutationIterable);
@@ -2973,9 +3097,9 @@ public class SpannerIO {
         Stopwatch timer = Stopwatch.createStarted();
         // loop is broken on success, timeout backoff/retry attempts exceeded, 
or other failure.
         try {
-          spannerWriteWithRetryIfSchemaChange(mutations);
+          CommitResponse response = 
spannerWriteWithRetryIfSchemaChange(mutations);
           spannerWriteSuccess.inc();
-          return;
+          return response;
         } catch (SpannerException exception) {
           if (exception.getErrorCode() == ErrorCode.DEADLINE_EXCEEDED) {
             spannerWriteTimeouts.inc();
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteResult.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteResult.java
index 87f67345036..31f612b00e8 100644
--- 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteResult.java
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteResult.java
@@ -26,6 +26,7 @@ import org.apache.beam.sdk.values.POutput;
 import org.apache.beam.sdk.values.PValue;
 import org.apache.beam.sdk.values.TupleTag;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableMap;
+import org.checkerframework.checker.nullness.qual.Nullable;
 
 /**
  * The results of a {@link SpannerIO#write()} transform.
@@ -42,6 +43,8 @@ import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Immuta
 public class SpannerWriteResult implements POutput {
   private final Pipeline pipeline;
   private final PCollection<Void> output;
+  private final @Nullable PCollection<WriteResults> writeResults;
+  private final @Nullable TupleTag<WriteResults> writeResultsTag;
   private final PCollection<MutationGroup> failedMutations;
   private final TupleTag<MutationGroup> failedMutationsTag;
 
@@ -50,8 +53,20 @@ public class SpannerWriteResult implements POutput {
       PCollection<Void> output,
       PCollection<MutationGroup> failedMutations,
       TupleTag<MutationGroup> failedMutationsTag) {
+    this(pipeline, output, null, null, failedMutations, failedMutationsTag);
+  }
+
+  public SpannerWriteResult(
+      Pipeline pipeline,
+      PCollection<Void> output,
+      @Nullable PCollection<WriteResults> writeResults,
+      @Nullable TupleTag<WriteResults> writeResultsTag,
+      PCollection<MutationGroup> failedMutations,
+      TupleTag<MutationGroup> failedMutationsTag) {
     this.pipeline = pipeline;
     this.output = output;
+    this.writeResults = writeResults;
+    this.writeResultsTag = writeResultsTag;
     this.failedMutations = failedMutations;
     this.failedMutationsTag = failedMutationsTag;
   }
@@ -63,7 +78,12 @@ public class SpannerWriteResult implements POutput {
 
   @Override
   public Map<TupleTag<?>, PValue> expand() {
-    return ImmutableMap.of(failedMutationsTag, failedMutations);
+    ImmutableMap.Builder<TupleTag<?>, PValue> builder = ImmutableMap.builder();
+    builder.put(failedMutationsTag, failedMutations);
+    if (writeResults != null && writeResultsTag != null) {
+      builder.put(writeResultsTag, writeResults);
+    }
+    return builder.build();
   }
 
   public PCollection<MutationGroup> getFailedMutations() {
@@ -74,6 +94,10 @@ public class SpannerWriteResult implements POutput {
     return output;
   }
 
+  public @Nullable PCollection<WriteResults> getWriteResults() {
+    return writeResults;
+  }
+
   @Override
   public void finishSpecifyingOutput(
       String transformName, PInput input, PTransform<?, ?> transform) {}
diff --git 
a/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/WriteResults.java
 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/WriteResults.java
new file mode 100644
index 00000000000..a85cd3e7ffa
--- /dev/null
+++ 
b/sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/WriteResults.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.beam.sdk.io.gcp.spanner;
+
+import com.google.cloud.Timestamp;
+import com.google.cloud.spanner.CommitResponse;
+import java.io.Serializable;
+import java.util.Objects;
+import org.apache.avro.reflect.AvroEncode;
+import org.apache.beam.sdk.coders.DefaultCoder;
+import org.apache.beam.sdk.extensions.avro.coders.AvroCoder;
+import 
org.apache.beam.sdk.io.gcp.spanner.changestreams.encoder.TimestampEncoding;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * The results of committing a batch of mutations to Cloud Spanner via {@link
+ * SpannerIO.Write#withWriteResults()}.
+ */
+@SuppressWarnings("initialization.fields.uninitialized") // Avro requires the 
default constructor
+@DefaultCoder(AvroCoder.class)
+public class WriteResults implements Serializable {
+
+  private static final long serialVersionUID = 1L;
+
+  private long mutationCount;
+
+  @AvroEncode(using = TimestampEncoding.class)
+  private @Nullable Timestamp commitTimestamp;
+
+  @AvroEncode(using = TimestampEncoding.class)
+  private @Nullable Timestamp snapshotTimestamp;
+
+  /** Default constructor for Avro serialization only. */
+  private WriteResults() {}
+
+  private WriteResults(
+      long mutationCount,
+      @Nullable Timestamp commitTimestamp,
+      @Nullable Timestamp snapshotTimestamp) {
+    this.mutationCount = mutationCount;
+    this.commitTimestamp = commitTimestamp;
+    this.snapshotTimestamp = snapshotTimestamp;
+  }
+
+  /** The number of mutations applied by Cloud Spanner in this commit, or 0 if 
unavailable. */
+  public long getMutationCount() {
+    return mutationCount;
+  }
+
+  /** The timestamp at which the write was committed in Cloud Spanner. */
+  public @Nullable Timestamp getCommitTimestamp() {
+    return commitTimestamp;
+  }
+
+  /** The snapshot timestamp of the transaction, if returned by Cloud Spanner. 
*/
+  public @Nullable Timestamp getSnapshotTimestamp() {
+    return snapshotTimestamp;
+  }
+
+  public static WriteResults create(
+      long mutationCount,
+      @Nullable Timestamp commitTimestamp,
+      @Nullable Timestamp snapshotTimestamp) {
+    return new WriteResults(mutationCount, commitTimestamp, snapshotTimestamp);
+  }
+
+  public static WriteResults fromCommitResponse(@Nullable CommitResponse 
commitResponse) {
+    if (commitResponse == null) {
+      return create(0L, null, null);
+    }
+    long mutationCount =
+        commitResponse.hasCommitStats() ? 
commitResponse.getCommitStats().getMutationCount() : 0L;
+    return create(
+        mutationCount, commitResponse.getCommitTimestamp(), 
commitResponse.getSnapshotTimestamp());
+  }
+
+  @Override
+  public boolean equals(@Nullable Object o) {
+    if (this == o) {
+      return true;
+    }
+    if (!(o instanceof WriteResults)) {
+      return false;
+    }
+    WriteResults that = (WriteResults) o;
+    return mutationCount == that.mutationCount
+        && Objects.equals(commitTimestamp, that.commitTimestamp)
+        && Objects.equals(snapshotTimestamp, that.snapshotTimestamp);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(mutationCount, commitTimestamp, snapshotTimestamp);
+  }
+
+  @Override
+  public String toString() {
+    return "WriteResults{"
+        + "mutationCount="
+        + mutationCount
+        + ", commitTimestamp="
+        + commitTimestamp
+        + ", snapshotTimestamp="
+        + snapshotTimestamp
+        + '}';
+  }
+}
diff --git 
a/sdks/java/io/google-cloud-platform/src/test/java/com/google/cloud/spanner/OptionsImposter.java
 
b/sdks/java/io/google-cloud-platform/src/test/java/com/google/cloud/spanner/OptionsImposter.java
index 9d2954950e9..72fc3a0d3d2 100644
--- 
a/sdks/java/io/google-cloud-platform/src/test/java/com/google/cloud/spanner/OptionsImposter.java
+++ 
b/sdks/java/io/google-cloud-platform/src/test/java/com/google/cloud/spanner/OptionsImposter.java
@@ -28,4 +28,12 @@ public class OptionsImposter {
   public static Duration maxCommitDelay(Options options) {
     return options.maxCommitDelay();
   }
+
+  public static boolean withCommitStats(Options options) {
+    return options.withCommitStats();
+  }
+
+  public static CommitResponse 
createCommitResponse(com.google.spanner.v1.CommitResponse proto) {
+    return new CommitResponse(proto);
+  }
 }
diff --git 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOWriteTest.java
 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOWriteTest.java
index 4adfe9fb0ea..cf8b876d039 100644
--- 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOWriteTest.java
+++ 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOWriteTest.java
@@ -67,6 +67,7 @@ import 
org.apache.beam.runners.core.metrics.MonitoringInfoConstants;
 import org.apache.beam.runners.core.metrics.MonitoringInfoMetricName;
 import org.apache.beam.sdk.Pipeline.PipelineExecutionException;
 import org.apache.beam.sdk.PipelineResult;
+import org.apache.beam.sdk.coders.Coder;
 import org.apache.beam.sdk.coders.SerializableCoder;
 import org.apache.beam.sdk.io.gcp.spanner.SpannerIO.BatchableMutationFilterFn;
 import org.apache.beam.sdk.io.gcp.spanner.SpannerIO.FailureMode;
@@ -76,6 +77,7 @@ import 
org.apache.beam.sdk.io.gcp.spanner.SpannerIO.WriteToSpannerFn;
 import org.apache.beam.sdk.metrics.Lineage;
 import org.apache.beam.sdk.metrics.MetricsEnvironment;
 import org.apache.beam.sdk.options.ValueProvider.StaticValueProvider;
+import org.apache.beam.sdk.testing.CoderProperties;
 import org.apache.beam.sdk.testing.PAssert;
 import org.apache.beam.sdk.testing.TestPipeline;
 import org.apache.beam.sdk.testing.TestStream;
@@ -84,15 +86,19 @@ import org.apache.beam.sdk.transforms.DoFn;
 import org.apache.beam.sdk.transforms.DoFn.FinishBundleContext;
 import org.apache.beam.sdk.transforms.DoFn.OutputReceiver;
 import org.apache.beam.sdk.transforms.DoFn.ProcessContext;
+import org.apache.beam.sdk.transforms.ParDo;
 import org.apache.beam.sdk.transforms.View;
 import org.apache.beam.sdk.transforms.display.DisplayData;
 import org.apache.beam.sdk.util.Sleeper;
+import org.apache.beam.sdk.values.KV;
 import org.apache.beam.sdk.values.PCollection;
 import org.apache.beam.sdk.values.PCollectionView;
+import org.apache.beam.sdk.values.TimestampedValue;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Preconditions;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
 import 
org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.Iterables;
 import org.joda.time.Duration;
+import org.joda.time.Instant;
 import org.junit.Before;
 import org.junit.Rule;
 import org.junit.Test;
@@ -1626,6 +1632,199 @@ public class SpannerIOWriteTest implements Serializable 
{
     verify(serviceFactory.mockSpanner(), times(2)).close();
   }
 
+  @Test
+  public void testWriteResultsCoder() throws Exception {
+    Coder<WriteResults> coder = 
pipeline.getCoderRegistry().getCoder(WriteResults.class);
+    Timestamp commitTs = Timestamp.ofTimeSecondsAndNanos(1700000000L, 
123456789);
+    Timestamp snapshotTs = Timestamp.ofTimeSecondsAndNanos(1699999999L, 
987654321);
+
+    WriteResults full = WriteResults.create(5L, commitTs, snapshotTs);
+    WriteResults withoutSnapshot = WriteResults.create(3L, commitTs, null);
+    WriteResults allNullTimestamps = WriteResults.create(0L, null, null);
+
+    CoderProperties.coderDecodeEncodeEqual(coder, full);
+    CoderProperties.coderDecodeEncodeEqual(coder, withoutSnapshot);
+    CoderProperties.coderDecodeEncodeEqual(coder, allNullTimestamps);
+  }
+
+  @Test
+  public void testGatherSortAndBatchFn_usesMinBundleTimestamp() throws 
Exception {
+    GatherSortCreateBatchesFn testFn =
+        new GatherSortCreateBatchesFn(
+            10000000, // batch bytes
+            100, // batch cells
+            5, // batch rows
+            100, // groupingFactor
+            null);
+
+    DoFn<MutationGroup, Iterable<MutationGroup>>.ProcessContext 
mockProcessContext =
+        Mockito.mock(ProcessContext.class);
+    DoFn<MutationGroup, Iterable<MutationGroup>>.FinishBundleContext 
mockFinishBundleContext =
+        Mockito.mock(FinishBundleContext.class);
+    when(mockProcessContext.sideInput(any())).thenReturn(getSchema());
+
+    Instant t1 = Instant.ofEpochMilli(3000L);
+    Instant t2 = Instant.ofEpochMilli(1000L);
+    Instant t3 = Instant.ofEpochMilli(2000L);
+
+    
when(mockProcessContext.element()).thenReturn(buildMutationGroup(buildUpsertMutation(1L)));
+    when(mockProcessContext.timestamp()).thenReturn(t1);
+    testFn.processElement(mockProcessContext, null);
+
+    
when(mockProcessContext.element()).thenReturn(buildMutationGroup(buildUpsertMutation(2L)));
+    when(mockProcessContext.timestamp()).thenReturn(t2);
+    testFn.processElement(mockProcessContext, null);
+
+    
when(mockProcessContext.element()).thenReturn(buildMutationGroup(buildUpsertMutation(3L)));
+    when(mockProcessContext.timestamp()).thenReturn(t3);
+    testFn.processElement(mockProcessContext, null);
+
+    testFn.finishBundle(mockFinishBundleContext);
+
+    verify(mockFinishBundleContext, times(1)).output(any(), eq(t2), any());
+  }
+
+  @Test
+  public void testWriteWithResultsEmitsCommitMetadataAndEnablesCommitStats() 
throws Exception {
+    Timestamp expectedCommitTs = Timestamp.ofTimeSecondsAndNanos(1700000000L, 
500);
+    Timestamp expectedSnapshotTs = 
Timestamp.ofTimeSecondsAndNanos(1699999995L, 250);
+    CommitResponse commitResponse =
+        OptionsImposter.createCommitResponse(
+            com.google.spanner.v1.CommitResponse.newBuilder()
+                .setCommitTimestamp(expectedCommitTs.toProto())
+                .setSnapshotTimestamp(expectedSnapshotTs.toProto())
+                .setCommitStats(
+                    
com.google.spanner.v1.CommitResponse.CommitStats.newBuilder()
+                        .setMutationCount(2L)
+                        .build())
+                .build());
+
+    when(serviceFactory
+            .mockDatabaseClient()
+            .writeAtLeastOnceWithOptions(
+                mutationBatchesCaptor.capture(),
+                any(ReadQueryUpdateTransactionOption.class),
+                eq(Options.commitStats())))
+        .thenReturn(commitResponse);
+
+    Mutation m1 = buildUpsertMutation(1L);
+
+    PCollection<WriteResults> results =
+        pipeline
+            .apply(Create.of(m1))
+            .apply(
+                SpannerIO.write()
+                    .withProjectId(PROJECT_NAME)
+                    .withInstanceId(INSTANCE_NAME)
+                    .withDatabaseId(DATABASE_NAME)
+                    .withServiceFactory(serviceFactory)
+                    .withWriteResults());
+
+    PAssert.that(results)
+        .containsInAnyOrder(WriteResults.create(2L, expectedCommitTs, 
expectedSnapshotTs));
+
+    pipeline.run();
+
+    verify(serviceFactory.mockDatabaseClient(), times(1))
+        .writeAtLeastOnceWithOptions(
+            mutationsInNoOrder(buildMutationBatch(m1)),
+            any(ReadQueryUpdateTransactionOption.class),
+            eq(Options.commitStats()));
+  }
+
+  @Test
+  public void testWriteGroupedWithResultsEmitsCommitMetadata() throws 
Exception {
+    Timestamp expectedCommitTs = Timestamp.ofTimeSecondsAndNanos(1700000100L, 
0);
+    CommitResponse commitResponse =
+        OptionsImposter.createCommitResponse(
+            com.google.spanner.v1.CommitResponse.newBuilder()
+                .setCommitTimestamp(expectedCommitTs.toProto())
+                .setCommitStats(
+                    
com.google.spanner.v1.CommitResponse.CommitStats.newBuilder()
+                        .setMutationCount(2L)
+                        .build())
+                .build());
+
+    when(serviceFactory
+            .mockDatabaseClient()
+            .writeAtLeastOnceWithOptions(
+                mutationBatchesCaptor.capture(),
+                any(ReadQueryUpdateTransactionOption.class),
+                eq(Options.commitStats())))
+        .thenReturn(commitResponse);
+
+    Mutation m1 = buildUpsertMutation(1L);
+    Mutation m2 = buildUpsertMutation(2L);
+    MutationGroup g = buildMutationGroup(m1, m2);
+
+    PCollection<WriteResults> results =
+        pipeline
+            .apply(Create.of(Arrays.asList(g)))
+            .apply(
+                SpannerIO.write()
+                    .withProjectId(PROJECT_NAME)
+                    .withInstanceId(INSTANCE_NAME)
+                    .withDatabaseId(DATABASE_NAME)
+                    .withServiceFactory(serviceFactory)
+                    .grouped()
+                    .withWriteResults());
+
+    PAssert.that(results).containsInAnyOrder(WriteResults.create(2L, 
expectedCommitTs, null));
+
+    pipeline.run();
+  }
+
+  @Test
+  public void 
testWriteWithResultsPreservesElementTimestampWhenBatchingDisabled() throws 
Exception {
+    Timestamp expectedCommitTs = Timestamp.ofTimeSecondsAndNanos(1700000200L, 
0);
+    CommitResponse commitResponse =
+        OptionsImposter.createCommitResponse(
+            com.google.spanner.v1.CommitResponse.newBuilder()
+                .setCommitTimestamp(expectedCommitTs.toProto())
+                .setCommitStats(
+                    
com.google.spanner.v1.CommitResponse.CommitStats.newBuilder()
+                        .setMutationCount(1L)
+                        .build())
+                .build());
+
+    when(serviceFactory
+            .mockDatabaseClient()
+            .writeAtLeastOnceWithOptions(
+                mutationBatchesCaptor.capture(),
+                any(ReadQueryUpdateTransactionOption.class),
+                eq(Options.commitStats())))
+        .thenReturn(commitResponse);
+
+    Mutation m1 = buildUpsertMutation(1L);
+    Instant recordTimestamp = Instant.ofEpochMilli(123456789L);
+
+    PCollection<KV<Instant, WriteResults>> timestampedResults =
+        pipeline
+            .apply(Create.timestamped(TimestampedValue.of(m1, 
recordTimestamp)))
+            .apply(
+                SpannerIO.write()
+                    .withProjectId(PROJECT_NAME)
+                    .withInstanceId(INSTANCE_NAME)
+                    .withDatabaseId(DATABASE_NAME)
+                    .withBatchSizeBytes(0)
+                    .withServiceFactory(serviceFactory)
+                    .withWriteResults())
+            .apply(
+                ParDo.of(
+                    new DoFn<WriteResults, KV<Instant, WriteResults>>() {
+                      @ProcessElement
+                      public void processElement(ProcessContext c) {
+                        c.output(KV.of(c.timestamp(), c.element()));
+                      }
+                    }));
+
+    PAssert.that(timestampedResults)
+        .containsInAnyOrder(
+            KV.of(recordTimestamp, WriteResults.create(1L, expectedCommitTs, 
null)));
+
+    pipeline.run();
+  }
+
   static MutationGroup buildMutationGroup(Mutation m, Mutation... other) {
     return MutationGroup.create(m, other);
   }
diff --git 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteIT.java
 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteIT.java
index 318237e1118..cd4a01e7321 100644
--- 
a/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteIT.java
+++ 
b/sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerWriteIT.java
@@ -21,6 +21,8 @@ import static 
org.apache.beam.sdk.io.gcp.spanner.SpannerTestHelper.isOmni;
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
 import static org.junit.Assume.assumeFalse;
 
 import com.google.api.gax.longrunning.OperationFuture;
@@ -45,6 +47,7 @@ import org.apache.beam.sdk.options.Default;
 import org.apache.beam.sdk.options.Description;
 import org.apache.beam.sdk.options.PipelineOptionsFactory;
 import org.apache.beam.sdk.schemas.Schema;
+import org.apache.beam.sdk.testing.PAssert;
 import org.apache.beam.sdk.testing.TestPipeline;
 import org.apache.beam.sdk.testing.TestPipelineOptions;
 import org.apache.beam.sdk.transforms.Create;
@@ -53,6 +56,7 @@ import org.apache.beam.sdk.transforms.MapElements;
 import org.apache.beam.sdk.transforms.ParDo;
 import org.apache.beam.sdk.transforms.View;
 import org.apache.beam.sdk.transforms.Wait;
+import org.apache.beam.sdk.values.PCollection;
 import org.apache.beam.sdk.values.PCollectionRowTuple;
 import org.apache.beam.sdk.values.PCollectionView;
 import org.apache.beam.sdk.values.Row;
@@ -230,6 +234,40 @@ public class SpannerWriteIT {
     assertThat(countNumberOfRecords(pgDatabaseName), equalTo((long) 
numRecords));
   }
 
+  @Test
+  public void testWriteWithResults() throws Exception {
+    int numRecords = 100;
+    PCollection<WriteResults> writeResults =
+        p.apply("Init", GenerateSequence.from(0).to(numRecords))
+            .apply("Generate mu", ParDo.of(new 
GenerateMutations(options.getTable())))
+            .apply(
+                "Write db with results",
+                SpannerTestHelper.setUpSpannerIO(
+                        SpannerIO.write()
+                            .withProjectId(project)
+                            .withInstanceId(options.getInstanceId())
+                            .withDatabaseId(databaseName))
+                    .withWriteResults());
+
+    PAssert.that(writeResults)
+        .satisfies(
+            results -> {
+              long totalMutations = 0;
+              for (WriteResults r : results) {
+                assertNotNull(r.getCommitTimestamp());
+                assertTrue(r.getMutationCount() > 0);
+                totalMutations += r.getMutationCount();
+              }
+              assertThat(totalMutations, equalTo(2L * numRecords));
+              return null;
+            });
+
+    PipelineResult result = p.run();
+    result.waitUntilFinish();
+    assertThat(result.getState(), is(PipelineResult.State.DONE));
+    assertThat(countNumberOfRecords(databaseName), equalTo((long) numRecords));
+  }
+
   @Test
   public void testWriteViaSchemaTransform() throws Exception {
     assumeFalse(

Reply via email to