Skip to content

Commit 217ad2b

Browse files
Merge branch '286-close-cursor-on-write-tx-exception' into dev
2 parents 1690622 + a8619d6 commit 217ad2b

4 files changed

Lines changed: 115 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ Notable changes to the ObjectBox Java library.
44

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

7-
## 5.0.2 - in development
7+
## Next release
8+
9+
- `BoxStore.runInTx` and `callInTx` close a write cursor even if the runnable or callable throws. This would previously
10+
result in cursor not closed warnings when the cursor was closed by the finalizer daemon.
811

912
## 5.0.1 - 2025-09-30
1013

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

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
import java.util.Iterator;
2424
import java.util.List;
2525
import java.util.Map;
26-
import java.util.concurrent.Callable;
2726

2827
import javax.annotation.Nullable;
2928
import javax.annotation.concurrent.ThreadSafe;
@@ -89,6 +88,13 @@ Cursor<T> getReader() {
8988
return cursor;
9089
}
9190

91+
/**
92+
* Returns if for the calling thread this has a Cursor, if any, for the currently active transaction.
93+
*/
94+
boolean hasActiveTxCursorForCurrentThread() {
95+
return activeTxCursor.get() != null;
96+
}
97+
9298
Cursor<T> getActiveTxCursor() {
9399
Transaction activeTx = store.activeTx.get();
94100
if (activeTx != null) {
@@ -166,20 +172,16 @@ public void closeThreadResources() {
166172
}
167173
}
168174

169-
void txCommitted(Transaction tx) {
170-
// Thread local readers will be renewed on next get, so we do not need clean them up
171-
172-
Cursor<T> cursor = activeTxCursor.get();
173-
if (cursor != null) {
174-
activeTxCursor.remove();
175-
cursor.close();
176-
}
177-
}
178-
179175
/**
180-
* Called by {@link BoxStore#callInReadTx(Callable)} - does not throw so caller does not need try/finally.
176+
* If there is one, and it was created using the given {@code tx}, removes and closes the {@link #activeTxCursor}
177+
* for the current thread.
178+
* <p>
179+
* This should be called before the active transaction is closed to clean up native resources.
180+
* <p>
181+
* Note: {@link #threadLocalReader} is either renewed by the next call to {@link #getReader()} or cleaned up by
182+
* {@link #closeThreadResources()}.
181183
*/
182-
void readTxFinished(Transaction tx) {
184+
void closeActiveTxCursorForCurrentThread(Transaction tx) {
183185
Cursor<T> cursor = activeTxCursor.get();
184186
if (cursor != null && cursor.getTx() == tx) {
185187
activeTxCursor.remove();

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

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -926,15 +926,20 @@ void txCommitted(Transaction tx, @Nullable int[] entityTypeIdsAffected) {
926926
}
927927
}
928928

929-
for (Box<?> box : boxes.values()) {
930-
box.txCommitted(tx);
931-
}
932-
933929
if (entityTypeIdsAffected != null) {
934930
objectClassPublisher.publish(entityTypeIdsAffected);
935931
}
936932
}
937933

934+
/**
935+
* For all boxes, calls {@link Box#closeActiveTxCursorForCurrentThread(Transaction)}.
936+
*/
937+
void closeActiveTxCursorsForCurrentThread(Transaction tx) {
938+
for (Box<?> box : boxes.values()) {
939+
box.closeActiveTxCursorForCurrentThread(tx);
940+
}
941+
}
942+
938943
/**
939944
* Returns a Box for the given type. Objects are put into (and get from) their individual Box.
940945
* <p>
@@ -977,6 +982,7 @@ public void runInTx(Runnable runnable) {
977982
tx.commit();
978983
} finally {
979984
activeTx.remove();
985+
closeActiveTxCursorsForCurrentThread(tx);
980986
tx.close();
981987
}
982988
} else {
@@ -1003,13 +1009,9 @@ public void runInReadTx(Runnable runnable) {
10031009
runnable.run();
10041010
} finally {
10051011
activeTx.remove();
1006-
10071012
// TODO That's rather a quick fix, replace with a more general solution
10081013
// (that could maybe be a TX listener with abort callback?)
1009-
for (Box<?> box : boxes.values()) {
1010-
box.readTxFinished(tx);
1011-
}
1012-
1014+
closeActiveTxCursorsForCurrentThread(tx);
10131015
tx.close();
10141016
}
10151017
} else {
@@ -1089,10 +1091,7 @@ public <T> T callInReadTx(Callable<T> callable) {
10891091

10901092
// TODO That's rather a quick fix, replace with a more general solution
10911093
// (that could maybe be a TX listener with abort callback?)
1092-
for (Box<?> box : boxes.values()) {
1093-
box.readTxFinished(tx);
1094-
}
1095-
1094+
closeActiveTxCursorsForCurrentThread(tx);
10961095
tx.close();
10971096
}
10981097
} else {
@@ -1119,6 +1118,7 @@ public <R> R callInTx(Callable<R> callable) throws Exception {
11191118
return result;
11201119
} finally {
11211120
activeTx.remove();
1121+
closeActiveTxCursorsForCurrentThread(tx);
11221122
tx.close();
11231123
}
11241124
} else {

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

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import java.util.concurrent.LinkedBlockingQueue;
3131
import java.util.concurrent.ThreadPoolExecutor;
3232
import java.util.concurrent.TimeUnit;
33+
import java.util.concurrent.atomic.AtomicBoolean;
3334
import java.util.concurrent.atomic.AtomicInteger;
3435
import java.util.concurrent.atomic.AtomicReference;
3536

@@ -422,12 +423,90 @@ public void testCallInReadTx() {
422423
}
423424

424425
@Test
425-
public void testRunInReadTxAndThenPut() {
426+
public void testRunInTx_closesActiveTxCursor() {
426427
final Box<TestEntity> box = getTestEntityBox();
427-
store.runInReadTx(box::count);
428-
// Verify that box does not hang on to the read-only TX by doing a put
428+
AtomicBoolean hasCursorInTx = new AtomicBoolean(false);
429+
store.runInTx(() -> {
430+
box.count(); // Call Box API that creates a reader/activeTxCursor
431+
hasCursorInTx.set(box.hasActiveTxCursorForCurrentThread());
432+
});
433+
// Verify a cursor for the active tx was created
434+
assertTrue(hasCursorInTx.get());
435+
// Check it was released
436+
assertFalse(box.hasActiveTxCursorForCurrentThread());
437+
438+
// Verify the same in case the runnable throws
439+
try {
440+
store.runInTx(() -> {
441+
box.count();
442+
throw new IllegalStateException("Throw in transaction");
443+
});
444+
} catch (IllegalStateException ignored) {}
445+
assertFalse(box.hasActiveTxCursorForCurrentThread());
446+
}
447+
448+
@Test
449+
public void testCallInTx_closesActiveTxCursor() throws Exception {
450+
final Box<TestEntity> box = getTestEntityBox();
451+
Boolean hasCursorInTx = store.callInTx(() -> {
452+
box.count(); // Call Box API that creates a reader/activeTxCursor
453+
return box.hasActiveTxCursorForCurrentThread();
454+
});
455+
// Verify a cursor for the active tx was created
456+
assertTrue(hasCursorInTx);
457+
// Check it was released
458+
assertFalse(box.hasActiveTxCursorForCurrentThread());
459+
460+
// Verify the same in case the callable throws
461+
try {
462+
store.callInTx(() -> {
463+
box.count();
464+
throw new IllegalStateException("Throw in transaction");
465+
});
466+
} catch (IllegalStateException ignored) {}
467+
assertFalse(box.hasActiveTxCursorForCurrentThread());
468+
}
469+
470+
@Test
471+
public void testRunInReadTx_closesActiveTxCursor() {
472+
final Box<TestEntity> box = getTestEntityBox();
473+
store.runInReadTx(box::count); // Call Box API that creates a reader/activeTxCursor
474+
// Verify that box does not hang on to the read-only TX: if it would, count() would re-use the cursor/tx from
475+
// above and not see the put object.
476+
putTestEntityAndExpectCount(1);
477+
assertFalse(box.hasActiveTxCursorForCurrentThread());
478+
479+
// Verify the same in case the runnable throws
480+
assertThrows(IllegalStateException.class, () -> store.runInReadTx(() -> {
481+
box.count();
482+
throw new IllegalStateException("Throw in transaction");
483+
}));
484+
putTestEntityAndExpectCount(2);
485+
assertFalse(box.hasActiveTxCursorForCurrentThread());
486+
}
487+
488+
@Test
489+
public void testCallInReadTx_closesActiveTxCursor() {
490+
final Box<TestEntity> box = getTestEntityBox();
491+
store.callInReadTx(box::count); // Call Box API that creates a reader/activeTxCursor
492+
// Verify that box does not hang on to the read-only TX: if it would, count() would re-use the cursor/tx from
493+
// above and not see the put object.
494+
putTestEntityAndExpectCount(1);
495+
assertFalse(box.hasActiveTxCursorForCurrentThread());
496+
497+
// Verify the same in case the callable throws
498+
assertThrows(IllegalStateException.class, () -> store.callInReadTx(() -> {
499+
box.count();
500+
throw new IllegalStateException("Throw in transaction");
501+
}));
502+
putTestEntityAndExpectCount(2);
503+
assertFalse(box.hasActiveTxCursorForCurrentThread());
504+
}
505+
506+
private void putTestEntityAndExpectCount(int expectedCount) {
507+
Box<TestEntity> box = getTestEntityBox();
429508
box.put(new TestEntity());
430-
assertEquals(1, box.count());
509+
assertEquals(expectedCount, box.count());
431510
}
432511

433512
@Test

0 commit comments

Comments
 (0)