Skip to content

Commit f91cdbc

Browse files
authored
fix: thread-unsafe date parsing (#130)
* fix: thread-unsafe date parsing * bump version
1 parent b4f4b41 commit f91cdbc

File tree

4 files changed

+109
-13
lines changed

4 files changed

+109
-13
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ or [JVM](https://github.com/Eppo-exp/java-server-sdk) SDKs.
1313

1414
```groovy
1515
dependencies {
16-
implementation 'cloud.eppo:sdk-common-jvm:3.10.0'
16+
implementation 'cloud.eppo:sdk-common-jvm:3.11.3'
1717
}
1818
```
1919

build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ plugins {
66
}
77

88
group = 'cloud.eppo'
9-
version = '3.11.2'
9+
version = '3.11.3'
1010
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")
1111

1212
java {

src/main/java/cloud/eppo/Utils.java

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import org.slf4j.LoggerFactory;
1414

1515
public final class Utils {
16-
private static final SimpleDateFormat UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat();
16+
private static final ThreadLocal<SimpleDateFormat> UTC_ISO_DATE_FORMAT = buildUtcIsoDateFormat();
1717
private static final Logger log = LoggerFactory.getLogger(Utils.class);
1818
private static final ThreadLocal<MessageDigest> md = buildMd5MessageDigest();
1919

@@ -31,6 +31,21 @@ protected MessageDigest initialValue() {
3131
};
3232
}
3333

34+
@SuppressWarnings("AnonymousHasLambdaAlternative")
35+
private static ThreadLocal<SimpleDateFormat> buildUtcIsoDateFormat() {
36+
return new ThreadLocal<SimpleDateFormat>() {
37+
@Override
38+
protected SimpleDateFormat initialValue() {
39+
// Note: we don't use DateTimeFormatter.ISO_DATE so that this supports older Android
40+
// versions
41+
SimpleDateFormat dateFormat =
42+
new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
43+
dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
44+
return dateFormat;
45+
}
46+
};
47+
}
48+
3449
public static void throwIfEmptyOrNull(String input, String errorMessage) {
3550
if (input == null || input.isEmpty()) {
3651
throw new IllegalArgumentException(errorMessage);
@@ -84,7 +99,7 @@ public static Date parseUtcISODateNode(JsonNode isoDateStringElement) {
8499
String isoDateString = isoDateStringElement.asText();
85100
Date result = null;
86101
try {
87-
result = UTC_ISO_DATE_FORMAT.parse(isoDateString);
102+
result = UTC_ISO_DATE_FORMAT.get().parse(isoDateString);
88103
} catch (ParseException e) {
89104
// We expect to fail parsing if the date is base 64 encoded
90105
// Thus we'll leave the result null for now and try again with the decoded value
@@ -94,7 +109,7 @@ public static Date parseUtcISODateNode(JsonNode isoDateStringElement) {
94109
// Date may be encoded
95110
String decodedIsoDateString = base64Decode(isoDateString);
96111
try {
97-
result = UTC_ISO_DATE_FORMAT.parse(decodedIsoDateString);
112+
result = UTC_ISO_DATE_FORMAT.get().parse(decodedIsoDateString);
98113
} catch (ParseException e) {
99114
log.warn("Date \"{}\" not in ISO date format", isoDateString);
100115
}
@@ -104,7 +119,7 @@ public static Date parseUtcISODateNode(JsonNode isoDateStringElement) {
104119
}
105120

106121
public static String getISODate(Date date) {
107-
return UTC_ISO_DATE_FORMAT.format(date);
122+
return UTC_ISO_DATE_FORMAT.get().format(date);
108123
}
109124

110125
public static String base64Encode(String input) {
@@ -125,11 +140,4 @@ public static String base64Decode(String input) {
125140
}
126141
return new String(decodedBytes);
127142
}
128-
129-
private static SimpleDateFormat buildUtcIsoDateFormat() {
130-
// Note: we don't use DateTimeFormatter.ISO_DATE so that this supports older Android versions
131-
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US);
132-
dateFormat.setTimeZone(java.util.TimeZone.getTimeZone("UTC"));
133-
return dateFormat;
134-
}
135143
}

src/test/java/cloud/eppo/UtilsTest.java

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
import com.fasterxml.jackson.databind.JsonNode;
88
import com.fasterxml.jackson.databind.ObjectMapper;
99
import java.util.Date;
10+
import java.util.concurrent.CountDownLatch;
1011
import java.util.concurrent.ExecutorService;
1112
import java.util.concurrent.Executors;
1213
import java.util.concurrent.TimeUnit;
1314
import java.util.concurrent.atomic.AtomicBoolean;
15+
import java.util.concurrent.atomic.AtomicInteger;
1416
import org.junit.jupiter.api.Test;
1517

1618
public class UtilsTest {
@@ -90,4 +92,90 @@ public void testParseUtcISODateNode() throws JsonProcessingException {
9092
assertNull(parsedDate);
9193
assertNull(parseUtcISODateNode(null));
9294
}
95+
96+
@Test
97+
public void testDateParsingThreadSafety() throws InterruptedException {
98+
final AtomicBoolean collisionDetected = new AtomicBoolean(false);
99+
final AtomicInteger unexpectedExceptions = new AtomicInteger(0);
100+
final AtomicInteger incorrectParseResults = new AtomicInteger(0);
101+
102+
int numThreads = 20; // Spawn 20 threads
103+
int iterationsPerThread = 100; // Each thread will parse 100 dates
104+
ExecutorService pool = Executors.newFixedThreadPool(numThreads);
105+
106+
CountDownLatch startLatch = new CountDownLatch(1);
107+
CountDownLatch finishLatch = new CountDownLatch(numThreads);
108+
109+
// Expected date: 2024-05-01T16:13:26.651Z -> 1714580006651L
110+
final String testDateString = "\"2024-05-01T16:13:26.651Z\"";
111+
final long expectedTimestamp = 1714580006651L;
112+
113+
try {
114+
for (int i = 0; i < numThreads; i++) {
115+
pool.execute(
116+
() -> {
117+
try {
118+
// Wait for all threads to start simultaneously
119+
startLatch.await();
120+
121+
ObjectMapper mapper = new ObjectMapper();
122+
123+
for (int j = 0; j < iterationsPerThread; j++) {
124+
try {
125+
JsonNode jsonNode = mapper.readTree(testDateString);
126+
Date parsedDate = parseUtcISODateNode(jsonNode);
127+
128+
if (parsedDate == null || parsedDate.getTime() != expectedTimestamp) {
129+
incorrectParseResults.incrementAndGet();
130+
collisionDetected.set(true);
131+
}
132+
133+
// Also test the reverse operation
134+
Date originalDate = new Date(expectedTimestamp);
135+
String formattedDate = getISODate(originalDate);
136+
if (!formattedDate.equals("2024-05-01T16:13:26.651Z")) {
137+
incorrectParseResults.incrementAndGet();
138+
collisionDetected.set(true);
139+
}
140+
141+
} catch (Exception e) {
142+
unexpectedExceptions.incrementAndGet();
143+
}
144+
}
145+
} catch (InterruptedException e) {
146+
Thread.currentThread().interrupt();
147+
} finally {
148+
finishLatch.countDown();
149+
}
150+
});
151+
}
152+
153+
// Start all threads simultaneously to maximize contention
154+
startLatch.countDown();
155+
156+
// Wait for all threads to complete
157+
assertTrue(
158+
finishLatch.await(30, TimeUnit.SECONDS), "Test threads did not complete within timeout");
159+
160+
} finally {
161+
pool.shutdown();
162+
assertTrue(
163+
pool.awaitTermination(5, TimeUnit.SECONDS),
164+
"Thread pool did not shutdown within timeout");
165+
}
166+
167+
// Print diagnostic information
168+
System.out.println("Unexpected exceptions: " + unexpectedExceptions.get());
169+
System.out.println("Incorrect parse results: " + incorrectParseResults.get());
170+
System.out.println("Total operations: " + (numThreads * iterationsPerThread * 2));
171+
172+
String failureMessage =
173+
"SimpleDateFormat thread-safety issue detected! "
174+
+ "Exceptions: "
175+
+ unexpectedExceptions.get()
176+
+ ", Incorrect results: "
177+
+ incorrectParseResults.get();
178+
assertFalse(collisionDetected.get(), failureMessage);
179+
assertEquals(0, unexpectedExceptions.get(), failureMessage);
180+
}
93181
}

0 commit comments

Comments
 (0)