Skip to content

Commit d4baa46

Browse files
author
Atharva Moroney
committed
Add Cloud Spanner Directed Reads support to SpannerIO
This change allows Beam pipeline developers to configure Cloud Spanner DirectedReadOptions on SpannerConfig, as well as on SpannerIO.read(), readAll(), and readChangeStream(). - Adds getDirectedReadOptions() and withDirectedReadOptions(...) overloads (accepting DirectedReadOptions, ValueProvider<DirectedReadOptions>, and String) to SpannerConfig. - Adds an internal parseDirectedReadOptions helper supporting both standard JSON strings and shorthand <location>:<type> strings (e.g. us-central1:READ_ONLY). - Threads DirectedReadOptions into SpannerOptions.Builder in SpannerAccessor so that all DatabaseClient instances and ChangeStreamDao queries automatically inherit the directed read configuration. - Adds delegation methods to SpannerIO.Read, ReadAll, and ReadChangeStream. - Adds comprehensive unit tests in SpannerAccessorTest and SpannerIOReadChangeStreamTest.
1 parent 123dc7f commit d4baa46

5 files changed

Lines changed: 232 additions & 0 deletions

File tree

sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerAccessor.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import com.google.cloud.spanner.v1.stub.SpannerStubSettings;
3737
import com.google.spanner.v1.CommitRequest;
3838
import com.google.spanner.v1.CommitResponse;
39+
import com.google.spanner.v1.DirectedReadOptions;
3940
import com.google.spanner.v1.ExecuteSqlRequest;
4041
import com.google.spanner.v1.PartialResultSet;
4142
import java.util.HashSet;
@@ -279,6 +280,10 @@ static SpannerOptions buildSpannerOptions(SpannerConfig spannerConfig) {
279280
if (databaseRole != null && databaseRole.get() != null && !databaseRole.get().isEmpty()) {
280281
builder.setDatabaseRole(databaseRole.get());
281282
}
283+
ValueProvider<DirectedReadOptions> directedReadOptions = spannerConfig.getDirectedReadOptions();
284+
if (directedReadOptions != null && directedReadOptions.get() != null) {
285+
builder.setDirectedReadOptions(directedReadOptions.get());
286+
}
282287
ValueProvider<Credentials> credentials = spannerConfig.getCredentials();
283288
if (credentials != null && credentials.get() != null) {
284289
builder.setCredentials(credentials.get());

sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerConfig.java

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,15 @@
2828
import com.google.cloud.spanner.Options.RpcPriority;
2929
import com.google.cloud.spanner.Spanner;
3030
import com.google.cloud.spanner.SpannerOptions;
31+
import com.google.protobuf.InvalidProtocolBufferException;
32+
import com.google.protobuf.util.JsonFormat;
33+
import com.google.spanner.v1.DirectedReadOptions;
3134
import java.io.Serializable;
35+
import java.util.List;
3236
import org.apache.beam.sdk.options.ValueProvider;
3337
import org.apache.beam.sdk.transforms.display.DisplayData;
3438
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.annotations.VisibleForTesting;
39+
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.base.Splitter;
3540
import org.apache.beam.vendor.guava.v32_1_2_jre.com.google.common.collect.ImmutableSet;
3641
import org.checkerframework.checker.nullness.qual.Nullable;
3742
import org.joda.time.Duration;
@@ -91,6 +96,8 @@ public String getHostValue() {
9196

9297
public abstract @Nullable ValueProvider<String> getDatabaseRole();
9398

99+
public abstract @Nullable ValueProvider<DirectedReadOptions> getDirectedReadOptions();
100+
94101
public abstract @Nullable ValueProvider<Duration> getPartitionQueryTimeout();
95102

96103
public abstract @Nullable ValueProvider<Duration> getPartitionReadTimeout();
@@ -185,6 +192,8 @@ abstract Builder setExecuteStreamingSqlRetrySettings(
185192

186193
abstract Builder setDatabaseRole(ValueProvider<String> databaseRole);
187194

195+
abstract Builder setDirectedReadOptions(ValueProvider<DirectedReadOptions> directedReadOptions);
196+
188197
abstract Builder setDataBoostEnabled(ValueProvider<Boolean> dataBoostEnabled);
189198

190199
abstract Builder setPartitionQueryTimeout(ValueProvider<Duration> partitionQueryTimeout);
@@ -335,6 +344,75 @@ public SpannerConfig withDatabaseRole(ValueProvider<String> databaseRole) {
335344
return toBuilder().setDatabaseRole(databaseRole).build();
336345
}
337346

347+
/** Specifies the Cloud Spanner directed read options. */
348+
public SpannerConfig withDirectedReadOptions(DirectedReadOptions directedReadOptions) {
349+
return withDirectedReadOptions(ValueProvider.StaticValueProvider.of(directedReadOptions));
350+
}
351+
352+
/** Specifies the Cloud Spanner directed read options. */
353+
public SpannerConfig withDirectedReadOptions(
354+
ValueProvider<DirectedReadOptions> directedReadOptions) {
355+
return toBuilder().setDirectedReadOptions(directedReadOptions).build();
356+
}
357+
358+
/** Specifies the Cloud Spanner directed read options from a string representation. */
359+
public SpannerConfig withDirectedReadOptions(String directedReadOptions) {
360+
if (directedReadOptions == null || directedReadOptions.isEmpty()) {
361+
return this;
362+
}
363+
return withDirectedReadOptions(parseDirectedReadOptions(directedReadOptions));
364+
}
365+
366+
@VisibleForTesting
367+
static DirectedReadOptions parseDirectedReadOptions(String directedReadOptions) {
368+
if (directedReadOptions == null || directedReadOptions.isEmpty()) {
369+
return DirectedReadOptions.getDefaultInstance();
370+
}
371+
if (!directedReadOptions.trim().startsWith("{")) {
372+
List<String> parts = Splitter.on(':').splitToList(directedReadOptions);
373+
DirectedReadOptions.ReplicaSelection.Builder selectionBuilder =
374+
DirectedReadOptions.ReplicaSelection.newBuilder();
375+
if (parts.size() == 2) {
376+
selectionBuilder.setLocation(parts.get(0).trim());
377+
try {
378+
selectionBuilder.setType(
379+
DirectedReadOptions.ReplicaSelection.Type.valueOf(parts.get(1).trim().toUpperCase()));
380+
} catch (IllegalArgumentException e) {
381+
throw new IllegalArgumentException(
382+
"Invalid replica type in directed read options: " + parts.get(1), e);
383+
}
384+
} else if (parts.size() == 1) {
385+
try {
386+
selectionBuilder.setType(
387+
DirectedReadOptions.ReplicaSelection.Type.valueOf(parts.get(0).trim().toUpperCase()));
388+
} catch (IllegalArgumentException e) {
389+
throw new IllegalArgumentException(
390+
"Expected <location>:<type> or valid JSON for directed read options, but got: "
391+
+ directedReadOptions,
392+
e);
393+
}
394+
} else {
395+
throw new IllegalArgumentException(
396+
"Expected <location>:<type> or valid JSON for directed read options, but got: "
397+
+ directedReadOptions);
398+
}
399+
return DirectedReadOptions.newBuilder()
400+
.setIncludeReplicas(
401+
DirectedReadOptions.IncludeReplicas.newBuilder()
402+
.addReplicaSelections(selectionBuilder.build()))
403+
.build();
404+
}
405+
406+
DirectedReadOptions.Builder builder = DirectedReadOptions.newBuilder();
407+
try {
408+
JsonFormat.parser().merge(directedReadOptions, builder);
409+
return builder.build();
410+
} catch (InvalidProtocolBufferException e) {
411+
throw new IllegalArgumentException(
412+
"Failed to parse DirectedReadOptions JSON: " + directedReadOptions, e);
413+
}
414+
}
415+
338416
/** Specifies if the pipeline has to be run on the independent compute resource. */
339417
public SpannerConfig withDataBoostEnabled(ValueProvider<Boolean> dataBoostEnabled) {
340418
return toBuilder().setDataBoostEnabled(dataBoostEnabled).build();

sdks/java/io/google-cloud-platform/src/main/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIO.java

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
import com.google.cloud.spanner.TimestampBound;
6262
import com.google.gson.Gson;
6363
import com.google.gson.GsonBuilder;
64+
import com.google.spanner.v1.DirectedReadOptions;
6465
import java.io.ByteArrayInputStream;
6566
import java.io.ByteArrayOutputStream;
6667
import java.io.IOException;
@@ -638,6 +639,24 @@ public ReadAll withExperimentalHost(String experimentalHost) {
638639
return withExperimentalHost(ValueProvider.StaticValueProvider.of(experimentalHost));
639640
}
640641

642+
/** Specifies the directed read options for Cloud Spanner. */
643+
public ReadAll withDirectedReadOptions(DirectedReadOptions directedReadOptions) {
644+
SpannerConfig config = getSpannerConfig();
645+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
646+
}
647+
648+
/** Specifies the directed read options for Cloud Spanner. */
649+
public ReadAll withDirectedReadOptions(ValueProvider<DirectedReadOptions> directedReadOptions) {
650+
SpannerConfig config = getSpannerConfig();
651+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
652+
}
653+
654+
/** Specifies the directed read options for Cloud Spanner from a string representation. */
655+
public ReadAll withDirectedReadOptions(String directedReadOptions) {
656+
SpannerConfig config = getSpannerConfig();
657+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
658+
}
659+
641660
/**
642661
* Specifies whether to use plaintext channel.
643662
*
@@ -927,6 +946,24 @@ public Read withExperimentalHost(String experimentalHost) {
927946
return withExperimentalHost(ValueProvider.StaticValueProvider.of(experimentalHost));
928947
}
929948

949+
/** Specifies the directed read options for Cloud Spanner. */
950+
public Read withDirectedReadOptions(DirectedReadOptions directedReadOptions) {
951+
SpannerConfig config = getSpannerConfig();
952+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
953+
}
954+
955+
/** Specifies the directed read options for Cloud Spanner. */
956+
public Read withDirectedReadOptions(ValueProvider<DirectedReadOptions> directedReadOptions) {
957+
SpannerConfig config = getSpannerConfig();
958+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
959+
}
960+
961+
/** Specifies the directed read options for Cloud Spanner from a string representation. */
962+
public Read withDirectedReadOptions(String directedReadOptions) {
963+
SpannerConfig config = getSpannerConfig();
964+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
965+
}
966+
930967
/**
931968
* Specifies whether to use plaintext channel.
932969
*
@@ -2052,6 +2089,28 @@ public ReadChangeStream withExperimentalHost(String experimentalHost) {
20522089
return withExperimentalHost(ValueProvider.StaticValueProvider.of(experimentalHost));
20532090
}
20542091

2092+
/** Specifies the directed read options for change stream queries. */
2093+
public ReadChangeStream withDirectedReadOptions(DirectedReadOptions directedReadOptions) {
2094+
SpannerConfig config = getSpannerConfig();
2095+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
2096+
}
2097+
2098+
/** Specifies the directed read options for change stream queries. */
2099+
public ReadChangeStream withDirectedReadOptions(
2100+
ValueProvider<DirectedReadOptions> directedReadOptions) {
2101+
SpannerConfig config = getSpannerConfig();
2102+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
2103+
}
2104+
2105+
/**
2106+
* Specifies the directed read options for change stream queries from a string representation
2107+
* (e.g., JSON string or "us-central1:READ_ONLY").
2108+
*/
2109+
public ReadChangeStream withDirectedReadOptions(String directedReadOptions) {
2110+
SpannerConfig config = getSpannerConfig();
2111+
return withSpannerConfig(config.withDirectedReadOptions(directedReadOptions));
2112+
}
2113+
20552114
/**
20562115
* Specifies whether to use plaintext channel.
20572116
*

sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerAccessorTest.java

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
import com.google.cloud.spanner.DatabaseId;
2626
import com.google.cloud.spanner.SpannerOptions;
27+
import com.google.spanner.v1.DirectedReadOptions;
2728
import org.apache.beam.sdk.extensions.gcp.auth.TestCredential;
2829
import org.apache.beam.sdk.options.ValueProvider.StaticValueProvider;
2930
import org.junit.Before;
@@ -251,4 +252,78 @@ public void testBuildSpannerOptionsWithCustomHost() {
251252
SpannerOptions options = SpannerAccessor.buildSpannerOptions(config1);
252253
assertEquals(host, options.getHost());
253254
}
255+
256+
@Test
257+
public void testBuildSpannerOptionsWithDirectedReadOptions() {
258+
DirectedReadOptions directedReadOptions =
259+
DirectedReadOptions.newBuilder()
260+
.setIncludeReplicas(
261+
DirectedReadOptions.IncludeReplicas.newBuilder()
262+
.addReplicaSelections(
263+
DirectedReadOptions.ReplicaSelection.newBuilder()
264+
.setLocation("us-central1")
265+
.setType(DirectedReadOptions.ReplicaSelection.Type.READ_ONLY)))
266+
.build();
267+
SpannerConfig config1 =
268+
SpannerConfig.create()
269+
.toBuilder()
270+
.setServiceFactory(serviceFactory)
271+
.setDirectedReadOptions(StaticValueProvider.of(directedReadOptions))
272+
.setProjectId(StaticValueProvider.of("project"))
273+
.setInstanceId(StaticValueProvider.of("test1"))
274+
.setDatabaseId(StaticValueProvider.of("test1"))
275+
.build();
276+
277+
SpannerOptions options = SpannerAccessor.buildSpannerOptions(config1);
278+
assertEquals(directedReadOptions, options.getDirectedReadOptions());
279+
}
280+
281+
@Test
282+
public void testBuildSpannerOptionsWithDirectedReadOptionsString() {
283+
String directedReadString = "us-central1:READ_ONLY";
284+
SpannerConfig config1 =
285+
SpannerConfig.create()
286+
.withServiceFactory(serviceFactory)
287+
.withProjectId("project")
288+
.withInstanceId("test1")
289+
.withDatabaseId("test1")
290+
.withDirectedReadOptions(directedReadString);
291+
292+
SpannerOptions options = SpannerAccessor.buildSpannerOptions(config1);
293+
assertEquals(
294+
DirectedReadOptions.ReplicaSelection.Type.READ_ONLY,
295+
options.getDirectedReadOptions().getIncludeReplicas().getReplicaSelections(0).getType());
296+
assertEquals(
297+
"us-central1",
298+
options
299+
.getDirectedReadOptions()
300+
.getIncludeReplicas()
301+
.getReplicaSelections(0)
302+
.getLocation());
303+
}
304+
305+
@Test
306+
public void testBuildSpannerOptionsWithDirectedReadOptionsJson() {
307+
String jsonString =
308+
"{\"includeReplicas\":{\"replicaSelections\":[{\"location\":\"us-east1\",\"type\":\"READ_WRITE\"}]}}";
309+
SpannerConfig config1 =
310+
SpannerConfig.create()
311+
.withServiceFactory(serviceFactory)
312+
.withProjectId("project")
313+
.withInstanceId("test1")
314+
.withDatabaseId("test1")
315+
.withDirectedReadOptions(jsonString);
316+
317+
SpannerOptions options = SpannerAccessor.buildSpannerOptions(config1);
318+
assertEquals(
319+
DirectedReadOptions.ReplicaSelection.Type.READ_WRITE,
320+
options.getDirectedReadOptions().getIncludeReplicas().getReplicaSelections(0).getType());
321+
assertEquals(
322+
"us-east1",
323+
options
324+
.getDirectedReadOptions()
325+
.getIncludeReplicas()
326+
.getReplicaSelections(0)
327+
.getLocation());
328+
}
254329
}

sdks/java/io/google-cloud-platform/src/test/java/org/apache/beam/sdk/io/gcp/spanner/SpannerIOReadChangeStreamTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,21 @@ public void testWithDefaultCredential() {
159159
defaultCredential, changeStreamSpannerConfigWithCredential.getCredentials().get());
160160
assertEquals(defaultCredential, metadataSpannerConfigWithCredential.getCredentials().get());
161161
}
162+
163+
@Test
164+
public void testSetDirectedReadOptions() {
165+
String directedReadString = "us-central1:READ_ONLY";
166+
readChangeStream = readChangeStream.withDirectedReadOptions(directedReadString);
167+
SpannerConfig changeStreamSpannerConfig = readChangeStream.buildChangeStreamSpannerConfig();
168+
assertEquals(
169+
"us-central1",
170+
changeStreamSpannerConfig
171+
.getDirectedReadOptions()
172+
.get()
173+
.getIncludeReplicas()
174+
.getReplicaSelections(0)
175+
.getLocation());
176+
}
162177
}
163178

164179
/** Parameterized tests for Dialect and Partition Mode combinations. */

0 commit comments

Comments
 (0)