Skip to content

Commit e5431c1

Browse files
Merge branch 'tests-fix-print-pool-threads' into 'dev'
BoxStore: improve thread pool shutdown, error messages #258 See merge request objectbox/objectbox-java!147
2 parents 992af54 + 6c3fe40 commit e5431c1

File tree

6 files changed

+188
-26
lines changed

6 files changed

+188
-26
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ For more insights into what changed in the ObjectBox C++ core, [check the Object
1010
an "Another BoxStore is still open for this directory" exception. Note that calling `close()` *is recommended* before
1111
creating a new instance. [#1201](https://github.com/objectbox/objectbox-java/issues/1201)
1212
- When using `BoxStoreBuilder.buildDefault()`, don't leak Store when setting as default fails.
13+
- To help diagnose, print stacks of all threads in the internal thread pool if shutting it down takes too long when
14+
closing `BoxStore`.
1315

1416
## 4.3.1 - 2025-08-12
1517

objectbox-java/src/main/java/io/objectbox/BoxStore.java

Lines changed: 72 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.io.Closeable;
2222
import java.io.File;
2323
import java.io.IOException;
24+
import java.io.PrintStream;
2425
import java.net.MalformedURLException;
2526
import java.net.URL;
2627
import java.util.ArrayList;
@@ -81,6 +82,8 @@ public class BoxStore implements Closeable {
8182

8283
/** The ObjectBox database version this Java library is known to work with. */
8384
private static final String VERSION = "4.3.1-2025-08-02";
85+
86+
private static final String OBJECTBOX_PACKAGE_NAME = "objectbox";
8487
private static BoxStore defaultStore;
8588

8689
/** Currently used DB dirs with values from {@link #getCanonicalPath(File)}. */
@@ -238,6 +241,7 @@ public static boolean isSyncServerAvailable() {
238241

239242
native long nativePanicModeRemoveAllObjects(long store, int entityId);
240243

244+
private final PrintStream errorOutputStream;
241245
private final File directory;
242246
private final String canonicalPath;
243247
/** Reference to the native store. Should probably get through {@link #getNativeStore()} instead. */
@@ -283,6 +287,7 @@ public static boolean isSyncServerAvailable() {
283287
relinker = builder.relinker;
284288
NativeLibraryLoader.ensureLoaded();
285289

290+
errorOutputStream = builder.errorOutputStream;
286291
directory = builder.directory;
287292
canonicalPath = getCanonicalPath(directory);
288293
verifyNotAlreadyOpen(canonicalPath);
@@ -613,7 +618,7 @@ public Transaction beginTx() {
613618
// Because write TXs are typically not cached, initialCommitCount is not as relevant than for read TXs.
614619
int initialCommitCount = commitCount;
615620
if (debugTxWrite) {
616-
System.out.println("Begin TX with commit count " + initialCommitCount);
621+
getOutput().println("Begin TX with commit count " + initialCommitCount);
617622
}
618623
long nativeTx = nativeBeginTx(getNativeStore());
619624
if (nativeTx == 0) throw new DbException("Could not create native transaction");
@@ -638,7 +643,7 @@ public Transaction beginReadTx() {
638643
// TODO add multithreaded test for this
639644
int initialCommitCount = commitCount;
640645
if (debugTxRead) {
641-
System.out.println("Begin read TX with commit count " + initialCommitCount);
646+
getOutput().println("Begin read TX with commit count " + initialCommitCount);
642647
}
643648
long nativeTx = nativeBeginReadTx(getNativeStore());
644649
if (nativeTx == 0) throw new DbException("Could not create native read transaction");
@@ -693,12 +698,17 @@ public void close() {
693698
// (due to all Java APIs doing closed checks).
694699
closed = true;
695700

701+
// Stop accepting new tasks (async calls, query publishers) on the internal thread pool
702+
internalThreadPool().shutdown();
703+
// Give running tasks some time to finish, print warnings if they do not to help callers fix their code
704+
checkThreadTermination();
705+
696706
List<Transaction> transactionsToClose;
697707
synchronized (transactions) {
698708
// Give open transactions some time to close (BoxStore.unregisterTransaction() calls notify),
699709
// 1000 ms should be long enough for most small operations and short enough to avoid ANRs on Android.
700710
if (hasActiveTransaction()) {
701-
System.out.println("Briefly waiting for active transactions before closing the Store...");
711+
getOutput().println("Briefly waiting for active transactions before closing the Store...");
702712
try {
703713
// It is fine to hold a lock on BoxStore.this as well as BoxStore.unregisterTransaction()
704714
// only synchronizes on "transactions".
@@ -708,7 +718,7 @@ public void close() {
708718
// If interrupted, continue with releasing native resources
709719
}
710720
if (hasActiveTransaction()) {
711-
System.err.println("Transactions are still active:"
721+
getErrorOutput().println("Transactions are still active:"
712722
+ " ensure that all database operations are finished before closing the Store!");
713723
}
714724
}
@@ -726,10 +736,6 @@ public void close() {
726736
if (handleToDelete != 0) { // failed before native handle was created?
727737
nativeDelete(handleToDelete);
728738
}
729-
730-
// When running the full unit test suite, we had 100+ threads before, hope this helps:
731-
threadPool.shutdown();
732-
checkThreadTermination();
733739
}
734740
}
735741
if (!oldClosedState) {
@@ -740,24 +746,52 @@ public void close() {
740746
}
741747
}
742748

743-
/** dump thread stacks if pool does not terminate promptly. */
749+
/**
750+
* Waits briefly for the internal {@link #internalThreadPool()} to terminate. If it does not terminate in time,
751+
* prints stack traces of the pool threads.
752+
*/
744753
private void checkThreadTermination() {
745754
try {
746-
if (!threadPool.awaitTermination(1, TimeUnit.SECONDS)) {
747-
int activeCount = Thread.activeCount();
748-
System.err.println("Thread pool not terminated in time; printing stack traces...");
749-
Thread[] threads = new Thread[activeCount + 2];
755+
if (!internalThreadPool().awaitTermination(1, TimeUnit.SECONDS)) {
756+
getErrorOutput().println("ObjectBox thread pool not terminated in time." +
757+
" Ensure all async calls have completed and subscriptions are cancelled before closing the Store." +
758+
"\nDumping stack traces of threads on the pool and any using ObjectBox APIs:" +
759+
"\n=== BEGIN OF DUMP ===");
760+
// Note: this may not print any pool threads if other threads are started while enumerating
761+
// (and the pool threads do not make it into the threads array).
762+
Thread[] threads = new Thread[Thread.activeCount()];
750763
int count = Thread.enumerate(threads);
751764
for (int i = 0; i < count; i++) {
752-
System.err.println("Thread: " + threads[i].getName());
753-
Thread.dumpStack();
765+
Thread thread = threads[i];
766+
if (shouldDumpThreadStackTrace(thread)) {
767+
getErrorOutput().println("Thread: " + thread.getName());
768+
StackTraceElement[] trace = thread.getStackTrace();
769+
for (StackTraceElement traceElement : trace) {
770+
getErrorOutput().println("\tat " + traceElement);
771+
}
772+
}
754773
}
774+
getErrorOutput().println("=== END OF DUMP ===");
755775
}
756776
} catch (InterruptedException e) {
757-
e.printStackTrace();
777+
e.printStackTrace(getErrorOutput());
758778
}
759779
}
760780

781+
private boolean shouldDumpThreadStackTrace(Thread thread) {
782+
// Dump any threads of the internal thread pool
783+
if (thread.getName().startsWith(ObjectBoxThreadPool.THREAD_NAME_PREFIX)) return true;
784+
785+
// Any other thread might be blocking a thread on the internal pool, so also dump any that appear to use
786+
// ObjectBox APIs.
787+
StackTraceElement[] trace = thread.getStackTrace();
788+
for (StackTraceElement traceElement : trace) {
789+
if (traceElement.getClassName().contains(OBJECTBOX_PACKAGE_NAME)) return true;
790+
}
791+
792+
return false;
793+
}
794+
761795
/**
762796
* Danger zone! This will delete all data (files) of this BoxStore!
763797
* You must call {@link #close()} before and read the docs of that method carefully!
@@ -894,7 +928,7 @@ void txCommitted(Transaction tx, @Nullable int[] entityTypeIdsAffected) {
894928
synchronized (txCommitCountLock) {
895929
commitCount++; // Overflow is OK because we check for equality
896930
if (debugTxWrite) {
897-
System.out.println("TX committed. New commit count: " + commitCount + ", entity types affected: " +
931+
getOutput().println("TX committed. New commit count: " + commitCount + ", entity types affected: " +
898932
(entityTypeIdsAffected != null ? entityTypeIdsAffected.length : 0));
899933
}
900934
}
@@ -1013,10 +1047,10 @@ public <T> T callInReadTxWithRetry(Callable<T> callable, int attempts, int initi
10131047
String diagnose = diagnose();
10141048
String message = attempt + " of " + attempts + " attempts of calling a read TX failed:";
10151049
if (logAndHeal) {
1016-
System.err.println(message);
1050+
getErrorOutput().println(message);
10171051
e.printStackTrace();
1018-
System.err.println(diagnose);
1019-
System.err.flush();
1052+
getErrorOutput().println(diagnose);
1053+
getErrorOutput().flush();
10201054

10211055
System.gc();
10221056
System.runFinalization();
@@ -1121,7 +1155,7 @@ public <R> R callInTxNoException(Callable<R> callable) {
11211155
* See also {@link #runInTx(Runnable)}.
11221156
*/
11231157
public void runInTxAsync(final Runnable runnable, @Nullable final TxCallback<Void> callback) {
1124-
threadPool.submit(() -> {
1158+
internalScheduleThread(() -> {
11251159
try {
11261160
runInTx(runnable);
11271161
if (callback != null) {
@@ -1142,7 +1176,7 @@ public void runInTxAsync(final Runnable runnable, @Nullable final TxCallback<Voi
11421176
* * See also {@link #callInTx(Callable)}.
11431177
*/
11441178
public <R> void callInTxAsync(final Callable<R> callable, @Nullable final TxCallback<R> callback) {
1145-
threadPool.submit(() -> {
1179+
internalScheduleThread(() -> {
11461180
try {
11471181
R result = callInTx(callable);
11481182
if (callback != null) {
@@ -1314,11 +1348,11 @@ public void setDbExceptionListener(@Nullable DbExceptionListener dbExceptionList
13141348

13151349
@Internal
13161350
public Future<?> internalScheduleThread(Runnable runnable) {
1317-
return threadPool.submit(runnable);
1351+
return internalThreadPool().submit(runnable);
13181352
}
13191353

13201354
@Internal
1321-
public ExecutorService internalThreadPool() {
1355+
ExecutorService internalThreadPool() {
13221356
return threadPool;
13231357
}
13241358

@@ -1337,6 +1371,20 @@ public TxCallback<?> internalFailedReadTxAttemptCallback() {
13371371
return failedReadTxAttemptCallback;
13381372
}
13391373

1374+
/**
1375+
* The output stream to print log messages to. Currently {@link System#out}.
1376+
*/
1377+
private PrintStream getOutput() {
1378+
return System.out;
1379+
}
1380+
1381+
/**
1382+
* The error output stream to print log messages to. This is {@link System#err} by default.
1383+
*/
1384+
private PrintStream getErrorOutput() {
1385+
return errorOutputStream;
1386+
}
1387+
13401388
void setDebugFlags(int debugFlags) {
13411389
nativeSetDebugFlags(getNativeStore(), debugFlags);
13421390
}

objectbox-java/src/main/java/io/objectbox/BoxStoreBuilder.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import java.io.FileOutputStream;
2626
import java.io.InputStream;
2727
import java.io.OutputStream;
28+
import java.io.PrintStream;
2829
import java.lang.reflect.Method;
2930
import java.util.ArrayList;
3031
import java.util.Arrays;
@@ -67,6 +68,12 @@ public class BoxStoreBuilder {
6768
/** The default maximum size the DB can grow to, which can be overwritten using {@link #maxSizeInKByte}. */
6869
public static final int DEFAULT_MAX_DB_SIZE_KBYTE = 1024 * 1024;
6970

71+
/**
72+
* The error output stream {@link BoxStore} uses for logging. Defaults to {@link System#err}, but can be customized
73+
* for tests.
74+
*/
75+
PrintStream errorOutputStream = System.err;
76+
7077
final byte[] model;
7178

7279
/** BoxStore uses this (not baseDirectory/name) */
@@ -145,6 +152,15 @@ public BoxStoreBuilder(byte[] model) {
145152
this.model = Arrays.copyOf(model, model.length);
146153
}
147154

155+
/**
156+
* For testing: set a custom error output stream {@link BoxStore} uses for logging. Defaults to {@link System#err}.
157+
*/
158+
@Internal
159+
BoxStoreBuilder setErrorOutput(PrintStream err) {
160+
errorOutputStream = err;
161+
return this;
162+
}
163+
148164
/**
149165
* Name of the database, which will be used as a directory for database files.
150166
* You can also specify a base directory for this one using {@link #baseDirectory(File)}.

objectbox-java/src/main/java/io/objectbox/internal/ObjectBoxThreadPool.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017 ObjectBox Ltd.
2+
* Copyright 2017-2024 ObjectBox Ltd.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -36,6 +36,8 @@
3636
*/
3737
@Internal
3838
public class ObjectBoxThreadPool extends ThreadPoolExecutor {
39+
40+
public static String THREAD_NAME_PREFIX = "ObjectBox-";
3941
private final BoxStore boxStore;
4042

4143
public ObjectBoxThreadPool(BoxStore boxStore) {
@@ -54,7 +56,7 @@ static class ObjectBoxThreadFactory implements ThreadFactory {
5456
private static final AtomicInteger POOL_COUNT = new AtomicInteger();
5557

5658
private final ThreadGroup group;
57-
private final String namePrefix = "ObjectBox-" + POOL_COUNT.incrementAndGet() + "-Thread-";
59+
private final String namePrefix = THREAD_NAME_PREFIX + POOL_COUNT.incrementAndGet() + "-Thread-";
5860
private final AtomicInteger threadCount = new AtomicInteger();
5961

6062
ObjectBoxThreadFactory() {

tests/objectbox-java-test/src/test/java/io/objectbox/AbstractObjectBoxTest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ protected Box<TestEntity> getTestEntityBox() {
162162

163163
@After
164164
public void tearDown() {
165+
// Note: do not collect dangling Cursors and Transactions before store closes (using System.gc()
166+
// or System.runFinalization()). Tests should mirror user code and do that themselves (calling close())
167+
// or rely on the library (through finalizers or BoxStore.close()).
168+
165169
if (store != null) {
166170
try {
167171
store.close();

0 commit comments

Comments
 (0)