Skip to content

Commit cd9a0a0

Browse files
Merge branch '282-always-destroy-write-tx' into 'dev'
Transaction: close write transactions even if store is closing #282 See merge request objectbox/objectbox-java!166
2 parents f5ff1eb + ff66286 commit cd9a0a0

File tree

4 files changed

+107
-5
lines changed

4 files changed

+107
-5
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ Notable changes to the ObjectBox Java library.
55
For more insights into what changed in the ObjectBox C++ core, [check the ObjectBox C changelog](https://github.com/objectbox/objectbox-c/blob/main/CHANGELOG.md).
66

77
## 5.0.1 - in development
8-
8+
9+
- Fixed a race condition with a closing store and still active transactions that kept the store from closing.
10+
For Android this may fix some rare ANR issues.
11+
912
### Sync
1013

1114
- Add `filterVariables` option to `SyncClient` builder.

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,11 @@ public static boolean isSyncServerAvailable() {
244244
private final PrintStream errorOutputStream;
245245
private final File directory;
246246
private final String canonicalPath;
247+
247248
/** Reference to the native store. Should probably get through {@link #getNativeStore()} instead. */
248249
volatile private long handle;
250+
volatile private boolean nativeStoreDestroyed = false;
251+
249252
private final Map<Class<?>, String> dbNameByClass = new HashMap<>();
250253
private final Map<Class<?>, Integer> entityTypeIdByClass = new HashMap<>();
251254
private final Map<Class<?>, EntityInfo<?>> propertiesByClass = new HashMap<>();
@@ -724,6 +727,7 @@ public void close() {
724727
handle = 0;
725728
if (handleToDelete != 0) { // failed before native handle was created?
726729
nativeDelete(handleToDelete);
730+
nativeStoreDestroyed = true;
727731
}
728732
}
729733
}
@@ -1405,15 +1409,31 @@ public long getNativeStore() {
14051409
/**
14061410
* For internal use only. This API might change or be removed with a future release.
14071411
* <p>
1408-
* Returns if the native Store was closed.
1412+
* Returns {@code true} once the native Store is about to be destroyed.
14091413
* <p>
14101414
* This is {@code true} shortly after {@link #close()} was called and {@link #isClosed()} returns {@code true}.
1415+
*
1416+
* @see #isNativeStoreDestroyed()
14111417
*/
14121418
@Internal
14131419
public boolean isNativeStoreClosed() {
14141420
return handle == 0;
14151421
}
14161422

1423+
/**
1424+
* For internal use only. This API might change or be removed with a future release.
1425+
* <p>
1426+
* Returns {@code true} once the native Store was destroyed.
1427+
* <p>
1428+
* This is {@code true} shortly after {@link #isNativeStoreClosed()} returns {@code true}.
1429+
*
1430+
* @see #isNativeStoreClosed()
1431+
*/
1432+
@Internal
1433+
public boolean isNativeStoreDestroyed() {
1434+
return nativeStoreDestroyed;
1435+
}
1436+
14171437
/**
14181438
* Returns the {@link SyncClient} associated with this store. To create one see {@link io.objectbox.sync.Sync Sync}.
14191439
*/

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

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,9 @@ public synchronized void close() {
101101
closed = true;
102102
store.unregisterTransaction(this);
103103

104-
if (!nativeIsOwnerThread(transaction)) {
104+
boolean isOwnerThread = nativeIsOwnerThread(transaction);
105+
if (!isOwnerThread) {
106+
// Note: don't use isActive(), it returns false here because closed == true already
105107
boolean isActive = nativeIsActive(transaction);
106108
boolean isRecycled = nativeIsRecycled(transaction);
107109
if (isActive || isRecycled) {
@@ -126,10 +128,49 @@ public synchronized void close() {
126128
// TODO not destroying is probably only a small leak on rare occasions, but still could be fixed
127129
if (!store.isNativeStoreClosed()) {
128130
nativeDestroy(transaction);
131+
} else {
132+
// Note: don't use isActive(), it returns false here because closed == true already
133+
boolean isActive = nativeIsActive(transaction);
134+
if (readOnly) {
135+
// Minor leak if TX is active, but still log so the ObjectBox team can check that it only happens
136+
// occasionally.
137+
// Note this cannot assume the store isn't destroyed, yet. The native and Java stores may at best
138+
// briefly wait for read transactions.
139+
System.out.printf(
140+
"Info: closing read transaction after store was closed (isActive=%s, isOwnerThread=%s), this should be avoided.%n",
141+
isActive, isOwnerThread);
142+
System.out.flush();
143+
144+
// Note: get fresh active state
145+
if (!nativeIsActive(transaction)) {
146+
nativeDestroy(transaction);
147+
}
148+
} else {
149+
// write transaction
150+
System.out.printf(
151+
"WARN: closing write transaction after store was closed (isActive=%s, isOwnerThread=%s), this must be avoided.%n",
152+
isActive, isOwnerThread);
153+
System.out.flush();
154+
155+
// Note: get fresh active state
156+
if (nativeIsActive(transaction) && store.isNativeStoreDestroyed()) {
157+
// This is an internal validation: if this is an active write-TX,
158+
// the (native) store will always wait for it, so it must not be destroyed yet.
159+
// If this ever happens, the above assumption is wrong, and throwing likely prevents a SIGSEGV.
160+
throw new IllegalStateException(
161+
"Internal error: cannot close active write transaction for an already destroyed store");
162+
}
163+
// Note: inactive transactions are always safe to destroy, regardless of store state and thread.
164+
// Note: the current native impl panics if the transaction is active AND created in another thread.
165+
nativeDestroy(transaction);
166+
}
129167
}
130168
}
131169
}
132170

171+
/**
172+
* For a write transaction commits the changes. For a read transaction throws.
173+
*/
133174
public void commit() {
134175
checkOpen();
135176
int[] entityTypeIdsAffected = nativeCommit(transaction);
@@ -141,6 +182,9 @@ public void commitAndClose() {
141182
close();
142183
}
143184

185+
/**
186+
* For a read or write transaction, aborts it.
187+
*/
144188
public void abort() {
145189
checkOpen();
146190
nativeAbort(transaction);
@@ -192,6 +236,12 @@ public BoxStore getStore() {
192236
return store;
193237
}
194238

239+
/**
240+
* A transaction is active after it was created until {@link #close()}, {@link #abort()}, or, for write
241+
* transactions only, {@link #commit()} is called.
242+
*
243+
* @return If this transaction is active.
244+
*/
195245
public boolean isActive() {
196246
return !closed && nativeIsActive(transaction);
197247
}

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616

1717
package io.objectbox;
1818

19-
import org.junit.Ignore;
2019
import org.junit.Test;
2120

2221
import java.util.concurrent.CountDownLatch;
22+
import java.util.concurrent.ThreadLocalRandom;
2323
import java.util.concurrent.TimeUnit;
2424

2525
import io.objectbox.annotation.IndexType;
@@ -228,25 +228,54 @@ public void testClose() {
228228
}
229229
}
230230

231-
@Ignore("Temporarily ignore until objectbox-java#282 is resolved")
231+
/**
232+
* Begin the first write-TX and ensure the second one blocks until the first one is closed.
233+
* A secondary test goal is to check races of a closing TX and a closing store.
234+
*/
232235
@Test
233236
public void testWriteTxBlocksOtherWriteTx() throws InterruptedException {
237+
// To change the likelihood of the TX vs store closing race, close the store using one of 3 different variants.
238+
// Assign and print the randomly chosen variant beforehand so it does not mess with thread timings later.
239+
// Warning: test variant 2 only manually, it will close the write-TX from a non-owner thread (in BoxStore.close)
240+
// where the native database is expected to panic.
241+
int closeStoreVariant = ThreadLocalRandom.current().nextInt(2 /* 3 - test variant 2 manually, see above */);
242+
System.out.println("Closing store variant: " + closeStoreVariant);
243+
234244
long time = System.currentTimeMillis();
235245
Transaction tx = store.beginTx();
236246
long duration = System.currentTimeMillis() - time; // Usually 0 on desktop
237247
final CountDownLatch latchBeforeBeginTx = new CountDownLatch(1);
238248
final CountDownLatch latchAfterBeginTx = new CountDownLatch(1);
249+
final CountDownLatch latchCloseStoreInMainThread = closeStoreVariant != 0 ? new CountDownLatch(1) : null;
239250
new Thread(() -> {
240251
latchBeforeBeginTx.countDown();
241252
Transaction tx2 = store.beginTx();
242253
latchAfterBeginTx.countDown();
254+
255+
if (closeStoreVariant != 0) {
256+
try {
257+
assertTrue(latchCloseStoreInMainThread.await(5, TimeUnit.SECONDS));
258+
} catch (InterruptedException e) {
259+
throw new RuntimeException("Interrupted", e);
260+
}
261+
}
243262
tx2.close();
244263
}).start();
245264
assertTrue(latchBeforeBeginTx.await(1, TimeUnit.SECONDS));
246265
long waitTime = 100 + duration * 10;
247266
assertFalse(latchAfterBeginTx.await(waitTime, TimeUnit.MILLISECONDS));
248267
tx.close();
249268
assertTrue(latchAfterBeginTx.await(waitTime * 2, TimeUnit.MILLISECONDS));
269+
270+
// closeStoreVariant == 0: not latch waiting, close store when tearing down test
271+
if (closeStoreVariant == 1) {
272+
// This variant tries to close the store and the TX at the same time.
273+
latchCloseStoreInMainThread.countDown();
274+
store.close(); // Maybe this runs a tiny bit before the close() in teardown(?)
275+
} else if (closeStoreVariant == 2) {
276+
store.close(); // Enforces closing the store before the TX.
277+
latchCloseStoreInMainThread.countDown();
278+
}
250279
}
251280

252281
@Test

0 commit comments

Comments
 (0)