Skip to content

jaschenk/jspaces

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Java Spaces

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.


Architecture

┌──────────────────────────────────────────────────────────────────┐
│                    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)                     │
└──────────────────────────────────────────────────────────────────┘

Core Components

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

Storage Model

The service uses a two-tier storage design:

  1. Primary runtime store: ConcurrentHashMap<String, SpaceEntry> for space entries and ConcurrentHashMap<String, SpaceInstance> for cluster membership. All read, take, template matching, count, snapshot, and status operations use these in-memory maps for maximum throughput.

  2. Secondary durable store: PostgreSQL via Spring Data JPA persists entries in the space_entries table and instances in the space_instances table. On startup, JPA data is used to hydrate the primary maps. Every give/save/update/delete operation is mirrored to PostgreSQL within a @Transactional boundary.

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

Startup Hydration

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.


PostgreSQL Schema

Hibernate's ddl-auto: update mode automatically creates and evolves the schema. The following tables and indexes are managed:

space_entries Table

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

space_instances Table

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


Operations

Give (Write)

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
  }'

Take (Read + Remove)

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"}}'

Read (Without Remove)

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"}}'

Remove (By ID)

Removes a specific entry by its unique ID.

curl -X DELETE http://localhost:8080/api/space/{entryId}

Template Matching Rules

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

Cluster Operations

Join

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:

  1. New instance sends JOIN with its ID and URL
  2. Receiving instance registers it as ACTIVE
  3. New instance can request a /api/cluster/snapshot to sync existing entries
  4. Other peers are notified via gossip

Peer Lifecycle

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:

  1. When a heartbeat to an ACTIVE peer fails, the peer transitions to SUSPECTED and the failure count is incremented
  2. Subsequent failed heartbeats increment the failure count — the peer remains SUSPECTED
  3. Once the failure count reaches the configured threshold (heartbeat-failures-before-dead), the peer transitions to DEAD
  4. 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.

Cluster Join Retries

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

Shared-Database Peer Discovery

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:

  1. On startup, ClusterService.hydrateFromJpa() loads all known instance records from the space_instances table into the in-memory ConcurrentHashMap
  2. After registering itself, the instance sends a JOIN request to every ACTIVE or SUSPECTED peer discovered from the database
  3. The instance requests a snapshot from the first responsive peer to synchronize any entries that may not yet be in the shared database
  4. 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:

  1. @PostConstruct — hydrate instance records from PostgreSQL
  2. onStartup() — resolve base URL, register self as ACTIVE
  3. joinPeersFromDatabase() — send JOIN to peers found in the hydrated data
  4. connectToSeedPeers() — additionally connect to any explicitly configured seed peers

Unjoin

An instance gracefully leaves the cluster:

curl -X POST "http://localhost:8080/api/cluster/unjoin/space-node-2?removeEntries=false"

Unjoin Protocol:

  1. Instance sends UNJOIN with its ID
  2. Status transitions to LEAVING → DEAD
  3. If removeEntries=true, all entries from that origin are deleted
  4. Peers are notified via gossip

Snapshot (Sync)

Get a full snapshot for synchronizing a new or rejoining instance:

curl http://localhost:8080/api/cluster/snapshot

Heartbeat

Peers send periodic heartbeats. Dead peers are detected after the configured timeout and marked DEAD.


Client SDK

// 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();

Configuration

Application Properties

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)

PostgreSQL / JPA Properties

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

Environment Variable Overrides

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

Running

Prerequisites

  • JDK 25 or higher
  • Maven 3.6+
  • PostgreSQL 12+ running and accessible
  • Spring Boot 3.5.14 (managed by the parent POM)

PostgreSQL Setup

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 and Run

# 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.jar

Logging

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


Project Structure

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

API Reference

Space Operations

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

Replication Operations (internal — peer-to-peer)

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.

Cluster Operations

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 History

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.

Requirements

  • JDK 25 or higher
  • Maven 3.6+
  • PostgreSQL 12+
  • Spring Boot 3.5.14

License

This project is provided as-is for educational and development purposes.

About

JAVA Spaces Implementation

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages