Skip to content

Commit 9901ad3

Browse files
committed
add unit tests for multi-threaded execution
1 parent 7e4c6e5 commit 9901ad3

4 files changed

Lines changed: 117 additions & 25 deletions

File tree

README.md

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ implementation "com.dynatrace.vectorscan4j:vectorscan4j:0.1.0"
4646

4747
### Requirements
4848

49-
- **Java 25+** (uses the [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/25/core/foreign-function-and-memory-api.html))
49+
- **Java 22+** (uses the [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/25/core/foreign-function-and-memory-api.html))
5050
- **Linux** (x86-64 or aarch64) — native vectorscan binaries are bundled in the JAR
5151

5252
## Usage
@@ -138,7 +138,7 @@ vectorscan4j provides utility methods for serializing and deserializing a Databa
138138
// ... save dbBytes to file, or send over network ...
139139

140140

141-
// ... after loading dbBytes from a file ...
141+
// load dbBytes from a file, then deserialize
142142
Database roundTripDb = Database.deserialize(dbBytes);
143143
roundTripDb.close();
144144
}
@@ -147,11 +147,11 @@ vectorscan4j provides utility methods for serializing and deserializing a Databa
147147

148148
## Incorrect Usage Patterns
149149

150-
There is a number of things to watch out for in terms of how to use the wrapper which can lead to memory leaks, undefined behavior, or break the JVM.
150+
There is a small number of niche things to watch out for in terms of how to use the wrapper which can lead to undefined behavior, or break the JVM.
151151

152152
### 1. Not closing Database / Scanner objects
153153

154-
Database- and Scanner objects both hold native memory. Calling close() on those objects will clean the native memory
154+
Database- and Scanner objects both hold native memory, i.e. memory that is not managed by the JVM Runtime. Calling close() on those objects will clean the native memory
155155
that they are holding. This memory will *also* be freed if one doesn't call the close() explicitly. However, in
156156
that case it will only happen when the Database- or Scanner object gets garbage collected (which might be far in
157157
the future).
@@ -165,9 +165,8 @@ the future).
165165
// -> Its native memory gets freed, but not immediately.
166166
}
167167
```
168-
This might still be a problem, as the garbage collector might not get called for a long time (because the bulk of the
169-
data was lying off-heap, so the JVM doesn't see any memory issues).
170-
Thus, the easiest and safest usage pattern to ensure that memory doesn't accumulate is through a try-with-resources-block:
168+
For more optimal memory usage, either manually close Database and/or Scanner objects by calling the close() method,
169+
or initialize them with a try-with-resources-block:
171170
``` java
172171
// This function is safe to call
173172
void createDbSafe() {
@@ -223,8 +222,8 @@ This is due to how the Foreign Function & Memory API handles exceptions thrown d
223222

224223
### 4. Using the same Scanner concurrently in multiple threads
225224

226-
A Scanner object maintains an internal scratch space, which is not safe to use in multiple threads.
227-
Using the same Scanner concurrently leads to undefined behavior.
225+
A Scanner object maintains an internal scratch space, which is not safe to use from multiple threads.
226+
Using the same Scanner from multiple threads will throw a VectorscanException.
228227

229228
## Limitations
230229

src/main/java/com/dynatrace/vectorscan4j/Scanner.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ abstract class Scanner implements AutoCloseable {
3939
protected final MemorySegment scratch;
4040
private final CallHandlerOnMatch callHandler = new CallHandlerOnMatch();
4141
protected MemorySegment funcPtr = VectorscanMatchEventHandler.allocate(callHandler, arena);
42-
;
42+
4343
protected final CleanupState cleanupState;
4444
private final Cleaner.Cleanable cleanable;
4545

@@ -89,8 +89,6 @@ protected void setHandler(OnMatchEventHandler handler) {
8989
}
9090

9191
protected Scanner(Database database) {
92-
CallHandlerOnMatch callHandler = new CallHandlerOnMatch();
93-
9492
this.database = database;
9593

9694
// allocate scratch space

src/test/java/com/dynatrace/vectorscan4j/BlockScannerTests.java

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,7 @@
3030
import java.nio.ByteBuffer;
3131
import java.nio.charset.StandardCharsets;
3232
import java.util.*;
33-
import java.util.concurrent.ExecutorService;
34-
import java.util.concurrent.Executors;
35-
import java.util.concurrent.Future;
33+
import java.util.concurrent.*;
3634
import org.junit.jupiter.api.Test;
3735

3836
public class BlockScannerTests {
@@ -58,8 +56,7 @@ void defaultScan() {
5856
String input = "I am searching for pattern1, has anyone seen pattern1?";
5957
List<Integer> matchedIds = new ArrayList<>();
6058

61-
scanner.scan(input, (id, from, to, _) -> {
62-
IO.println(String.format("Match pattern with id %d, from %d to %d", id, from, to));
59+
scanner.scan(input, (id, _, _, _) -> {
6360
matchedIds.add(id);
6461
return true;
6562
});
@@ -262,12 +259,8 @@ void countLiteralPatternOccurrences() {
262259
return true;
263260
});
264261
}
265-
for (int i = 0; i < exprs.size(); i++) {
266-
IO.println(String.format("%s -> matched %d times.", exprs.get(i).pattern(), countsById[i]));
267-
}
268-
// foo -> matched 3 times.
269-
// bar -> matched 2 times.
270-
// qux -> matched 0 times.
262+
int[] a = {3, 2, 0};
263+
assertArrayEquals(a, countsById);
271264
}
272265

273266
@Test
@@ -443,4 +436,31 @@ void scannerKeepsDatabaseAlive() throws InterruptedException {
443436
assertNull(dbRef.get(), "Database did not get freed.");
444437
assertNull(scannerRef.get(), "Scanner did not get freed.");
445438
}
439+
440+
@Test
441+
void usingScannerFromMultipleThreadsThrowsVectorscanException() throws Exception {
442+
Database db = new Database(expressions, BLOCK_MODE);
443+
BlockScanner scanner = new BlockScanner(db);
444+
445+
ExecutorService service = Executors.newFixedThreadPool(2);
446+
try {
447+
CountDownLatch stop = new CountDownLatch(1);
448+
CountDownLatch scannerInUse = new CountDownLatch(1);
449+
OnMatchEventHandler handler = (_, _, _, _) -> {
450+
try {
451+
scannerInUse.countDown();
452+
stop.await();
453+
} catch (InterruptedException _) {
454+
}
455+
return true;
456+
};
457+
service.submit(() -> scanner.scan("pattern1 ", handler));
458+
scannerInUse.await(); // wait until the scanner has found a match, meaning it is currently in use.
459+
assertThrows(VectorscanException.class, () -> scanner.scan("pattern1 ", doNothing));
460+
stop.countDown();
461+
} finally {
462+
service.shutdown();
463+
assertTrue(service.awaitTermination(1L, TimeUnit.SECONDS));
464+
}
465+
}
446466
}

src/test/java/com/dynatrace/vectorscan4j/DatabaseTests.java

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
import java.util.Arrays;
3131
import java.util.EnumSet;
3232
import java.util.List;
33+
import java.util.concurrent.*;
34+
import java.util.stream.Stream;
3335
import org.junit.jupiter.api.Test;
3436

3537
public class DatabaseTests {
@@ -87,9 +89,7 @@ void modifyExpressions() {
8789
new Expression("pat1", EnumSet.noneOf(Flags.class)),
8890
new Expression("pat2", EnumSet.noneOf(Flags.class)));
8991
try (Database database = new Database(expr, ExecutionMode.BLOCK_MODE)) {
90-
IO.println(database.getExpression(0));
9192
expr.set(0, new Expression("pat3", EnumSet.noneOf(Flags.class)));
92-
IO.println(database.getExpression(0));
9393
}
9494
}
9595

@@ -267,6 +267,8 @@ void expressionsAreDefensivelyCopied() {
267267
List<Expression> mutable = new ArrayList<>(List.of(new Expression("pat1"), new Expression("pat2")));
268268

269269
try (Database db = new Database(mutable, BLOCK_MODE)) {
270+
BlockScanner scannerBefore = new BlockScanner(db);
271+
270272
// Mutate the original list after database creation
271273
mutable.set(0, new Expression("REPLACED"));
272274
mutable.add(new Expression("pat3"));
@@ -275,6 +277,29 @@ void expressionsAreDefensivelyCopied() {
275277
assertEquals(2, db.getNumExpressions());
276278
assertEquals("pat1", db.getExpression(0).pattern());
277279
assertEquals("pat2", db.getExpression(1).pattern());
280+
281+
// Scanners created before and after mutations should find the same patterns
282+
BlockScanner scannerAfter = new BlockScanner(db);
283+
String input = "We have pat1 and pat2 in this string";
284+
285+
List<Integer> beforeMatches = new ArrayList<>();
286+
scannerBefore.scan(input, (id, _, _, _) -> {
287+
beforeMatches.add(id);
288+
return true;
289+
});
290+
291+
List<Integer> afterMatches = new ArrayList<>();
292+
scannerAfter.scan(input, (id, _, _, _) -> {
293+
afterMatches.add(id);
294+
return true;
295+
});
296+
297+
// Both scanners should find pat1 (id=0) and pat2 (id=1) once
298+
assertEquals(List.of(0, 1), beforeMatches);
299+
assertEquals(List.of(0, 1), afterMatches);
300+
301+
scannerBefore.close();
302+
scannerAfter.close();
278303
}
279304
}
280305

@@ -310,4 +335,54 @@ void notClosingStillFreesMemory() throws InterruptedException {
310335
Thread.sleep(100);
311336
assertNull(dbRef.get(), "Database did not get freed.");
312337
}
338+
339+
@Test
340+
void useDatabaseFromMultipleThreads() throws Exception {
341+
List<Expression> exprs = List.of(new Expression("pat1"), new Expression("pat2"));
342+
String input = "pat1 xx pat1 yy pat1";
343+
344+
int nThreads = 256;
345+
Database db = new Database(exprs, BLOCK_MODE);
346+
List<BlockScanner> scanners =
347+
Stream.generate(() -> new BlockScanner(db)).limit(nThreads).toList();
348+
CyclicBarrier allMatched = new CyclicBarrier(nThreads);
349+
ExecutorService pool = Executors.newFixedThreadPool(nThreads);
350+
try {
351+
List<Future<List<Integer>>> futures = new ArrayList<>();
352+
for (int i = 0; i < nThreads; i++) {
353+
int finalI = i;
354+
Future<List<Integer>> f = pool.submit(() -> {
355+
List<Integer> matchedIds = new ArrayList<>();
356+
scanners.get(finalI).scan(input, (id, _, _, _) -> {
357+
try {
358+
allMatched.await(); // all scanners wait for each other to find a match until they continue.
359+
matchedIds.add(id);
360+
} catch (Exception _) {
361+
}
362+
return true;
363+
});
364+
return matchedIds;
365+
});
366+
futures.add(f);
367+
}
368+
369+
List<List<Integer>> allMatches = new ArrayList<>();
370+
for (Future<List<Integer>> f : futures) {
371+
allMatches.add(f.get());
372+
}
373+
374+
// Every scanner scanned the same input, so each should have found the same match IDs.
375+
List<Integer> expected = List.of(0, 0, 0);
376+
for (List<Integer> matches : allMatches) {
377+
assertEquals(expected, matches);
378+
}
379+
} finally {
380+
pool.shutdownNow();
381+
}
382+
383+
db.close();
384+
for (var scanner : scanners) {
385+
scanner.close();
386+
}
387+
}
313388
}

0 commit comments

Comments
 (0)