A distributed shared-object space implementation built with Spring Boot 3.5.14, JDK 25, ConcurrentHashMap primary storage, PostgreSQL/JPA secondary persistence, and logback logging. Inspired by the JavaSpaces specification, this service allows client peers to share objects through a tuple-space model with template matching, lease-based expiry, cluster join/unjoin support, and configurable retry with exponential backoff for cluster joins.
┌──────────────────────────────────────────────────────────────────┐
│ Space Instance A │
│ ┌──────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
│ │ REST API │ │ SpaceEngine │ │ ConcurrentHashMap │ │
│ │ (give, │──│ (template │──│ primary runtime │ │
│ │ take, │ │ matching) │ │ storage │ │
│ │ read, │ └──────┬───────┘ └──────────┬─────────────┘ │
│ │ remove) │ │ │ │
│ └──────────────┘ ▼ ▼ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ ClusterService │ │ PostgreSQL / JPA │ │
│ │ ConcurrentHashMap│──│ secondary durable │ │
│ │ membership │ │ persistence │ │
│ └────────┬─────────┘ │ space_entries table │ │
│ │ │ space_instances table │ │
│ │ └────────────────────────┘ │
└─────────────────────────┼────────────────────────────────────────┘
│ HTTP
┌─────────────────────────┼────────────────────────────────────────┐
│ Space Instance B │
│ (same structure, syncs via snapshots) │
└──────────────────────────────────────────────────────────────────┘
| Component | Description |
|---|---|
| SpaceEngine | Core engine implementing give, take, read, remove against a primary ConcurrentHashMap, with PostgreSQL/JPA hydration/persistence and read-write locks for compound operations |
| SpaceTemplate | Pattern-matching engine: null type = wildcard, null field value = field-exists wildcard |
| ClusterService | Manages cluster membership in a primary ConcurrentHashMap: join, unjoin, heartbeat, dead-peer detection, anti-entropy sync, with configurable retry with exponential backoff for seed peer connections |
| SpaceEntry | JPA entity stored in memory and mirrored to the space_entries PostgreSQL table — survives restarts |
| SpaceInstance | JPA entity stored in memory and mirrored to the space_instances PostgreSQL table — survives restarts |
| SpaceEntryRepository | Spring Data JPA repository for SpaceEntry with custom queries by type, origin, and lease expiry |
| SpaceInstanceRepository | Spring Data JPA repository for SpaceInstance with custom queries by status and instance ID |
| SpaceClient | Lightweight Java HTTP client SDK for peer applications |
| LeaseSweeper | Scheduled task that purges expired entries from memory and PostgreSQL |
| PeerClient | HTTP client for inter-peer communication with retry-aware snapshot requests |
The service uses a two-tier storage design:
-
Primary runtime store:
ConcurrentHashMap<String, SpaceEntry>for space entries andConcurrentHashMap<String, SpaceInstance>for cluster membership. All read, take, template matching, count, snapshot, and status operations use these in-memory maps for maximum throughput. -
Secondary durable store: PostgreSQL via Spring Data JPA persists entries in the
space_entriestable and instances in thespace_instancestable. On startup, JPA data is used to hydrate the primary maps. Every give/save/update/delete operation is mirrored to PostgreSQL within a@Transactionalboundary.
This keeps normal space operations fast (in-memory) while preserving durable, queryable persistence across restarts. The write pattern is: JPA first, then map for inserts, and map first, then JPA for deletes (with rollback to re-insert into the map if JPA deletion fails).
On application startup, the @PostConstruct hydration phase loads all entries and instances from PostgreSQL into their respective ConcurrentHashMap instances. Any entries whose leases have already expired are deleted from the database during hydration rather than loaded into memory. This ensures the runtime map starts with only valid, active data.
Hibernate's ddl-auto: update mode automatically creates and evolves the schema. The following tables and indexes are managed:
| Column | Type | Description |
|---|---|---|
id |
VARCHAR(255) |
Primary key (UUID) |
typename |
VARCHAR(256) |
Logical type name for template matching |
payload |
TEXT |
JSON-serialized original object |
indexedfields |
TEXT |
JSON map of fields for template matching |
origininstanceid |
VARCHAR(128) |
ID of the space instance that wrote this entry |
leaseexpiry |
TIMESTAMP |
Lease expiration time (null = infinite) |
createdat |
TIMESTAMP |
Creation timestamp |
updatedat |
TIMESTAMP |
Last update timestamp |
Indexes: idx_entry_type on typename, idx_entry_origin on origininstanceid, idx_entry_lease on leaseexpiry
| Column | Type | Description |
|---|---|---|
id |
VARCHAR(255) |
Primary key (same as instanceId) |
instanceid |
VARCHAR(255) |
Unique instance identifier |
baseurl |
VARCHAR(255) |
HTTP base URL for peer communication |
status |
VARCHAR(255) |
Instance status: ACTIVE, SUSPECTED, LEAVING, or DEAD |
joinedat |
TIMESTAMP |
Join timestamp |
lastheartbeat |
TIMESTAMP |
Last received heartbeat |
entrycount |
BIGINT |
Number of entries from this instance |
failedheartbeatcount |
INTEGER |
Consecutive heartbeat failures (0 on success) |
Indexes: idx_instance_status on status
Writes an object into the space. The payload is JSON-serialized; indexed fields enable template matching.
curl -X POST http://localhost:8080/api/space/give \
-H "Content-Type: application/json" \
-d '{
"typeName": "Task",
"payload": {"name": "process-orders", "assignee": "alice"},
"indexedFields": {"status": "pending", "priority": 1},
"leaseMs": 0
}'Atomically reads and removes the oldest matching entry. Only one client can take a given entry.
curl -X POST http://localhost:8080/api/space/take \
-H "Content-Type: application/json" \
-d '{"typeName": "Task", "fields": {"status": "pending"}}'Reads the oldest matching entry without removing it. Safe for multiple concurrent readers.
curl -X POST http://localhost:8080/api/space/read \
-H "Content-Type: application/json" \
-d '{"typeName": "Task", "fields": {"status": "pending"}}'Removes a specific entry by its unique ID.
curl -X DELETE http://localhost:8080/api/space/{entryId}| Template Field | Entry Field | Match? |
|---|---|---|
typeName = null |
any type | ✅ wildcard |
typeName = "Task" |
"Task" |
✅ exact |
typeName = "Task" |
"Order" |
❌ |
fields.status = "pending" |
status = "pending" |
✅ exact |
fields.status = null |
status = anything |
✅ field-exists wildcard |
fields.status = "pending" |
field absent | ❌ |
fields.priority = 1 |
priority = 1 |
✅ numeric match |
A new space instance joins the cluster:
curl -X POST http://localhost:8080/api/cluster/join \
-H "Content-Type: application/json" \
-d '{"instanceId": "space-node-2", "baseUrl": "http://host2:8080"}'Join Protocol:
- New instance sends JOIN with its ID and URL
- Receiving instance registers it as ACTIVE
- New instance can request a
/api/cluster/snapshotto sync existing entries - Other peers are notified via gossip
A peer transitions through the following statuses during its lifetime in the cluster:
JOINING → ACTIVE ⇄ SUSPECTED → DEAD → (recovery probe) → ACTIVE
↑_______________________________________|
| Status | Description |
|---|---|
| JOINING | Instance is in the process of joining the cluster |
| ACTIVE | Fully participating — heartbeats are succeeding |
| SUSPECTED | One or more heartbeats have failed — the peer may be temporarily unreachable but is not yet confirmed dead. Heartbeats continue to be sent to suspected peers. |
| DEAD | Confirmed dead after N consecutive heartbeat failures. Removed from the active peer cache. Eligible for periodic recovery probes. |
| LEAVING | Gracefully leaving the cluster via explicit unjoin |
Heartbeat failure progression:
- When a heartbeat to an ACTIVE peer fails, the peer transitions to SUSPECTED and the failure count is incremented
- Subsequent failed heartbeats increment the failure count — the peer remains SUSPECTED
- Once the failure count reaches the configured threshold (
heartbeat-failures-before-dead), the peer transitions to DEAD - At any point, a successful heartbeat resets the failure count and returns the peer to ACTIVE
Recovery from DEAD: DEAD peers are not permanently abandoned. A periodic recovery probe checks whether each DEAD peer has come back online. If a probe succeeds, the peer is transitioned back to ACTIVE and re-added to the active peer cache, resuming normal heartbeat flow.
When connecting to seed peers at startup, the service implements configurable retry with exponential backoff. If a seed peer is temporarily unavailable (e.g., network partition or the peer is still starting), the join will be retried up to a configurable maximum number of attempts with increasing delays between each attempt.
Retry behavior:
- On each failed attempt, a warning is logged with the attempt number and the total max attempts
- The backoff delay starts at the configured base value and is multiplied by the configured multiplier after each attempt
- Example: with defaults (1000ms base, 2.0 multiplier, 3 max attempts), the delays are 1000ms → 2000ms → 4000ms before giving up
- A final error is logged if all retry attempts are exhausted for a given peer
- Even if all join retries fail, the peer URL remains discoverable via the recovery probe mechanism once it comes online
When all instances share the same PostgreSQL database, peer discovery happens automatically at startup — no explicit seed peer configuration is required. This is the simplest way to deploy a cluster: point every instance at the same database and they will find each other.
How it works:
- On startup,
ClusterService.hydrateFromJpa()loads all known instance records from thespace_instancestable into the in-memoryConcurrentHashMap - After registering itself, the instance sends a JOIN request to every ACTIVE or SUSPECTED peer discovered from the database
- The instance requests a snapshot from the first responsive peer to synchronize any entries that may not yet be in the shared database
- If no peers are found in the database, the instance starts as the first node in the cluster
Important: For shared-database discovery to work, each instance must register with its externally-reachable base URL. Set spaces.base-url (or SPACE_BASE_URL) to a URL that other instances can use to reach this instance (e.g., http://myhost:8080). Without this, instances default to http://localhost:<port> and peers on other machines will not be able to communicate with them.
The startup flow for a new instance joining a shared-database cluster:
@PostConstruct— hydrate instance records from PostgreSQLonStartup()— resolve base URL, register self as ACTIVEjoinPeersFromDatabase()— send JOIN to peers found in the hydrated dataconnectToSeedPeers()— additionally connect to any explicitly configured seed peers
An instance gracefully leaves the cluster:
curl -X POST "http://localhost:8080/api/cluster/unjoin/space-node-2?removeEntries=false"Unjoin Protocol:
- Instance sends UNJOIN with its ID
- Status transitions to LEAVING → DEAD
- If
removeEntries=true, all entries from that origin are deleted - Peers are notified via gossip
Get a full snapshot for synchronizing a new or rejoining instance:
curl http://localhost:8080/api/cluster/snapshotPeers send periodic heartbeats. Dead peers are detected after the configured timeout and marked DEAD.
// Create a client
SpaceClient client = new SpaceClient("http://localhost:8080");
// Give (write)
SpaceEntry entry = client.give(
"Task",
Map.of("name", "my-task", "priority", 1),
Map.of("status", "pending", "priority", 1),
0 // infinite lease
);
// Take (read + remove)
SpaceEntry taken = client.take("Task", Map.of("status", "pending"));
// Read (without remove)
SpaceEntry found = client.read("Task", Map.of("priority", 1));
// Remove by ID
boolean removed = client.remove(entry.getId());
// Cluster operations
client.joinCluster("my-instance", "http://myhost:8080");
client.unjoinCluster("my-instance", false);
ClusterStatus status = client.getClusterStatus();| Property | Default | Description |
|---|---|---|
server.port |
8080 |
HTTP port |
spaces.instance-id |
auto-generated | Unique instance identifier |
spaces.base-url |
auto-detected | Externally-reachable base URL for this instance (e.g. http://myhost:8080). If not set, auto-detected from the machine's hostname and server port. Critical in clustered deployments where peers must reach each other by a real hostname or IP — without this, all instances register as http://localhost:<port> and cannot communicate. |
spaces.cluster.enabled |
true |
Enable clustering |
spaces.cluster.heartbeat-interval |
5000 |
Heartbeat interval (ms) |
spaces.cluster.peer-timeout |
15000 |
Time before peer declared dead (ms) |
spaces.cluster.sync-interval |
10000 |
Sync check interval (ms) |
spaces.cluster.peers |
[] |
Seed peer URLs for initial discovery |
spaces.cluster.join-retry-max-attempts |
3 |
Maximum retry attempts per seed peer on join |
spaces.cluster.join-retry-backoff-ms |
1000 |
Initial backoff delay in ms before first retry |
spaces.cluster.join-retry-backoff-multiplier |
2.0 |
Multiplier applied to backoff after each attempt |
spaces.cluster.heartbeat-failures-before-dead |
3 |
Consecutive heartbeat failures before SUSPECTED → DEAD |
spaces.cluster.dead-peer-recovery-interval |
30000 |
Interval in ms between recovery probes for DEAD peers |
spaces.default-lease-ms |
0 |
Default lease time (0 = infinite) |
| Property | Default | Description |
|---|---|---|
spring.datasource.url |
jdbc:postgresql://localhost:5432/javaspaces |
PostgreSQL connection URL |
spring.datasource.username |
spaces |
Database username |
spring.datasource.password |
spaces |
Database password |
spring.datasource.hikari.maximum-pool-size |
10 |
HikariCP maximum pool size |
spring.datasource.hikari.minimum-idle |
2 |
HikariCP minimum idle connections |
spring.datasource.hikari.idle-timeout |
30000 |
HikariCP idle timeout (ms) |
spring.datasource.hikari.connection-timeout |
20000 |
HikariCP connection timeout (ms) |
spring.jpa.hibernate.ddl-auto |
update |
Hibernate schema management |
spring.jpa.show-sql |
false |
Log SQL statements |
spring.jpa.open-in-view |
false |
Disable OSIV anti-pattern |
All configuration properties can be overridden via environment variables:
| Variable | Property |
|---|---|
SPACE_PORT |
server.port |
SPACE_BASE_URL |
spaces.base-url |
SPACE_DB_URL |
spring.datasource.url |
SPACE_DB_USER |
spring.datasource.username |
SPACE_DB_PASSWORD |
spring.datasource.password |
SPACE_DB_POOL_SIZE |
spring.datasource.hikari.maximum-pool-size |
SPACE_JPA_DDL |
spring.jpa.hibernate.ddl-auto |
SPACE_INSTANCE_ID |
spaces.instance-id |
SPACE_CLUSTER_ENABLED |
spaces.cluster.enabled |
SPACE_HEARTBEAT_INTERVAL |
spaces.cluster.heartbeat-interval |
SPACE_PEER_TIMEOUT |
spaces.cluster.peer-timeout |
SPACE_SYNC_INTERVAL |
spaces.cluster.sync-interval |
SPACE_CLUSTER_PEERS |
spaces.cluster.peers |
SPACE_JOIN_RETRY_MAX |
spaces.cluster.join-retry-max-attempts |
SPACE_JOIN_RETRY_BACKOFF |
spaces.cluster.join-retry-backoff-ms |
SPACE_JOIN_RETRY_MULTIPLIER |
spaces.cluster.join-retry-backoff-multiplier |
SPACE_HB_FAILURES_BEFORE_DEAD |
spaces.cluster.heartbeat-failures-before-dead |
SPACE_DEAD_PEER_RECOVERY_INTERVAL |
spaces.cluster.dead-peer-recovery-interval |
SPACE_DEFAULT_LEASE_MS |
spaces.default-lease-ms |
- JDK 25 or higher
- Maven 3.6+
- PostgreSQL 12+ running and accessible
- Spring Boot 3.5.14 (managed by the parent POM)
Create the database and user before starting the service:
-- Connect as a superuser
CREATE USER spaces WITH PASSWORD 'spaces';
CREATE DATABASE javaspaces OWNER spaces;
GRANT ALL PRIVILEGES ON DATABASE javaspaces TO spaces;Hibernate will automatically create the space_entries and space_instances tables (with indexes) on first startup when ddl-auto is set to update.
# Build
mvn clean package -DskipTests
# Run (standalone, using default PostgreSQL connection)
java -jar target/java-spaces-3.0.0.jar
# Run with custom PostgreSQL connection
java -jar target/java-spaces-3.0.0.jar \
--spring.datasource.url=jdbc:postgresql://db.example.com:5432/javaspaces \
--spring.datasource.username=spaces \
--spring.datasource.password=secret
# Run with environment variables
export SPACE_DB_URL=jdbc:postgresql://db.example.com:5432/javaspaces
export SPACE_DB_USER=spaces
export SPACE_DB_PASSWORD=secret
java -jar target/java-spaces-3.0.0.jar
# Run a second instance (clustered)
java -jar target/java-spaces-3.0.0.jar \
--server.port=8082 \
--spaces.base-url=http://myhost:8082 \
--spaces.cluster.peers=http://myhost:8081
# Run clustered instances sharing the same database (auto-discovery)
# Instance 1:
java -jar target/java-spaces-3.0.0.jar \
--spaces.instance-id=node-1 \
--spaces.base-url=http://host1:8080 \
--spring.datasource.url=jdbc:postgresql://db.example.com:5432/javaspaces
# Instance 2 (will auto-discover node-1 from the shared database):
java -jar target/java-spaces-3.0.0.jar \
--spaces.instance-id=node-2 \
--spaces.base-url=http://host2:8080 \
--spring.datasource.url=jdbc:postgresql://db.example.com:5432/javaspaces
# Run with Docker PostgreSQL
docker run -d --name spaces-postgres \
-e POSTGRES_USER=spaces \
-e POSTGRES_PASSWORD=spaces \
-e POSTGRES_DB=javaspaces \
-p 5432:5432 \
postgres:16
java -jar target/java-spaces-3.0.0.jarLogback is configured with three log files:
| File | Content |
|---|---|
logs/java-spaces.log |
General application log |
logs/java-spaces-space-ops.log |
Space operations (give/take/read/remove) |
logs/java-spaces-cluster.log |
Cluster events (join/unjoin/heartbeat/retries) |
Hibernate and HikariCP loggers are set to WARN to reduce noise at runtime.
src/main/java/com/spaces/
├── JavaSpacesApplication.java # Spring Boot entry point
├── engine/
│ ├── SpaceEngine.java # Core space operations (CHM primary, JPA secondary)
│ ├── SpaceEntry.java # JPA @Entity — space_entries table
│ ├── SpaceEntryRepository.java # JPA repository for SpaceEntry
│ ├── SpaceTemplate.java # Template matching logic
│ ├── SpaceProperties.java # Configuration properties (incl. retry config)
│ ├── SpaceOperationException.java # Runtime exception
│ └── LeaseSweeper.java # Scheduled lease cleanup
├── cluster/
│ ├── ClusterService.java # Join/unjoin/heartbeat/sync (CHM primary, JPA secondary)
│ ├── SpaceInstance.java # JPA @Entity — space_instances table
│ ├── SpaceInstanceRepository.java # JPA repository for SpaceInstance
│ ├── PeerClient.java # HTTP client for peer communication (retry-aware)
│ ├── JoinRequest.java # Join DTO
│ ├── HeartbeatMessage.java # Heartbeat DTO
│ ├── SyncSnapshot.java # Snapshot DTO
│ └── ClusterStatus.java # Status DTO
├── controller/
│ ├── SpaceController.java # REST API: give/take/read/remove
│ └── ClusterController.java # REST API: join/unjoin/status
├── client/
│ ├── SpaceClient.java # Client SDK (Java HTTP client)
│ └── SpaceClientException.java # Client exception
└── demo/
└── SpaceDemo.java # Demo application
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/space/give |
Write an entry |
POST |
/api/space/take |
Read + remove matching entry |
POST |
/api/space/read |
Read matching entry (no remove) |
DELETE |
/api/space/{id} |
Remove entry by ID |
POST |
/api/space/read-all |
Read all matching entries |
POST |
/api/space/take-all |
Take all matching entries |
GET |
/api/space/count |
Count active entries |
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/space/replicate/give |
Receive a replicated entry from a peer |
POST |
/api/space/replicate/remove/{id} |
Receive a replicated entry removal from a peer |
Both replication endpoints accept an X-Space-Source-Instance header identifying the originating instance, preventing infinite re-gossip loops.
| Method | Endpoint | Description |
|---|---|---|
POST |
/api/cluster/join |
Join the cluster |
POST |
/api/cluster/unjoin/{id} |
Unjoin from cluster |
POST |
/api/cluster/heartbeat |
Send heartbeat |
GET |
/api/cluster/snapshot |
Get full state snapshot |
GET |
/api/cluster/status |
Get cluster status |
| Version | Storage | Description |
|---|---|---|
| 1.0.0 | In-memory only | Initial implementation with ConcurrentHashMap |
| 2.0.0 | ConcurrentHashMap + JsonDb | Added JSON file secondary persistence |
| 3.0.0 | ConcurrentHashMap + PostgreSQL/JPA | PostgreSQL secondary persistence, cluster join retries with exponential backoff, peer lifecycle (ACTIVE → SUSPECTED → DEAD) with recovery probes |
| 3.1.0 | ConcurrentHashMap + PostgreSQL/JPA | Connection pool exhaustion fix: separated @Transactional DB writes from blocking HTTP gossip calls; async gossip via CompletableFuture. Entry replication: give/take/remove operations now propagate to all active peers in real-time via /api/space/replicate endpoints with X-Space-Source-Instance header to prevent infinite loops. HikariCP pool default increased to 20 with leak detection. |
- JDK 25 or higher
- Maven 3.6+
- PostgreSQL 12+
- Spring Boot 3.5.14
This project is provided as-is for educational and development purposes.