-
Notifications
You must be signed in to change notification settings - Fork 1
Gotchas and Pitfalls
What this page covers: the audit-surfaced traps in one place — the API shapes that compile but surprise you, and the backend behaviors that differ from what the call site implies. Each entry says what bites, why, and how to avoid it, and deep-links to the page that explains it in full.
📌 Note — none of these are bugs; they're sharp edges of an intentionally small, backend-agnostic API. Knowing them up front saves a debugging session.
// WRONG — compiles, then everything is keyed by the entity, not the UUID
EntityDescriptor.builder(Player.class, UUID.class) …
// RIGHT — keyType first, entity type second
EntityDescriptor.builder(UUID.class, Player.class) …Both arguments are Class, so a flip is a clean compile and a runtime mess. The signature is
builder(Class<K> keyType, Class<V> type). Read it as "key, then value."
→ Defining Entities
SqlConfig is shared by MySQL/MariaDB, PostgreSQL, and H2 — the dialect comes from the Storage
subclass, not the config. The generic create(...) can't disambiguate, so any SqlConfig becomes a
MySQL/MariaDB SqlStorage, even with a jdbc:postgresql:// or jdbc:h2: URL.
Storages.create(new SqlConfig("jdbc:postgresql://…", …)); // ⚠️ MySQL dialect against Postgres!
Storages.createPostgreSQL(new SqlConfig("jdbc:postgresql://…", …)); // ✅ explicit dialect
Storages.createH2(new SqlConfig("jdbc:h2:file:./data", "", "")); // ✅ explicit dialectUse the typed builders when you need PostgreSQL or H2. → Choosing a Backend · PostgreSQL · H2
A versioned descriptor (@OptimisticLock, .versioned(), or .version(...)) is enforced by
MySQL/MariaDB, PostgreSQL, and MongoDB — but H2 deliberately opts out. On H2 a versioned
descriptor silently degrades to a plain upsert: it never throws OptimisticLockException, and creating
the storage never fails because of versioning. LocalFile and InMemory don't enforce it either.
⚠️ Gotcha — don't validate concurrent-write protection against H2 and assume it holds in production. Test optimistic locking against a server-grade backend.
SQL, Mongo, and InMemory store the payload as native JSON (a JSON column, a BSON sub-document, a
parsed in-memory tree), so they require a codec where isJsonCodec() is true. Pairing one of them
with JacksonYamlCodec fails with IllegalArgumentException.
// ⚠️ rejected — YAML is not JSON
Storages.createSQL(cfg).repository(
EntityDescriptor.builder(UUID.class, P.class).codec(new JacksonYamlCodec<>(P.class))…);
// ✅ LocalFile is the ONLY backend that accepts YAML
Storages.createLocalFile(cfg).repository(
EntityDescriptor.builder(UUID.class, P.class).codec(new JacksonYamlCodec<>(P.class))…);Use JacksonJsonCodec everywhere except when you specifically want human-readable .yml files on
LocalFile. → Codecs
A key is persisted by its toString() (the SQL primary key, the Mongo unique index, the LocalFile
filename) and matched by equals/hashCode (InMemory and the manager cache). So a key type must have a
stable, unique toString() of at most 255 characters and value-based equals/hashCode. UUID,
String, Long, Integer, and records qualify; a plain object with the default identity
Object.toString() does not.
An oversized key isn't silently truncated — save/saveAll reject it: the returned future completes
exceptionally with IllegalArgumentException, before the key reaches storage.
→ Entities, Keys & Collections
A collection name must match ^[a-zA-Z][a-zA-Z0-9_]*$ — the safe intersection across every backend (no
quoting or escaping ever needed). A name with a dash, dot, space, or leading digit is rejected at
build(). → Defining Entities
A field referenced by findBy(...) or query(...) must be declared as an index (@Indexed or
IndexHint). An undeclared field throws IllegalArgumentException at execution time on every
backend, LocalFile included — LocalFile validates the declaration before its full scan, so a query
that works there keeps working when you swap in SQL or Mongo.
repo.query(Query.eq("nickname", "Al")).join(); // ⚠️ throws unless "nickname" is an indexQuery.and(...) is the only combinator — conditions are intersected (AND). For OR, run two queries and
union the results client-side. Query.range(...) bounds are inclusive, and null means an open
end. → Indexing & Queries
MongoStorage implements TransactionalStorage, but inTransaction(...) only works against a MongoDB
replica set (4.0+). On a standalone mongod it throws at runtime. The InMemory backend is
transactional too, but with no isolation (atomic apply, no concurrent-reader guarantees). LocalFile
is not transactional at all.
⚠️ Gotcha —instanceof TransactionalStoragebeingtruedoes not mean the deployment supports it. Mongo needs a replica set; gate accordingly.
→ Transactions · MongoDB
In the manager module, a Ref resolves only against the RefRegistry it is bound to. The explicit
way to make an unbound ref is Ref.of(key, type, null): perfectly fine to put on an entity and save (it
serializes as just the key), but calling resolve() returns a failed future and peek() throws
IllegalStateException.
Ref.of(id, Guild.class, null).resolve(); // ⚠️ failed future: "not bound to a RefRegistry"
refRegistry.ref(id, Guild.class).resolve(); // ✅ bound
Ref.of(id, Guild.class, refRegistry).resolve(); // ✅ boundRefs read back through a refRegistry.codec(...) codec are bound automatically. → Caching & References
The manager module dropped the process-wide static registry on purpose. Each context (new RefRegistry()) is its own registry, and managers self-register into the registry you pass them. This is
what lets two subsystems register a manager for the same entity type on different storages
without colliding — but it means you must wire your codecs and managers through your registry.
Resolving a ref whose type has no manager in that registry fails fast ("No RefResolver registered").
→ Caching & References · Caching Managers
When you use a CachingManager, prefer saveAndCache(...) / deleteAndEvict(...). The plain
repository().save(...) / delete(...) hit the backend but leave the cached cell behind — a memoized
Ref keeps serving the old value. → Caching & References
Cached entries are held by strong references. An expired-but-untouched entry under
CachePolicy.ttl(...) is not GC-eligible until it's overwritten or evicted. Bound memory with
CacheOptions.maxSize (LRU) and/or call purgeExpired() periodically — don't expect TTL alone to free
memory. → Cache Policies & Freshness
StorageTransfer.execute() returns a future that does not complete exceptionally for expected
failures (a duplicate key, a non-empty target, a per-entity codec error). Check report.success() and
iterate report.errors() — a successfully-completed future does not mean a successful transfer.
→ Moving Data Between Backends
A dirty-trackable entity's dirty flag (whether backing IDirtyable or annotated with @DirtyFlag) is
in-memory bookkeeping, not state. It must be transient /
@JsonIgnore so it never reaches the backend; if it is serialized, a decoded entity can come back
already marked dirty and trigger a spurious re-save (or pin a fresh cell against reloads). A plain
entity is never affected — but the moment you opt in, audit that the flag is
excluded from the codec.
Your domain mutator should flip the flag — markDirty() from inside the setter is the idiom — but
Jackson must not trip that on decode, or every value you read back starts life dirty. Use field
access (@JsonAutoDetect field visibility, no public setters) so deserialization writes the field
directly without running your dirtying setter.
// ⚠️ a public setBalance(...) that calls markDirty() also fires during Jackson decode → loaded dirty
// ✅ field-access codec writes `balance` directly; only your own setter marks it dirty→ Cache Policies & Freshness · Codecs
A cell that is dirty (isDirty() true) is always served and never reloaded: an elapsed TTL won't
refetch it, invalidate(key) won't drop it, and even a noCache per-ref override won't bypass it. This
is deliberate — local changes win over the freshness policy, so a reload can't lose an unflushed edit.
The flip side: if you genuinely need the backend's copy of a dirty entity, you must flush or
evict it first, knowingly discarding the local change.
A flush clears each cell's dirty flag before persisting. If another thread re-dirties a value while the batch is in flight, it simply gets picked up by the next flush — so a save can happen twice (a redundant, identical re-save), but a change is never lost. Don't build on "this was saved exactly once"; build on "an unflushed change is eventually saved."
→ Concurrency & Threading · Caching Managers
When flushDirty() hits an OptimisticLockException for a cell, the default reaction is to evict
it: the local unsaved value is dropped and the next read reloads the backend's winning copy. A
non-conflict error is treated as transient — the cell is kept and re-marked dirty for the next flush
to retry. So a write-back entity in a versioned collection can silently lose its in-memory edit on a
concurrent-write conflict; if that matters, reconcile explicitly rather than relying on the flush.
→ Optimistic Locking · Cache Policies & Freshness
Cross-Process Cache Sync has two sharp edges. On a poll backend
(MySQL/MariaDB, H2, LocalFile) PollingCacheSync detects an in-place update only when the descriptor
is versioned (@OptimisticLock); a non-versioned descriptor — and H2, which doesn't enforce
lock_version — reports version 0 for every key, so polling there catches deletes only. And the
PostgreSQL push transport (LISTEN/NOTIFY) is fire-and-forget: an instance disconnected during a
NOTIFY misses that event.
⚠️ Gotcha — always keep aCachePolicy.ttl(...)as the safety net under cache sync, and use a versioned descriptor if you need updates (not just deletes) to propagate on a poll backend.
→ Cross-Process Cache Sync · Optimistic Locking
- FAQ / Troubleshooting — these traps as "exception → cause → fix".
- API Cheat Sheet — the full surface at a glance.
- Glossary — terms used above (descriptor, codec, capability, bound ref…).
-
The Async API — how exceptional completion surfaces (
CompletionExceptioncause). - Dependency Versions & Overrides — the H2 1.x↔2.x file-format trap.
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