Skip to content

Concurrency and Threading

Petrus Pradella edited this page Jun 20, 2026 · 3 revisions

Concurrency & 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.


The 30-second version

// 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.


The shared executor: StorageExecutors

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 (named storage-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 Storage in the process. On Java 21+ there's nothing to tune; on older JVMs the pool self-sizes to your core count.


.join() — convenient, but it blocks a thread

.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, calling repo.find(...).join() within the lambda you passed to inTransaction(...), 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, inTransaction scope work), compose futures — return the next CompletableFuture instead 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 a thenCompose, that's the signal to compose instead.


Why no blocking variants

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.


Exceptions across the async boundary

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.


Thread-safety of your own code

  • 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 from installDefault) 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 saveAndCache or single-writer discipline (see Caching & References).

Write-back flush concurrency

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, calls markClean() 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 own markDirty()) 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.


See also

Clone this wiki locally