Embedded Eclipse Store clustering: one writer and N reader nodes, replicated
over Aeron. Each node is fully consistent locally; the cluster is eventually
consistent. Architecture and the reasons behind every rule below are recorded
in module-info.java (the ADR). This file
covers only building, configuring, and operating a node.
Requires Java 27 exactly and Maven 3.9+. Every JVM running a node needs:
--enable-preview --add-modules jdk.incubator.vector --add-exports java.base/jdk.internal.misc=ALL-UNNAMED
The first two flags enable Java 27's preview Vector API, which Eclipse Store's JVector index uses for SIMD acceleration (configuration guide). Maven applies these flags to its test and integration-test JVMs.
On the module path, target the export at org.eclipse.serializer.base
instead of ALL-UNNAMED. Retiring stale JVector graphs uses the Store's public
VectorIndex.invalidateGraph() (PR #832), so no
--add-opens and no reflection are needed.
mvn test # default gate
mvn verify -Pintegration # embedded Store/Aeron integration
mvn verify -Pcrashmatrix # forked crash matrix
mvn verify -Psoak # writer/reader soak
mvn verify -Ppoolsweep # 10,000-transaction pool ownership sweep (about five minutes)The pool-ownership integration test runs 1,000 transactions by default (it is bound by two
Store fsyncs per transaction). Run the full 10,000-transaction sweep nightly or before a release with mvn verify -Ppoolsweep.
The build tracks Eclipse Store/Serializer 5.0.0-SNAPSHOT. Deploy every node
from the same dated snapshot. This is a pre-release artifact, not published
to Maven Central.
<dependency>
<groupId>peruncs</groupId>
<artifactId>peruncs-cluster</artifactId>
</dependency>try (var node = ClusterStorage.<MyRoot>Foundation().setRootSupplier(MyRoot::new).startNode()) {
ClusterStorageManager<MyRoot> storage = node.storageManager();
// Writer: mutate and persist inside a write section.
int size = storage.graphBoundary().write(() -> {
MyRoot root = storage.root().get();
root.add("value");
storage.store(root);
return root.size();
});
// Reader: traverse only inside a read section; copy out what you need.
int seen = storage.graphBoundary().read(() -> storage.root().get().size());
NodeStatus status = node.status();
}- If a write callback may have changed the graph before failing, call
graphBoundary().invalidate(cause)before leaving the section. ClusterNode.close()andstorageManager().shutdown()are equivalent and idempotent.- Exceptions from
peruncs.cluster.errors:ReaderWriteRejectedException: a write on a reader;StorageLimitReachedException: the Store is full;GraphInvalidatedException: the graph is latched invalid; restart or reseed;
ReseedRequiredException: seed the node again.
| Eclipse Store | PerunCS Cluster |
|---|---|
EmbeddedStorage.start(root, path) |
ClusterStorage.start(root) using NodeConfig.fromEnvironment() |
EmbeddedStorage.Foundation()…start() |
ClusterStorage.Foundation()…start() |
Tune an EmbeddedStorageFoundation |
Pass it to setEmbeddedStorageFoundation; the node still owns the live path from NodeConfig.storage().root() |
Obtain a StorageManager |
Use the returned ClusterStorageManager<T> |
Set a custom configuration with setNodeConfig(NodeConfig.fromMap(values)) or
setNodeConfig(NodeConfig.builder()...). Use startNode() when the application
also needs status and backup operations. The node handle and its storage
manager share one lifecycle; closing either closes the node.
The manager follows Eclipse Store's common API with these cluster constraints:
- Call
graphBoundary().read(...)for graph traversal andgraphBoundary().write(...)for application mutations. setRootrequires aLazyroot.viewRoots()omits the internalperuncs.replicationroot.importDataandimportFilesare unsupported. Replicated roles reject raw persistence-target writes and metadata changes; reader writes raiseReaderWriteRejectedException.shutdown()closes the complete node. SeeClusterStorageManagerJavadoc for the method-level role behavior.
Create the ClusterNode from the container's application-start hook and close
it from the matching disposal hook. Do not rely on a JVM shutdown hook. Give
the application node.storageManager() wherever it expects an Eclipse Store
StorageManager, and use NodeConfig.fromMap(...) to adapt framework settings.
Map node status to container health as follows:
- readiness follows
status.ready(); - liveness fails for replication states
FAILEDandRESEED_REQUIRED; - report
DEGRADEDand a failed last backup without stopping a live node; - include
status.role()in health details. Standalone nodes reportNOT_CONFIGUREDreplication.
Settings come from the process environment by default. Applications can pass
NodeConfig.fromMap(...) to ClusterStorage.Foundation().setNodeConfig(...),
or create a NodeConfig with its builder. This keeps framework-specific
configuration outside the cluster library. All environment keys use the
PERUNCS_ prefix; old unprefixed and MSCNL_* aliases are not read.
PERUNCS_PROD_MODE=true
PERUNCS_REPLICATION_TRANSPORT=aeron
PERUNCS_REPLICATION_ROLE=writer|reader|backup-reader
PERUNCS_AERON_CLUSTER_ID=<stable cluster UUID>
PERUNCS_AERON_NODE_ID=<stable node UUID>
PERUNCS_AERON_STORE_GENERATION=<Store generation UUID>
PERUNCS_STORAGE_LIMIT_GB=<limit> # writer and reader
PERUNCS_STORAGE_LIMIT_CHECKER_INTERVAL_MINUTES=<minutes> # writer and reader
Readers discover the writer's recording through the alias= of the live
channel. Set PERUNCS_AERON_RECORDING_ID only to override discovery. Without
PERUNCS_PROD_MODE=true, a node starts as a standalone Store unless
PERUNCS_REPLICATION_TRANSPORT=aeron is explicitly selected. Production mode
enables stricter channel, filesystem, and Archive checks. To run a development
cluster, set the Aeron transport and role; a standalone Store leaves transport
at none (the default) and has no role. Production standalone status reports
role STANDALONE and replication state NOT_CONFIGURED.
Put every live path on node-local storage: a local disk, or a block volume attached to this host only. Never put them on NFS, SMB, or another shared network file system. The backup volume is the only path that may be shared.
| Setting | Default | Holds | Media |
|---|---|---|---|
PERUNCS_STORAGE_PATH |
storage |
Store and writer lock (<path>/storage) |
local |
PERUNCS_AERON_DIRECTORY |
<PERUNCS_STORAGE_PATH>/aeron |
Aeron MediaDriver files (recreated at start) | local; tmpfs such as /dev/shm recommended; /tmp rejected in production |
PERUNCS_AERON_ARCHIVE_DIRECTORY |
<aeron dir>.archive |
Aeron Archive recording | local, durable |
PERUNCS_BACKUP_PATH |
backups |
finished backup archives | may be a shared network volume |
PERUNCS_BACKUP_WORKSPACE_PATH |
<PERUNCS_STORAGE_PATH>/backup-workspace |
uncompressed export and compression of a backup | local; needs room for the Store plus its archive |
- Production paths must be absolute.
- The driver, Archive, and Store paths must not overlap.
- Keep the Store and Archive of one node together; losing either requires a new writer epoch and reader reseed.
- A writer locks
<PERUNCS_STORAGE_PATH>/storage/writer.lockbefore opening the Store. This prevents a second process using the same Store path; deployment must ensure only one host is configured as writer because locks on separate Store copies cannot coordinate.
Project requirement: PerunCS replication has no node authentication or transport encryption. Replace loopback channel defaults with addresses routable between peers; production mode rejects loopback and wildcard endpoints.
- The development live channel is
control=localhost:40123|control-mode=dynamic|fc=max|term-length=16m|alias=peruncs-<cluster>. - Several clusters on one network: give each cluster its own
PERUNCS_AERON_CLUSTER_ID, its own control, replay, and watermark endpoints, and its own stream ids.
This table is generated by NodeConfig.settingsMarkdown(). unset means no raw
value is supplied; paths and channels with derived defaults are described above.
| Setting | Default |
|---|---|
PERUNCS_PROD_MODE |
false |
PERUNCS_REPLICATION_TRANSPORT |
none |
PERUNCS_REPLICATION_ROLE |
unset |
PERUNCS_STORAGE_PATH |
storage |
PERUNCS_BACKUP_PATH |
backups |
PERUNCS_BACKUP_WORKSPACE_PATH |
unset |
PERUNCS_BACKUP_MAX_ENTRIES |
1048576 |
PERUNCS_BACKUP_PUBLICATION_LOCK_TIMEOUT_MILLIS |
30000 |
PERUNCS_KEPT_BACKUPS_COUNT |
3 |
PERUNCS_STORAGE_LIMIT_CHECKER_INTERVAL_MINUTES |
unset |
PERUNCS_GC_INTERVAL_MINUTES |
unset |
PERUNCS_BACKUP_INTERVAL_MINUTES |
120 |
PERUNCS_STORAGE_LIMIT_GB |
unset |
PERUNCS_DATA_MERGER_TIMEOUT |
10000 |
PERUNCS_DATA_MERGER_APPLY_TIMEOUT |
60000 |
PERUNCS_DATA_MERGER_LIMIT |
67108864 |
PERUNCS_POOL_MAX_RETAINED_BYTES |
33554432 |
PERUNCS_GRAPH_DRAIN_TIMEOUT_MILLIS |
5000 |
PERUNCS_BACKUP_CLOSE_TIMEOUT_MILLIS |
60000 |
PERUNCS_BACKUP_STOP_TIMEOUT_MILLIS |
60000 |
PERUNCS_BACKUP_STOP_POLL_INTERVAL_MILLIS |
100 |
PERUNCS_BACKUP_RETENTION_RETRY_ATTEMPTS |
3 |
PERUNCS_BACKUP_RETENTION_RETRY_DELAY_MILLIS |
100 |
PERUNCS_BACKUP_PUBLICATION_RETRY_ATTEMPTS |
3 |
PERUNCS_MAINTENANCE_FAILURE_THRESHOLD |
3 |
PERUNCS_WRITER_RECOVERY_ATTEMPTS |
3 |
PERUNCS_MAINTENANCE_CLOSE_TIMEOUT_MILLIS |
5000 |
PERUNCS_STORAGE_CHECK_CLOSE_TIMEOUT_MILLIS |
5000 |
PERUNCS_INDEX_REFRESH_TIMEOUT_MILLIS |
600000 |
PERUNCS_INDEX_VALIDATION_MAX_OBJECTS |
65536 |
PERUNCS_AERON_ARCHIVE_DIRECTORY |
unset |
PERUNCS_AERON_DIRECTORY |
unset |
PERUNCS_AERON_RETENTION_INTERVAL_MINUTES |
1 |
PERUNCS_AERON_TERM_LENGTH |
16777216 |
PERUNCS_AERON_MTU_LENGTH |
1408 |
PERUNCS_AERON_CHUNK_SIZE |
131072 |
PERUNCS_AERON_MAX_TRANSACTION_BYTES |
67108864 |
PERUNCS_AERON_OFFER_TIMEOUT_NANOS |
30000000000 |
PERUNCS_AERON_RECORDING_START_TIMEOUT_NANOS |
30000000000 |
PERUNCS_AERON_RECORDED_POSITION_TIMEOUT_NANOS |
30000000000 |
PERUNCS_AERON_ABORT_RECORDED_POSITION_TIMEOUT_NANOS |
5000000000 |
PERUNCS_AERON_RETRY_IDLE_MAX_PARK_NANOS |
1000000 |
PERUNCS_AERON_RETRY_JITTER_BASE_NANOS |
1000 |
PERUNCS_AERON_RETRY_JITTER_CAP_NANOS |
1000000 |
PERUNCS_AERON_RETRY_ARCHIVE_PROBE_DELAY_NANOS |
10000000 |
PERUNCS_AERON_RETRY_CATALOG_PROBE_INITIAL_DELAY_NANOS |
1000000 |
PERUNCS_AERON_RETRY_CATALOG_PROBE_MAX_DELAY_NANOS |
100000000 |
PERUNCS_AERON_RETENTION_OPERATION_TIMEOUT_MILLIS |
60000 |
PERUNCS_AERON_RECORDING_STOP_TIMEOUT_NANOS |
30000000000 |
PERUNCS_AERON_READER_STOP_TIMEOUT_NANOS |
30000000000 |
PERUNCS_AERON_RECONNECT_TIMEOUT_NANOS |
30000000000 |
PERUNCS_AERON_NODE_ID |
unset |
PERUNCS_AERON_STORE_GENERATION |
unset |
PERUNCS_AERON_CLUSTER_ID |
unset |
PERUNCS_AERON_EPOCH |
1 |
PERUNCS_AERON_STREAM_ID |
1001 |
PERUNCS_AERON_RECORDING_ID |
-1 |
PERUNCS_AERON_WATERMARK_STREAM_ID |
unset |
PERUNCS_AERON_FILE_SYNC_LEVEL |
1 |
PERUNCS_AERON_MIN_ARCHIVE_FREE_BYTES |
0 |
PERUNCS_AERON_ARCHIVE_SEGMENT_FILE_LENGTH |
134217728 |
PERUNCS_AERON_ARCHIVE_LOW_STORAGE_SPACE_THRESHOLD |
134217728 |
PERUNCS_AERON_MAX_CONCURRENT_REPLAYS |
20 |
PERUNCS_AERON_DRIVER_TIMEOUT_MILLIS |
10000 |
PERUNCS_AERON_ARCHIVE_CONTROL_TIMEOUT_NANOS |
5000000000 |
PERUNCS_AERON_WATERMARK_CLOSE_TIMEOUT_NANOS |
5000000000 |
PERUNCS_AERON_LIVE_CHANNEL |
unset |
PERUNCS_AERON_REPLAY_CHANNEL |
unset |
PERUNCS_AERON_ARCHIVE_REPLICATION_CHANNEL |
unset |
PERUNCS_AERON_WATERMARK_CHANNEL |
unset |
PERUNCS_AERON_CONTROL_CHANNEL |
unset |
PERUNCS_AERON_CONTROL_RESPONSE_CHANNEL |
unset |
PERUNCS_AERON_RETENTION_READERS |
`` |
PERUNCS_AERON_THREADING_MODE |
unset |
Before a reader (or a backup-reader without an upload) starts for the first time, give it a matching Store, by either:
- restoring a compatible backup from the backup volume (done automatically at startup when one exists), or
- with the writer stopped, copying the writer's Store directory to the reader.
A node reporting RESEED_REQUIRED needs the same procedure.
ClusterNode exposes:
status(): an immutableNodeStatuswith the role,ready,healthy, storage bytes,ReplicationStatus(state, sequences, lag), and backup-reader status for backup and post-publication maintenance failures. Operator actions:- standalone nodes report
ReplicationState.NOT_CONFIGURED; FAILED: stop serving and inspect; a node whose Archive stayed unreachable past the reconnect budget also reportsFAILEDand resumes from the same Store mark after a restart. A writer whose recovery failedPERUNCS_WRITER_RECOVERY_ATTEMPTS(default 3) consecutive times also latchesFAILED; if the log says the Archive recording is not stopped, check that no second writer process still holds it;DEGRADED: may serve, but fix the dependency;RESEED_REQUIRED: stop and reseed (the recording no longer covers the Store mark).
- standalone nodes report
startStorageChecks(): periodic Store checks (writer and reader).createBackup(BackupSlot): backup-reader only; returns aCompletableFuture<BackupInfo>. A concurrent request completes the future exceptionally withBackupBusyException.
These are in-process calls without authentication. Cluster replication deliberately has no node authentication or transport encryption. If you expose these calls through a separate network service, endpoint policy belongs to the application. A typical mapping:
BackupBusyException→ 409;- not ready or unhealthy → 503;
- anything else → 500.
- Backups are written to
PERUNCS_BACKUP_PATHas<timestamp>.zip(scheduled) or<timestamp>.manual.zip. - To bootstrap a cluster from an existing Store, place it as
user-uploaded-storage.zipin the backup volume and start the backup-reader. It installs the upload, publishes a starter backup, and then deletes the upload.
- Enabling retention: set
PERUNCS_AERON_RETENTION_READERS=<reader UUIDs, comma-separated>on the writer only, and set the watermark channel and stream on every node. - Behaviour: the writer checks retention on its maintenance schedule and deletes complete Archive segments only after every listed reader has confirmed the boundary. Without the list or a complete quorum, history is kept. Each reader reports the position its restart would resume at (the prepare start of the transaction in its Store mark), and the writer keeps the Archive segment that holds the oldest such position.
- Capacity procedure:
- Alert when
archiveUsableSpaceBytesapproachesPERUNCS_AERON_MIN_ARCHIVE_FREE_BYTES(below it, writes are rejected). - Take a Store and Archive backup.
- Stop the writer.
- Enlarge or replace the Archive's local storage.
- Restart with the same Store mark and Archive recording.
- Alert when
- Never delete recording segments or edit the Store mark by hand. If the Archive is lost, start a new epoch and reseed every reader.
Test design, invariants, and fault models are documented in the test classes' Javadoc. This section lists commands and knobs only.
mvn verify -Psoak -Dsoak.seconds=30 -Dsoak.restarts=10
for seed in 1 2 3 4 5 6 7 8 9 10; do
mvn failsafe:verify -Psoak -Dsoak.seconds=15 -Dsoak.seed=$seed
done
Prefer many seeds over one long run. Outputs:
target/soak-events.jsonl: the per-run chaos schedule;target/soak.jfr: summarize withjfr summaryorSoakJfrReport.
| Property | Default | Purpose |
|---|---|---|
soak.seed |
1 |
Workload values and chaos rotation offset |
soak.seconds |
30 |
Workload duration before convergence |
soak.readers |
3 |
Reader count (tested with 5–6) |
soak.writer.threads |
3 |
Writer workload threads |
soak.query.threads |
2 |
Query threads per reader |
soak.payloadBytes |
0 |
Padded transaction body size; 0 = compact |
soak.restarts |
10 |
Chaos-work budget; heavy operations count as two |
soak.lagSlots |
100 |
Reader lag allowance in replication slots |
soak.miniCensus |
20 |
Entities per mid-run census |
soak.maxTornReads |
64 |
Benign query retries allowed; 0 = strict |
soak.corrupt |
true |
Store-mark rollback and Archive-tail corruption |
soak.gc |
true |
Forced-GC bursts |
soak.writerRestart |
true |
Writer restarts |
soak.reseed |
true |
Reseed and rejoin |
soak.retention |
true |
Watermark retention and purge |
soak.events |
target/soak-events.jsonl |
Event log path |
soak.jfr.fail |
false |
Fail on calibrated JFR budget warnings |
mvn verify -Pcrashmatrix
mvn verify -Pcrashmatrix \
-Dit.test=ReplicationMarkCrashMatrixIT#namedMarkAndFourMegabytesOfStoreDataRecoverTogetherAfterProcessKill
Each of the 200 trials seeds the production ReplicationMark, kills a child JVM
at a random point inside its 4 MiB Store commit, then verifies the mark and
payload recover together. The delay uses a fixed seed for repeatability.