Skip to content

Commit 3690ba9

Browse files
Adding Test for RaceCondition (#88)
* Adding awaitility for testing Concurrency on full encode/decode loop * Add Test for Concurrency test for round trip encode/decode JSON Encode/Decode * Add Fuzztest * Update the lazy loading of ObjectMapper * Adding RaceCondition test * cleaup RaceCondition test * remove warnings form spotbugs
1 parent e0adac7 commit 3690ba9

12 files changed

Lines changed: 535 additions & 21 deletions

build.gradle

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ java {
2727

2828
repositories {
2929
mavenCentral()
30+
mavenLocal()
3031
}
3132

3233
jacoco {
@@ -40,13 +41,15 @@ dependencies {
4041
testImplementation platform('org.junit:junit-bom:6.0.2')
4142
testImplementation 'org.junit.jupiter:junit-jupiter'
4243
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
44+
testImplementation 'org.awaitility:awaitility:4.2.1'
4345
}
4446

4547
test {
4648
useJUnitPlatform()
4749
finalizedBy jacocoTestReport // report is always generated after tests run
4850
}
4951

52+
5053
jacocoTestReport {
5154
dependsOn test
5255
reports {
@@ -96,3 +99,4 @@ tasks.register('specsValidation', Test) {
9699

97100
include '**/ConformanceTest.class'
98101
}
102+

src/main/java/dev/toonformat/jtoon/util/ObjectMapperSingleton.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public final class ObjectMapperSingleton {
1515
/**
1616
* Holds the singleton ObjectMapper.
1717
*/
18-
private static ObjectMapper INSTANCE;
18+
private static volatile ObjectMapper INSTANCE;
1919

2020
private ObjectMapperSingleton() {
2121
throw new UnsupportedOperationException("Utility class cannot be instantiated");
@@ -27,14 +27,20 @@ private ObjectMapperSingleton() {
2727
* @return ObjectMapper
2828
*/
2929
public static ObjectMapper getInstance() {
30-
if (INSTANCE == null) {
31-
INSTANCE = JsonMapper.builder()
32-
.changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.ALWAYS))
33-
.addModule(new AfterburnerModule()) // Speeds up Jackson by 20–40% in most real-world cases
34-
.defaultTimeZone(TimeZone.getTimeZone("UTC")) // set a default timezone for dates
35-
.disable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
36-
.build();
30+
ObjectMapper result = INSTANCE;
31+
if (result == null) {
32+
synchronized (ObjectMapperSingleton.class) {
33+
result = INSTANCE;
34+
if (result == null) {
35+
INSTANCE = result = JsonMapper.builder()
36+
.changeDefaultPropertyInclusion(incl -> incl.withValueInclusion(JsonInclude.Include.ALWAYS))
37+
.addModule(new AfterburnerModule()) // Speeds up Jackson by 20–40% in most real-world cases
38+
.defaultTimeZone(TimeZone.getTimeZone("UTC")) // set a default timezone for dates
39+
.disable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
40+
.build();
41+
}
42+
}
3743
}
38-
return INSTANCE;
44+
return result;
3945
}
4046
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package dev.toonformat.jtoon;
2+
3+
import org.junit.jupiter.api.Test;
4+
5+
import java.util.ArrayList;
6+
import java.util.Collections;
7+
import java.util.LinkedHashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.concurrent.CountDownLatch;
11+
import java.util.concurrent.ExecutorService;
12+
import java.util.concurrent.Executors;
13+
import java.util.concurrent.ThreadLocalRandom;
14+
15+
import static java.util.concurrent.TimeUnit.SECONDS;
16+
import static org.awaitility.Awaitility.await;
17+
import static org.junit.jupiter.api.Assertions.*;
18+
19+
class JToonConcurrencyTest {
20+
21+
@Test
22+
void encodeDecodeStressTest() {
23+
int threads = 8;
24+
int tasksPerThread = 5_000;
25+
26+
final ExecutorService executor = Executors.newFixedThreadPool(threads);
27+
final CountDownLatch latch = new CountDownLatch(threads * tasksPerThread);
28+
final List<Throwable> errors = Collections.synchronizedList(new ArrayList<>());
29+
30+
Runnable task = () -> {
31+
try {
32+
// Given
33+
final Map<String, Object> data = new LinkedHashMap<>();
34+
data.put("id", ThreadLocalRandom.current().nextInt());
35+
data.put("name", "Alice");
36+
data.put("tags", List.of("x", "y", "z"));
37+
38+
// When
39+
final String toon = JToon.encode(data);
40+
41+
// Then
42+
assertNotNull(toon);
43+
final Object decoded = JToon.decode(toon);
44+
assertNotNull(decoded);
45+
46+
} catch (Throwable ex) {
47+
errors.add(ex);
48+
} finally {
49+
latch.countDown();
50+
}
51+
};
52+
53+
for (int i = 0; i < threads * tasksPerThread; i++) {
54+
executor.submit(task);
55+
}
56+
57+
await()
58+
.atMost(10, SECONDS)
59+
.until(() -> latch.getCount() == 0);
60+
61+
executor.shutdown();
62+
63+
assertTrue(errors.isEmpty(), "Errors occurred in threads: " + errors);
64+
}
65+
66+
void encodeDecodeJSONStressTest() {
67+
int threads = 8;
68+
int tasksPerThread = 5_000;
69+
70+
final ExecutorService executor = Executors.newFixedThreadPool(threads);
71+
final CountDownLatch latch = new CountDownLatch(threads * tasksPerThread);
72+
final List<Throwable> errors = Collections.synchronizedList(new ArrayList<>());
73+
74+
Runnable task = () -> {
75+
try {
76+
// Given
77+
String json = "{\"foo\":123, \"bar\":[\"a\",\"b\"]}";
78+
79+
// When
80+
String toon = JToon.encodeJson(json);
81+
82+
// Then
83+
assertNotNull(toon);
84+
String roundTrip = JToon.decodeToJson(toon);
85+
assertNotNull(roundTrip);
86+
assertTrue(roundTrip.contains("\"foo\":123"));
87+
88+
} catch (Throwable ex) {
89+
errors.add(ex);
90+
} finally {
91+
latch.countDown();
92+
}
93+
};
94+
95+
for (int i = 0; i < threads * tasksPerThread; i++) {
96+
executor.submit(task);
97+
}
98+
99+
await()
100+
.atMost(10, SECONDS)
101+
.until(() -> latch.getCount() == 0);
102+
103+
executor.shutdown();
104+
105+
assertTrue(errors.isEmpty(), "Errors occurred in threads: " + errors);
106+
}
107+
108+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package dev.toonformat.jtoon;
2+
3+
import org.junit.jupiter.api.Tag;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.time.Duration;
7+
import java.util.Arrays;
8+
import java.util.SplittableRandom;
9+
10+
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
11+
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
12+
13+
class JToonFuzzTest {
14+
15+
private static final SplittableRandom RANDOM = new SplittableRandom();
16+
17+
@Test
18+
@Tag("fuzz")
19+
void fuzzUnicodeInput() {
20+
final String[] evil = {
21+
"\u0000", // null char
22+
"\uD800", // broken surrogate
23+
"\uFFFF",
24+
"\u2028", // line separator
25+
"💣", // emoji
26+
"漢字"
27+
};
28+
assertDoesNotThrow(() -> Arrays.stream(evil).forEach(s -> {
29+
try {
30+
JToon.decode("{\"x\":\"" + s + "\"}");
31+
} catch (RuntimeException e) {
32+
// acceptable
33+
}
34+
}));
35+
}
36+
37+
38+
@Test
39+
@Tag("fuzz")
40+
void fuzzDoesNotHang() {
41+
for (int i = 0; i < 1_000; i++) {
42+
byte[] bytes = new byte[RANDOM.nextInt(500)];
43+
RANDOM.nextBytes(bytes);
44+
String input = new String(bytes);
45+
46+
assertTimeoutPreemptively(
47+
Duration.ofMillis(100),
48+
() -> {
49+
try {
50+
JToon.decode(input);
51+
} catch (RuntimeException e) {
52+
// expected
53+
}
54+
}
55+
);
56+
}
57+
}
58+
59+
60+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package dev.toonformat.jtoon;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
6+
import java.util.ArrayList;
7+
import java.util.LinkedHashMap;
8+
import java.util.List;
9+
import java.util.Map;
10+
import java.util.concurrent.*;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
14+
class JToonRaceConditionTest {
15+
16+
@Test
17+
@DisplayName("Should be thread-safe when encoding and decoding concurrently")
18+
void concurrentEncodeDecode() throws InterruptedException, ExecutionException {
19+
int threadCount = 20;
20+
int iterationsPerThread = 100;
21+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
22+
23+
Map<String, Object> input = new LinkedHashMap<>();
24+
input.put("name", "JToon");
25+
input.put("version", "1.0.0");
26+
input.put("tags", List.of("java", "json", "toon"));
27+
input.put("active", true);
28+
29+
Map<String, Object> metadata = new LinkedHashMap<>();
30+
metadata.put("author", "dev");
31+
metadata.put("stars", 100);
32+
metadata.put("created", java.time.LocalDateTime.now());
33+
input.put("metadata", metadata);
34+
35+
List<Future<Void>> futures = new ArrayList<>();
36+
37+
for (int i = 0; i < threadCount * iterationsPerThread; i++) {
38+
futures.add(executor.submit(() -> {
39+
String encoded = JToon.encode(input);
40+
Object decoded = JToon.decode(encoded);
41+
42+
// When decoding, LocalDateTime becomes a String
43+
// We use toString check for other fields and manual check for metadata
44+
Map<String, Object> decodedMap = (Map<String, Object>) decoded;
45+
assertEquals(input.get("name"), decodedMap.get("name"));
46+
assertEquals(input.get("version"), decodedMap.get("version"));
47+
assertEquals(input.get("active"), decodedMap.get("active"));
48+
49+
Map<String, Object> decodedMetadata = (Map<String, Object>) decodedMap.get("metadata");
50+
assertEquals("dev", decodedMetadata.get("author"));
51+
assertEquals(100L, ((Number) decodedMetadata.get("stars")).longValue());
52+
53+
return null;
54+
}));
55+
}
56+
57+
for (Future<Void> future : futures) {
58+
future.get();
59+
}
60+
61+
executor.shutdown();
62+
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
63+
executor.shutdownNow();
64+
}
65+
}
66+
67+
@Test
68+
@DisplayName("Should handle different objects concurrently without interference")
69+
void concurrentDifferentObjects() throws InterruptedException, ExecutionException {
70+
int threadCount = 10;
71+
int iterations = 1000;
72+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
73+
74+
List<Future<Void>> futures = new ArrayList<>();
75+
76+
for (int i = 0; i < iterations; i++) {
77+
final int index = i;
78+
futures.add(executor.submit(() -> {
79+
Map<String, Object> obj = Map.of("key", "value" + index);
80+
String encoded = JToon.encode(obj);
81+
Map<String, Object> decoded = (Map<String, Object>) JToon.decode(encoded);
82+
assertEquals("value" + index, decoded.get("key"));
83+
return null;
84+
}));
85+
}
86+
87+
for (Future<Void> future : futures) {
88+
future.get();
89+
}
90+
91+
executor.shutdown();
92+
}
93+
}

src/test/java/dev/toonformat/jtoon/TestPojos.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ public record OrderEmployee(String name, int id, Address address) {
143143
* Class with Jackson Annotations
144144
*/
145145
public static class FullEmployee {
146-
public AnnotatedEmployee employee;
146+
public final AnnotatedEmployee employee;
147147
private final Map<String, String> properties;
148148

149149
public FullEmployee(AnnotatedEmployee employee, Map<String, String> properties) {
@@ -155,6 +155,10 @@ public FullEmployee(AnnotatedEmployee employee, Map<String, String> properties)
155155
public Map<String, String> getProperties() {
156156
return properties;
157157
}
158+
159+
public AnnotatedEmployee employee() {
160+
return employee;
161+
}
158162
}
159163

160164
/**
@@ -169,7 +173,9 @@ public record HotelInfoLlmRerankDTO(String no,
169173
String hotelAddressDistance) {
170174
}
171175

172-
public record UserDTO(Integer id, String firstName, String lastName, java.sql.Date lastLogin) {}
176+
public record UserDTO(Integer id, String firstName, String lastName, java.sql.Date lastLogin) {
177+
178+
}
173179

174180
/**
175181
* Custom Serializer for HotelInfoLlmRerankDTO

0 commit comments

Comments
 (0)