-
Notifications
You must be signed in to change notification settings - Fork 1
Concurrency and Threading
What this page covers: the thread model behind every async call — StorageExecutors (virtual
threads on Java 21+, a bounded daemon pool otherwise), what .join() does and when it's dangerous, why
there are no blocking variants, and the rules for staying out of trouble.
📌 Note — every I/O method in the library returns a
CompletableFuture. This page is the threading companion to The Async API, which covers composition (thenApply/thenCompose) and exception handling. Read that one for how to use the futures; read this one for what runs them.
// Every storage operation is async and runs on the shared executor.
CompletableFuture<Optional<PlayerData>> future = repo.find(id);
// Compose when you can (stays off the calling thread, never blocks):
future.thenApply(opt -> opt.map(PlayerData::getScore).orElse(0))
.thenAccept(score -> render(score));
// Block with .join() only when you must (e.g. app shutdown, a CLI, a test):
PlayerData alice = repo.find(aliceId).join().orElseThrow();There are no blocking variants — no findSync(...). The async API is the only API; .join() is how
you opt into blocking, and you do it consciously.
All async work dispatches to one process-wide executor, StorageExecutors.get(). It picks its
implementation once, at class load:
-
Java 21+ → a virtual-thread-per-task executor (
Executors.newVirtualThreadPerTaskExecutor(), invoked via reflection so the published bytecode stays Java 8 compatible). Each task gets its own virtual thread; blocking on I/O is essentially free, and you can have enormous concurrency without tuning a pool. -
Java 8–20 → a bounded daemon thread pool sized
max(2, availableProcessors()). Threads are daemons (namedstorage-async-N) and time out after 3 seconds idle, so an idle application holds no threads. The queue is unbounded; excess tasks wait in it rather than spawning more threads.
📌 Note — the threads are daemon threads. They will not keep the JVM alive on their own. Always
close()your storage on shutdown to release the connection pool / file handles; the executor itself needs no shutdown.
💡 Tip — you don't configure this and you don't create it. It's shared across every
Storagein the process. On Java 21+ there's nothing to tune; on older JVMs the pool self-sizes to your core count.
.join() waits for the future and returns its value (or throws). It's the right tool for a CLI, a test,
or an orderly shutdown. But it parks the calling thread until the result is ready, and that has a
sharp edge on the pre-Java-21 fallback pool.
⚠️ Gotcha — nested-blocking pool starvation (fallback pool only). Blocking on a storage future from inside a task already running on the executor (for example, callingrepo.find(...).join()within the lambda you passed toinTransaction(...), which itself runs on this executor) holds a pool thread while it waits. If every pool thread blocks this way, the futures they're waiting on can never get a thread to run — a classic pool-starvation deadlock. On Java 21+ virtual threads make this a non-issue (a parked virtual thread doesn't pin a carrier for I/O waits).
The safe rules:
-
Inside async callbacks (
thenCompose,inTransactionscope work), compose futures — return the nextCompletableFutureinstead of calling.join()on it. This never blocks a worker thread.tx.inTransaction(scope -> { Repository<UUID, Account> accounts = scope.repository(ACCOUNTS); return accounts.find(fromId) // return the future — do NOT .join() here .thenCompose(opt -> { Account from = opt.orElseThrow(); from.setBalance(from.getBalance() - 100); return accounts.save(from); // compose the next step }); }).join(); // a single .join() at the top level is fine
-
At the top level — your
main, a command handler, a test, shutdown —.join()is fine. You're on a caller thread (not a pool thread), so blocking it harms nothing.
💡 Tip — a good heuristic: at most one
.join()per logical operation, at the outermost edge. Everything inside composes. If you find yourself wanting to.join()inside athenCompose, that's the signal to compose instead.
A blocking twin for every method (find/findSync, save/saveSync, …) would double the surface,
invite the wrong default, and hide the cost of I/O behind an innocuous-looking call. By making
CompletableFuture the only return type, the library keeps one mental model:
- I/O is async; you compose it.
- When you genuinely need a value now,
.join()is the explicit, visible opt-in to blocking.
This also means the threading story is uniform across every backend and capability — CRUD, queries, transactions, migrations, and transfers all return futures and all run on the same executor.
A failed operation completes the future exceptionally. With .join(), the original exception surfaces
as the cause of a CompletionException:
try {
repo.save(entity).join();
} catch (CompletionException ex) {
Throwable cause = ex.getCause(); // e.g. OptimisticLockException, IllegalArgumentException
// handle cause
}In composition, use exceptionally(...) / handle(...) instead of try/catch. Full treatment in
The Async API; the optimistic-lock case is in Optimistic Locking.
-
Repositories are cached and shared.
storage.repository(sameDescriptor)returns the same object; it's safe to share across threads. -
Log sinks run on the executor. Events fire from worker threads, so a custom
StorageLogSink(and a host sink frominstallDefault) must be thread-safe. See Logging & Diagnostics. -
Your entities are not synchronized by the library. If you hand the same mutable entity instance to
concurrent operations, that's on you. With the manager module's cache, the identity map hands out one
shared instance per key — coordinate writes through
saveAndCacheor single-writer discipline (see Caching & References).
The manager's write-back path (flushDirty() on dirty-trackable entities) is designed to need no extra
lock of its own — it composes with the cell's existing stamp-ordered publication.
-
Clear-before-save, batched.
flushDirty()collects the dirty cells, callsmarkClean()on each before persisting them, then persists the batch in one go. Clearing first is what makes a concurrent re-dirty safe to ignore: if another thread mutates a value (and calls its ownmarkDirty()) while the save is in flight, that value simply re-sets its own flag and is picked up by the next flush. The result is at-least-once, never lossy — a redundant re-save is possible, a dropped change is not. (This is the same guarantee surfaced as a pitfall in Gotchas & Pitfalls.) -
seedIfAbsent(key, value)keeps the first instance. Installing a live value only-if-absent happens under the store lock, so concurrent seeds for the same key converge on one instance (keep-first) — later seeds see the existing cell and return it rather than replacing it. This preserves the identity map under races without the caller holding a lock.
-
The Async API — the
CompletableFuturemodel, composition, and exceptional completion in full. -
Transactions — why scope work must compose (never
.join()) on the fallback pool. - Logging & Diagnostics — events fire on worker threads; sinks must be thread-safe.
- Caching & References — the cache's identity map and shared-instance concurrency notes.
-
Moving Data Between Backends —
StorageTransfer.execute()runs on the same executor. - Dependency Versions & Overrides — the Java 8 runtime floor that makes the reflective virtual-thread probe necessary.
EveryDatabase · Home · made by Petrus Pradella
Getting Started
Core Concepts
Working with Data
Backends
Manager Module
- Caching & References
- Typed References (Ref)
- Caching Managers
- Cache Policies & Freshness
- Cross-Process Cache Sync
- One Entity, Many Databases
Operations
Advanced
Reference
Contributing