ZeroZ DB — developer guide¶
For someone building an application against ZeroZ DB. The feasibility and design document explains why it is built this way; this explains how to use it.
Version 0.1.0. See API stability before depending on anything.
1. Getting it¶
From Maven Central:
<dependency>
<groupId>com.zeroz4j</groupId>
<artifactId>zerozdb</artifactId>
<version>0.1.0</version>
</dependency>
Or build from source:
Requires JDK 21+ (virtual threads). Brings in org.eclipse.store:storage-embedded and
org.eclipse.serializer:serializer.
2. The model in one minute¶
Your objects are the database. There is no mapping layer, no query language, and no separate schema: a graph of plain Java objects hangs off a root object, EclipseStore persists it, and ZeroZ DB adds what a database needs on top — transactions, durability, indexes, constraints, concurrency control, and optionally a network server.
Three rules carry most of the practice:
- Reads are just field access. Wrap them in
db.read(...)when you need a consistent view. - Writes go inside a write-block, and you enlist what you changed.
- Enlisting is not cascading. Storing an object does not store objects it points to — that is EclipseStore's rule and ZeroZ DB keeps it. Enlist every level you changed.
public class ShopRoot {
public final Map<String, Product> products = new HashMap<>();
public long nextId = 1;
}
ZeroZDb db = ZeroZDb.open(new ShopRoot(), Path.of("data/shop"));
ShopRoot root = db.root();
db.write(ctx -> {
long id = root.nextId++;
root.products.put("SKU-1", new Product(id, "Laptop stand"));
ctx.store(root); // the counter changed
ctx.store(root.products); // the map changed - storing root does NOT cover it
});
List<Product> all = db.read(() -> List.copyOf(root.products.values()));
db.close();
3. Writing¶
Write-blocks (preferred)¶
db.write(ctx -> { ... }); // returns nothing
long id = db.writeResult(ctx -> { ... ; return newId; }); // returns a value
Everything enlisted in the block lands in one atomic commit, on disk before the call returns. If the block throws, nothing is persisted and the objects you enlisted are restored in memory from before-images. Blocks nest: an inner block joins the outer one and there is still exactly one commit.
Inside the block:
| call | meaning |
|---|---|
ctx.store(obj) |
enlist a changed object |
ctx.edit(obj) |
enlist before mutating, so rollback is exact |
ctx.storeAll(a, b, c) |
convenience |
ctx.onRollback(runnable) |
compensation for effects before-images cannot undo (e.g. removing an element from a collection) |
ctx.storeChecked(obj, baseline) |
enlist with a stale-edit check — §6 |
Prefer ctx.edit(obj) before changing an object when you care about rollback fidelity: a
snapshot taken after the change already contains the change.
Transactions across method calls¶
For frameworks whose API is begin() … commit():
try (WriteTransaction tx = db.beginWrite()) {
prospect.setPhone("555");
tx.context().store(prospect);
tx.commit();
}
Same guarantees as a block. Always use try-with-resources: close() rolls back an unfinished
transaction, so a forgotten commit() cannot leave the store's write lock held. Nested
beginWrite() joins the outer transaction; a nested rollback() poisons the outer commit rather
than letting it persist work that was abandoned.
Durability¶
Durability.SYNC (default) means an acknowledged write survives power loss — ZeroZ DB adds the
fsync EclipseStore does not do itself. Durability.OS_BUFFERED is EclipseStore's native
behaviour: survives process death, not a power cut, roughly 10× faster. Use it for bulk imports
and rebuildable data.
4. Reading¶
db.read takes a shared lock, so the block cannot observe a half-applied write. Many readers run
concurrently. Copy or reduce inside the block — returning a live collection and iterating it
afterwards escapes the guarantee.
Do not start a write inside a read block. A read lock cannot be upgraded; the engine detects this and throws rather than hanging.
5. Indexes and constraints¶
Index<String, Product> byCategory =
db.index("byCategory", Product.class, () -> root.products, p -> p.category);
UniqueIndex<String, Product> bySku =
db.uniqueIndex("bySku", Product.class, () -> root.products, p -> p.sku);
List<Product> office = byCategory.get("office"); // O(1), no scan
Product one = bySku.get("SKU-1");
Registration scans once; after that the index is maintained at every commit — additions,
removals, and key changes. A unique violation throws UniqueConstraintException before
anything is persisted, and the whole write-block rolls back. Index state only changes after a
commit succeeds, so a failed block never leaves an index lying.
6. Concurrent edits¶
Writes are serialised per store, so two write-blocks cannot interleave. The remaining hazard is think time — a user opens a form, someone else saves, the first user saves over it:
long baseline = db.baseline(prospect); // when the edit begins
...
db.write(ctx -> {
ctx.edit(prospect);
prospect.setPhone("555");
ctx.storeChecked(prospect, baseline); // throws StaleObjectException if it moved
});
Opt-in per call site; plain store remains last-write-wins. Objects are version-tracked only
from their first baseline(...), so code that never checks pays nothing.
7. Several stores at once¶
One ZeroZDb per store. To change several atomically:
CrossStoreWrite.run(ctx -> {
ctx.on(registry).edit(registryRoot.tenants);
registryRoot.tenants.put(id, tenant);
ctx.on(tenantStore).edit(tenantRoot.settings);
tenantRoot.settings.put("locale", "de");
}, registry, tenantStore);
Locks are taken in a total order, so concurrent cross-writes cannot deadlock. Every participant is validated before any of them commits.
Limits. All participants must be stores this JVM owns — a store served by another JVM cannot join. And this is not distributed 2PC: process death between two participants' commits leaves the earlier ones committed. Design so that the risky multi-store work happens locally and any shared store is touched once, last, idempotently.
8. Multiple JVMs¶
// Every JVM runs this. First one owns and serves the store; the rest become clients.
ZeroZDbNode node = ZeroZDbNode.open(Path.of("data/shop"), ShopRoot::new);
long id = node.execute(new AddProduct("SKU-1")); // DbCommand, runs on the owner
int n = node.query(new ProductCount()); // DbQuery, runs on the owner
| mode | owns data | serves others | use |
|---|---|---|---|
EMBEDDED |
yes | no socket at all | data only this JVM touches |
AUTO_SERVER (default) |
if free, else client | yes when owner | shared data |
CLIENT_ONLY |
never | no | app JVMs in front of a dedicated server |
execute / query / localReads behave identically in all three, so application code is written
once. localDb() — write-blocks, index registration, CrossStoreWrite — is available wherever
the node owns its data.
Remote work travels as command objects, not lambdas: the executing JVM needs the code, so
DbCommand and DbQuery implementations live in a jar both sides load.
public class AddProduct implements DbCommand<Long> {
public String sku; // public fields, no-arg constructor
public AddProduct() { }
public AddProduct(String sku) { this.sku = sku; }
public Long execute(WriteContext ctx, Object root) {
ShopRoot shop = (ShopRoot) root;
ctx.edit(shop); ctx.edit(shop.products);
long id = shop.nextId++;
shop.products.put(sku, new Product(id, sku));
return id;
}
}
Do not use records for commands, queries or anything else that crosses the wire.
EclipseStore's serializer reaches fields directly, and the JVM refuses that for records unless
you start it with --add-exports java.base/jdk.internal.misc=ALL-UNNAMED. A plain class with
public fields and a no-arg constructor works everywhere. (The failure is a runtime
Could not obtain access to "jdk.internal.misc.Unsafe", not a compile error, so it surfaces the
first time a command is sent rather than when it is written.)
Reads at heap speed on a client:
try (var local = node.localReads()) {
int count = local.read(root -> ((ShopRoot) root).products.size()); // ~130 ns
}
A client's replica refreshes the instant the owner commits, so it trails by about one round trip.
Use node.query(...) when a read must be exactly current.
Standalone server¶
port = 5150
bindAddress = 127.0.0.1
secret = change-me
schemaId = myapp-v3
durability = SYNC
store.shop.dir = /data/shop
store.shop.root = com.example.ShopRoot
console.port = 8090
console.password = ops
Binding to anything other than loopback requires a secret; the server refuses to start
otherwise. Add TLS with Tls.server(keystore, password). Cross-host deployments should also use
LeaseFileArbiter for ownership, since OS file locks are unreliable on network volumes.
9. Schema changes¶
Your classes are the schema. EclipseStore maps old data onto new classes automatically, and ZeroZ DB defaults to strict matching: a field whose name disappeared arrives unset rather than being silently filled from an unrelated field of the same type. Declare renames:
ZeroZDb.open(root, dir, Durability.SYNC,
SchemaEvolution.strict().rename("com.x.Product#sku", "com.x.Product#code"));
Guard rollback compatibility in your build:
@Test
void schemaStaysRollbackCompatible() {
SchemaCompatibility.check(Path.of("src/test/resources/schema-baseline.txt"),
List.of(ShopRoot.class, Product.class));
}
Adding fields is safe and keeps rollback possible. Removing or retyping a field does not — once the new release rewrites a record, the previous release cannot read it.
10. Operations console¶
ConsoleServer console = ConsoleServer.builder()
.store("shop", db)
.model(ShopRoot.class, Product.class)
.schemaBaseline(Path.of("src/test/resources/schema-baseline.txt"))
.queries("shop", new QueryCatalog()
.register("by-sku", "Find a product by SKU", List.of("sku"),
p -> new FindBySku(p.get("sku"))))
.password("ops")
.port(8090)
.start();
Browse the object graph by field name, run the named queries you publish, see the model diffed against the committed baseline. Read-only, loopback-only unless given a password.
API stability¶
0.x — the API may change. What is settled by real use: ZeroZDb open/attach/read/write,
WriteContext, WriteTransaction, Durability, Index/UniqueIndex, CrossStoreWrite.
Newer and more likely to move: ZeroZDbNode, ReplicaView, ConsoleServer, the lease arbiters,
SchemaPolicy/StoreSchema.
Anything not mentioned in this guide should be treated as internal even when it is public.
Known limits¶
- Everything in a store must fit the heap (EclipseStore
Lazyreferences mitigate). Very large datasets are the wrong fit — see the design document's §3. - One writer per store. Throughput ceiling ~2.4k commits/s buffered, ~280/s synced on a dev laptop; stores are independent, so N stores multiply that.
- No store eviction yet. A process that opens thousands of stores lazily and never closes them will hold them all; an LRU registry is designed but unbuilt.
- Replication ships whole snapshots, so refresh cost is O(graph) per change batch. Right for read-heavy stores, wrong for a large graph under constant writes.
attach(manager)uses the caller's storage configuration — durability and schema-evolution settings passed toopen(...)do not apply, because the manager already exists.- Authentication is one shared secret per server. No per-client identity or per-store authorisation yet.
- No metrics or health endpoint beyond the console's overview.
- Records cannot cross the wire without an
--add-exportsJVM flag — see §8.