Skip to content

Commit c29a614

Browse files
Merge branch '1068-close-while-read-tx-active' into 'dev'
BoxStore: before closing, briefly wait on, then try to close active transactions objectbox/objectbox#1068 See merge request objectbox/objectbox-java!140
2 parents 88e3122 + acc26bf commit c29a614

File tree

5 files changed

+173
-22
lines changed

5 files changed

+173
-22
lines changed

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

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ public static boolean isSyncServerAvailable() {
233233
private final File directory;
234234
private final String canonicalPath;
235235
/** Reference to the native store. Should probably get through {@link #getNativeStore()} instead. */
236-
private long handle;
236+
volatile private long handle;
237237
private final Map<Class<?>, String> dbNameByClass = new HashMap<>();
238238
private final Map<Class<?>, Integer> entityTypeIdByClass = new HashMap<>();
239239
private final Map<Class<?>, EntityInfo<?>> propertiesByClass = new HashMap<>();
@@ -636,13 +636,14 @@ public boolean isReadOnly() {
636636
}
637637

638638
/**
639-
* Closes the BoxStore and frees associated resources.
640-
* This method is useful for unit tests;
641-
* most real applications should open a BoxStore once and keep it open until the app dies.
639+
* Closes this BoxStore and releases associated resources.
642640
* <p>
643-
* WARNING:
644-
* This is a somewhat delicate thing to do if you have threads running that may potentially still use the BoxStore.
645-
* This results in undefined behavior, including the possibility of crashing.
641+
* Before calling, <b>all database operations must have finished</b> (there are no more active transactions).
642+
* <p>
643+
* If that is not the case, the method will briefly wait on any active transactions, but then will forcefully close
644+
* them to avoid crashes and print warning messages ("Transactions are still active"). If this occurs,
645+
* analyze your code to make sure all database operations, notably in other threads or data observers,
646+
* are properly finished.
646647
*/
647648
public void close() {
648649
boolean oldClosedState;
@@ -658,19 +659,42 @@ public void close() {
658659
}
659660

660661
// Closeable recommendation: mark as closed before any code that might throw.
662+
// Also, before checking on transactions to avoid any new transactions from getting created
663+
// (due to all Java APIs doing closed checks).
661664
closed = true;
665+
662666
List<Transaction> transactionsToClose;
663667
synchronized (transactions) {
668+
// Give open transactions some time to close (BoxStore.unregisterTransaction() calls notify),
669+
// 1000 ms should be long enough for most small operations and short enough to avoid ANRs on Android.
670+
if (hasActiveTransaction()) {
671+
System.out.println("Briefly waiting for active transactions before closing the Store...");
672+
try {
673+
// It is fine to hold a lock on BoxStore.this as well as BoxStore.unregisterTransaction()
674+
// only synchronizes on "transactions".
675+
//noinspection WaitWhileHoldingTwoLocks
676+
transactions.wait(1000);
677+
} catch (InterruptedException e) {
678+
// If interrupted, continue with releasing native resources
679+
}
680+
if (hasActiveTransaction()) {
681+
System.err.println("Transactions are still active:"
682+
+ " ensure that all database operations are finished before closing the Store!");
683+
}
684+
}
664685
transactionsToClose = new ArrayList<>(this.transactions);
665686
}
687+
// Close all transactions, including recycled (not active) ones stored in Box threadLocalReader.
688+
// It is expected that this prints a warning if a transaction is not owned by the current thread.
666689
for (Transaction t : transactionsToClose) {
667690
t.close();
668691
}
669-
if (handle != 0) { // failed before native handle was created?
670-
nativeDelete(handle);
671-
// The Java API has open checks, but just in case re-set the handle so any native methods will
672-
// not crash due to an invalid pointer.
673-
handle = 0;
692+
693+
long handleToDelete = handle;
694+
// Make isNativeStoreClosed() return true before actually closing to avoid Transaction.close() crash
695+
handle = 0;
696+
if (handleToDelete != 0) { // failed before native handle was created?
697+
nativeDelete(handleToDelete);
674698
}
675699

676700
// When running the full unit test suite, we had 100+ threads before, hope this helps:
@@ -814,9 +838,27 @@ public void removeAllObjects() {
814838
public void unregisterTransaction(Transaction transaction) {
815839
synchronized (transactions) {
816840
transactions.remove(transaction);
841+
// For close(): notify if there are no more open transactions
842+
if (!hasActiveTransaction()) {
843+
transactions.notifyAll();
844+
}
817845
}
818846
}
819847

848+
/**
849+
* Returns if {@link #transactions} has a single transaction that {@link Transaction#isActive() isActive()}.
850+
* <p>
851+
* Callers must synchronize on {@link #transactions}.
852+
*/
853+
private boolean hasActiveTransaction() {
854+
for (Transaction tx : transactions) {
855+
if (tx.isActive()) {
856+
return true;
857+
}
858+
}
859+
return false;
860+
}
861+
820862
void txCommitted(Transaction tx, @Nullable int[] entityTypeIdsAffected) {
821863
// Only one write TX at a time, but there is a chance two writers race after commit: thus synchronize
822864
synchronized (txCommitCountLock) {
@@ -1290,6 +1332,18 @@ public long getNativeStore() {
12901332
return handle;
12911333
}
12921334

1335+
/**
1336+
* For internal use only. This API might change or be removed with a future release.
1337+
* <p>
1338+
* Returns if the native Store was closed.
1339+
* <p>
1340+
* This is {@code true} shortly after {@link #close()} was called and {@link #isClosed()} returns {@code true}.
1341+
*/
1342+
@Internal
1343+
public boolean isNativeStoreClosed() {
1344+
return handle == 0;
1345+
}
1346+
12931347
/**
12941348
* Returns the {@link SyncClient} associated with this store. To create one see {@link io.objectbox.sync.Sync Sync}.
12951349
*/

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017 ObjectBox Ltd. All rights reserved.
2+
* Copyright 2017-2024 ObjectBox Ltd. All rights reserved.
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.
@@ -21,6 +21,12 @@
2121
import io.objectbox.annotation.apihint.Internal;
2222
import io.objectbox.sync.SyncClient;
2323

24+
/**
25+
* This is a workaround to access internal APIs, notably for tests.
26+
* <p>
27+
* To avoid this, future APIs should be exposed via interfaces with an internal implementation that can be used by
28+
* tests.
29+
*/
2430
@Internal
2531
public class InternalAccess {
2632

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017 ObjectBox Ltd. All rights reserved.
2+
* Copyright 2017-2024 ObjectBox Ltd. All rights reserved.
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.
@@ -124,7 +124,7 @@ public synchronized void close() {
124124

125125
// If store is already closed natively, destroying the tx would cause EXCEPTION_ACCESS_VIOLATION
126126
// TODO not destroying is probably only a small leak on rare occasions, but still could be fixed
127-
if (!store.isClosed()) {
127+
if (!store.isNativeStoreClosed()) {
128128
nativeDestroy(transaction);
129129
}
130130
}
@@ -193,8 +193,7 @@ public BoxStore getStore() {
193193
}
194194

195195
public boolean isActive() {
196-
checkOpen();
197-
return nativeIsActive(transaction);
196+
return !closed && nativeIsActive(transaction);
198197
}
199198

200199
public boolean isRecycled() {
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2024 ObjectBox Ltd. All rights reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.objectbox.query;
18+
19+
import io.objectbox.annotation.apihint.Internal;
20+
21+
/**
22+
* This is a workaround to access internal APIs for tests.
23+
* <p>
24+
* To avoid this, future APIs should be exposed via interfaces with an internal implementation that can be used by
25+
* tests.
26+
*/
27+
@Internal
28+
public class InternalQueryAccess {
29+
30+
/**
31+
* For testing only.
32+
*/
33+
public static <T> void nativeFindFirst(Query<T> query, long cursorHandle) {
34+
query.nativeFindFirst(query.handle, cursorHandle);
35+
}
36+
37+
}

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

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017 ObjectBox Ltd. All rights reserved.
2+
* Copyright 2017-2024 ObjectBox Ltd. All rights reserved.
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.
@@ -16,6 +16,10 @@
1616

1717
package io.objectbox;
1818

19+
import org.junit.Ignore;
20+
import org.junit.Test;
21+
import org.junit.function.ThrowingRunnable;
22+
1923
import java.io.IOException;
2024
import java.util.ArrayList;
2125
import java.util.concurrent.Callable;
@@ -27,13 +31,14 @@
2731
import java.util.concurrent.ThreadPoolExecutor;
2832
import java.util.concurrent.TimeUnit;
2933
import java.util.concurrent.atomic.AtomicInteger;
34+
import java.util.concurrent.atomic.AtomicReference;
3035

3136
import io.objectbox.exception.DbException;
3237
import io.objectbox.exception.DbExceptionListener;
3338
import io.objectbox.exception.DbMaxReadersExceededException;
34-
import org.junit.Ignore;
35-
import org.junit.Test;
36-
import org.junit.function.ThrowingRunnable;
39+
import io.objectbox.query.InternalQueryAccess;
40+
import io.objectbox.query.Query;
41+
3742

3843
import static org.junit.Assert.assertArrayEquals;
3944
import static org.junit.Assert.assertEquals;
@@ -44,6 +49,7 @@
4449
import static org.junit.Assert.assertThrows;
4550
import static org.junit.Assert.assertTrue;
4651
import static org.junit.Assert.fail;
52+
import static org.junit.Assume.assumeFalse;
4753

4854
public class TransactionTest extends AbstractObjectBoxTest {
4955

@@ -293,6 +299,7 @@ public void testClose() {
293299
assertFalse(tx.isClosed());
294300
tx.close();
295301
assertTrue(tx.isClosed());
302+
assertFalse(tx.isActive());
296303

297304
// Double close should be fine
298305
tx.close();
@@ -306,7 +313,6 @@ public void testClose() {
306313
assertThrowsTxClosed(tx::renew);
307314
assertThrowsTxClosed(tx::createKeyValueCursor);
308315
assertThrowsTxClosed(() -> tx.createCursor(TestEntity.class));
309-
assertThrowsTxClosed(tx::isActive);
310316
assertThrowsTxClosed(tx::isRecycled);
311317
}
312318

@@ -315,6 +321,55 @@ private void assertThrowsTxClosed(ThrowingRunnable runnable) {
315321
assertEquals("Transaction is closed", ex.getMessage());
316322
}
317323

324+
@Test
325+
public void nativeCallInTx_storeIsClosed_throws() throws InterruptedException {
326+
// Ignore test on Windows, it was observed to crash with EXCEPTION_ACCESS_VIOLATION
327+
assumeFalse(TestUtils.isWindows());
328+
329+
System.out.println("NOTE This test will cause \"Transaction is still active\" and \"Irrecoverable memory error\" error logs!");
330+
331+
CountDownLatch callableIsReady = new CountDownLatch(1);
332+
CountDownLatch storeIsClosed = new CountDownLatch(1);
333+
CountDownLatch callableIsDone = new CountDownLatch(1);
334+
AtomicReference<Exception> callableException = new AtomicReference<>();
335+
336+
// Goal: be just passed closed checks on the Java side, about to call a native query API.
337+
// Then close the Store, then call the native API. The native API call should not crash the VM.
338+
Callable<Void> waitingCallable = () -> {
339+
Box<TestEntity> box = store.boxFor(TestEntity.class);
340+
Query<TestEntity> query = box.query().build();
341+
// Obtain Cursor handle before closing the Store as getActiveTxCursor() has a closed check
342+
long cursorHandle = io.objectbox.InternalAccess.getActiveTxCursorHandle(box);
343+
344+
callableIsReady.countDown();
345+
try {
346+
if (!storeIsClosed.await(5, TimeUnit.SECONDS)) {
347+
throw new IllegalStateException("Store did not close within 5 seconds");
348+
}
349+
// Call native query API within the transaction (opened by callInReadTx below)
350+
InternalQueryAccess.nativeFindFirst(query, cursorHandle);
351+
query.close();
352+
} catch (Exception e) {
353+
callableException.set(e);
354+
}
355+
callableIsDone.countDown();
356+
return null;
357+
};
358+
new Thread(() -> store.callInReadTx(waitingCallable)).start();
359+
360+
callableIsReady.await();
361+
store.close();
362+
storeIsClosed.countDown();
363+
364+
if (!callableIsDone.await(10, TimeUnit.SECONDS)) {
365+
fail("Callable did not finish within 10 seconds");
366+
}
367+
Exception exception = callableException.get();
368+
assertTrue(exception instanceof IllegalStateException);
369+
// Note: the "State" at the end of the message may be different depending on platform, so only assert prefix
370+
assertTrue(exception.getMessage().startsWith("Illegal Store instance detected! This is a severe usage error that must be fixed."));
371+
}
372+
318373
@Test
319374
public void testRunInTxRecursive() {
320375
final Box<TestEntity> box = getTestEntityBox();

0 commit comments

Comments
 (0)