Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.time.Duration;
import java.time.Instant;
import java.time.format.DateTimeParseException;
import java.util.concurrent.atomic.AtomicLong;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand All @@ -37,12 +38,12 @@ public class InitialLimitedDurationErrorInjectionPolicy
LoggerFactory.getLogger(InitialLimitedDurationErrorInjectionPolicy.class);
private static final long serialVersionUID = 1L;

private Instant startTime;
private static volatile Instant startTime = null;
private static final AtomicLong callCount = new AtomicLong(0);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was AtomicLong needed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we made the state static, all the threads on a Dataflow worker are now hitting the exact same counter. So if we were to use synchronized block, every thread would have to acquire the lock and wait. AtomicLong is better as incrementAndGet() allows threads to update the counter without blocking each other.

private final Duration injectionDuration;
private final String effectiveDurationParameter;
private String errorCodeToBeInjected;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The errorCodeToBeInjected field is not initialized with a default value. When the errorCode parameter is blank or missing in the input JSON, the constructor logs that it is using the default DEADLINE_EXCEEDED, but it does not actually assign a value to errorCodeToBeInjected, leaving it as null. Initializing it to Code.DEADLINE_EXCEEDED.name() by default ensures that the policy behaves as documented and avoids potential NullPointerExceptions in the caller.

Suggested change
private String errorCodeToBeInjected;
private String errorCodeToBeInjected = Code.DEADLINE_EXCEEDED.name();

private Clock clock;
private long callCount;

private static final String DEFAULT_DURATION = "PT10M";
private static final String DURATION_FIELD_IN_OBJECT = "duration";
Expand Down Expand Up @@ -124,22 +125,20 @@ public InitialLimitedDurationErrorInjectionPolicy(JsonNode inputParameter, Clock
*/
@Override
public boolean shouldInjectionError() {
if (this.startTime == null) {
synchronized (this) {
if (this.startTime == null) {
this.startTime = Instant.now(clock);
if (startTime == null) {
synchronized (InitialLimitedDurationErrorInjectionPolicy.class) {
if (startTime == null) {
startTime = Instant.now(clock);
Comment on lines +129 to +131

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are synchronising on the class now instead of the object of the class? Do I understand that right? What was the issue happening earlier?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What was happening earlier:
The original issue was that Beam actually deserializes multiple instances of this policy per worker (usually one per thread or bundle). When the variables weren't static, each instance got its own isolated timer. This meant the duration kept resetting per-thread instead of applying globally - so i kept getting the same fake exception for each retry ultimately failing the job.

I fixed that by making the state static so it's shared across the worker. But once the state is static, locking on this (the instance) is unsafe because threads using different instances would acquire different locks, leading to race conditions. Synchronizing on the Class object ensures all instances share the exact same lock to initialize the global timer safely.

LOG.info(
"First call detected. Errors will be injected for {} starting from {}.",
this.injectionDuration,
this.startTime);
startTime);
}
}
}
Comment thread
aasthabharill marked this conversation as resolved.
synchronized (this) {
++callCount;
}
long currentCallCount = callCount.incrementAndGet();

if (callCount < INITIAL_ALLOWED_CALLS_COUNT) {
if (currentCallCount < INITIAL_ALLOWED_CALLS_COUNT) {
return false;
}

Expand Down Expand Up @@ -186,6 +185,11 @@ void setClockForTesting(Clock clock) {
this.clock = clock;
}

public static void resetForTesting() {
startTime = null;
callCount.set(0);
}

@Override
public String toString() {
return "InitialLimitedDurationErrorInjectionPolicy{"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,16 @@
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneOffset;
import org.junit.Before;
import org.junit.Test;

public class InitialLimitedDurationErrorInjectionPolicyTest {

@Before
public void setUp() {
InitialLimitedDurationErrorInjectionPolicy.resetForTesting();
}

private ObjectNode createInputObject(String duration) {
ObjectNode node = JsonNodeFactory.instance.objectNode();
if (duration != null) {
Expand Down Expand Up @@ -268,4 +274,26 @@ public void shouldInjectError_startTimeDoesNotChangeAfterFirstCall() {
Instant thirdStartTime = policy.getStartTime();
assertEquals("Start time should still not change", firstStartTime, thirdStartTime);
}

@Test
public void constructor_shouldParseErrorCode() {
ObjectNode input = JsonNodeFactory.instance.objectNode();
input.put("duration", "PT5S");
input.put("errorCode", "UNAVAILABLE");
Clock clock = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
InitialLimitedDurationErrorInjectionPolicy policy =
new InitialLimitedDurationErrorInjectionPolicy(input, clock);

assertEquals("UNAVAILABLE", policy.getErrorCodeToBeInjected());
}

@Test
public void constructor_shouldUseDefaultErrorCodeIfBlank() {
ObjectNode input = JsonNodeFactory.instance.objectNode();
input.put("duration", "PT5S");
input.put("errorCode", " ");
Clock clock = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
InitialLimitedDurationErrorInjectionPolicy policy =
new InitialLimitedDurationErrorInjectionPolicy(input, clock);
}
Comment thread
aasthabharill marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,95 @@ public void shouldInjectError_alwaysReturnsFalse() {
// The method should always return false, as its purpose is to delay, not to inject an error.
assertThat(policy.shouldInjectionError()).isFalse();
}

@Test
public void shouldInjectError_concurrentCallsCoverDoubleCheckedLocking() throws Exception {
ObjectNode input = JsonNodeFactory.instance.objectNode();
input.put("transactionTimeoutBakeDuration", "PT5M");
TransactionTimeoutInjectionPolicy policy =
new TransactionTimeoutInjectionPolicy(input, Clock.systemUTC());

Thread waitingThread =
new Thread(
() -> {
policy.shouldInjectionError();
});

synchronized (policy) {
waitingThread.start();
Thread.sleep(500);

java.lang.reflect.Field startTimeField =
TransactionTimeoutInjectionPolicy.class.getDeclaredField("startTime");
startTimeField.setAccessible(true);
startTimeField.set(policy, Instant.now());
}

waitingThread.join();
java.lang.reflect.Field startTimeField =
TransactionTimeoutInjectionPolicy.class.getDeclaredField("startTime");
startTimeField.setAccessible(true);
assertThat(startTimeField.get(policy)).isNotNull();
}

@Test
public void constructor_shouldParseJobStartTime() {
ObjectNode input = JsonNodeFactory.instance.objectNode();
input.put("jobStartTime", "2025-01-01T00:00:00Z");
Clock clock = Clock.fixed(Instant.EPOCH, ZoneOffset.UTC);
TransactionTimeoutInjectionPolicy policy = new TransactionTimeoutInjectionPolicy(input, clock);
policy.setClockForTesting(Clock.fixed(Instant.parse("2025-01-01T01:00:00Z"), ZoneOffset.UTC));
long start = System.currentTimeMillis();
policy.shouldInjectionError();
long end = System.currentTimeMillis();
assertThat(end - start).isLessThan(50L);
}

@Test
public void constructor_shouldThrowExceptionForInvalidJobStartTime() {
ObjectNode input = JsonNodeFactory.instance.objectNode();
input.put("jobStartTime", "Invalid");
IllegalArgumentException e =
assertThrows(
IllegalArgumentException.class,
() -> new TransactionTimeoutInjectionPolicy(input, Clock.systemUTC()));
assertThat(e).hasMessageThat().contains("Failed to parse jobStartTime");
}

@Test
public void shouldInjectionError_initializesClockIfNull() throws Exception {
ObjectNode input = JsonNodeFactory.instance.objectNode();
TransactionTimeoutInjectionPolicy policy = new TransactionTimeoutInjectionPolicy(input);
java.lang.reflect.Field clockField =
TransactionTimeoutInjectionPolicy.class.getDeclaredField("clock");
clockField.setAccessible(true);
clockField.set(policy, null);

assertThat(policy.shouldInjectionError()).isFalse();
}

@Test
public void shouldInjectDelay_handlesInterruptedException() throws Exception {
ObjectNode input = JsonNodeFactory.instance.objectNode();
input.put("transactionTimeoutBakeDuration", "PT5M");
input.put("transactionDelayDuration", "PT5M");
TransactionTimeoutInjectionPolicy policy =
new TransactionTimeoutInjectionPolicy(input, Clock.systemUTC());

policy.setRandomForTesting(
new Random() {
@Override
public double nextDouble() {
return 0.1;
}
});

Thread thread = new Thread(() -> policy.shouldInjectionError());
thread.start();
Thread.sleep(200);
thread.interrupt();

thread.join(1000);
assertThat(thread.isAlive()).isFalse();
}
}
181 changes: 108 additions & 73 deletions v2/gcs-spanner-dv/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,82 +16,117 @@
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~-->

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>com.google.cloud.teleport.v2</groupId>
<artifactId>dynamic-templates</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>
<parent>
<groupId>com.google.cloud.teleport.v2</groupId>
<artifactId>dynamic-templates</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>gcs-spanner-dv</artifactId>
<artifactId>gcs-spanner-dv</artifactId>

<dependencies>
<dependency>
<groupId>com.google.cloud.teleport.v2</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-core</artifactId>
</dependency>
<dependencies>
<dependency>
<groupId>com.google.cloud.teleport.v2</groupId>
<artifactId>common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>com.google.cloud</groupId>
<artifactId>google-cloud-core</artifactId>
</dependency>

<!-- Test Dependencies -->
<dependency>
<groupId>com.google.cloud.teleport</groupId>
<artifactId>it-google-cloud-platform</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.beam</groupId>
<artifactId>beam-it-jdbc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql-connector-java.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.cloud.teleport.v2</groupId>
<artifactId>spanner-common</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>LATEST</version> <!-- Use the latest version available -->
<scope>test</scope>
</dependency>
</dependencies>
<!-- Test Dependencies -->
<dependency>
<groupId>com.google.cloud.teleport</groupId>
<artifactId>it-google-cloud-platform</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.beam</groupId>
<artifactId>beam-it-jdbc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql-connector-java.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.google.cloud.teleport.v2</groupId>
<artifactId>spanner-common</artifactId>
<version>1.0-SNAPSHOT</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-inline</artifactId>
<version>LATEST</version> <!-- Use the latest version available -->
<scope>test</scope>
</dependency>
</dependencies>

<!-- Jacoco configuration -->
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<configuration>
<excludes combine.children="append">
<!-- combine.children appends to the existing exclusions in the
parent POM(s). -->
<exclude>com/google/cloud/teleport/v2/dto/**</exclude>
<exclude>com/google/cloud/teleport/v2/constants/**</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
<!-- Jacoco configuration -->
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<configuration>
<excludes combine.children="append">
<!-- combine.children appends to the existing exclusions in the
parent POM(s). -->
<exclude>com/google/cloud/teleport/v2/dto/**</exclude>
<exclude>com/google/cloud/teleport/v2/constants/**</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>

<profiles>
<profile>
<id>useRealSpanner</id>
<activation>
<activeByDefault>true</activeByDefault>
<property>
<name>!activateFailureInjection</name>
</property>
</activation>
<dependencies>
<dependency>
<groupId>com.google.cloud.teleport.v2</groupId>
<artifactId>real-spanner-service</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</profile>
<profile>
<id>failureInjectionTest</id>
<activation>
<property>
<name>activateFailureInjection</name>
<value>true</value>
</property>
</activation>
<dependencies>
<dependency>
<groupId>com.google.cloud.teleport.v2</groupId>
<artifactId>failure-injected-spanner-service</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</profile>
</profiles>
</project>
Loading
Loading