This project demonstrates a minimal in-memory Dynamo-style consistent hashing ring with single-token-per-node membership. Similar to BigTable, DynamoDB, and Cassandra, for distribution. We don't persist any data to disk. The idea is the tracking server instances in a region form a group where they have a shared memory of user sessions.
- Add/remove/list ring nodes in-memory
- Deterministic token generation per node (
id@host:port) - Key lookup to primary owner with ring wrap-around
- Preference list generation using configurable replication factor
- Optional periodic node sync from a remote URL
- HTTP endpoints for quick manual testing
- Duplicate-request detection with a 60s sliding duplicate window and a 300s cache TTL
- Cluster routing:
/requestderives a key, locates the owner via the ring, and forwards over HTTP if remote - Failure simulation: each node carries an
aliveflag; dead nodes are skipped during routing - Metrics:
requests.duplicateandrequests.newcounters via Micrometer / Spring Boot Actuator
GET /request?session=<alphanumeric>&program=<int>— classify a request asNEWorDUPLICATEGET /actuator/metrics/requests.duplicate(andrequests.new) — counter values
POST /ring/nodes— add nodeDELETE /ring/nodes/{id}— remove nodePOST /ring/nodes/{id}/fail— simulate node failure (setsalive=false)POST /ring/nodes/{id}/recover— recover a failed node (setsalive=true)GET /ring— list ring nodes (sorted by token)GET /ring/locate?key=...— resolve key to primary + preference list
POST /internal/classify— body{"key": "..."}; processes locally without re-forwarding
POST /ring/nodes
{
"id": "node-a",
"host": "127.0.0.1",
"port": 9001
}Every node serves a small status page at its root URL — open
http://<any-node-host>:<port>/ in a browser. The page (Alpine.js, no build
step) polls a single endpoint on the serving node, GET /cluster/status,
which returns a snapshot maintained by ClusterStatusAggregator. That
aggregator runs on a fixed-delay schedule
(freyja.cluster-status.poll-interval-ms, default 2s) and fans out
server-to-server to every peer's
/actuator/metrics/requests.new and /actuator/metrics/requests.duplicate.
This means the browser only needs to reach the node that served the page —
peer hosts don't need to be reachable from your laptop.
A three-node walk-through that exercises the ring, the UI, and the load
generator — all on localhost.
Open three terminals and start one node in each, on different ports:
# terminal 1
./gradlew bootRun --args='--server.port=9001 --freyja.node.host=127.0.0.1'
# terminal 2
./gradlew bootRun --args='--server.port=9002 --freyja.node.host=127.0.0.1'
# terminal 3
./gradlew bootRun --args='--server.port=9003 --freyja.node.host=127.0.0.1'Each node self-registers itself, but they don't yet know about each other.
Tell every node about the other two by POSTing to /ring/nodes:
for me in 9001 9002 9003; do
for peer in 9001 9002 9003; do
[ "$me" = "$peer" ] && continue
curl -s -X POST "http://127.0.0.1:$me/ring/nodes" \
-H 'Content-Type: application/json' \
-d "{\"id\":\"freyja-$peer\",\"host\":\"127.0.0.1\",\"port\":$peer}" \
>/dev/null
done
done
# sanity check: every node should now list all three
curl -s http://127.0.0.1:9001/ring | jqBrowse to any node:
http://127.0.0.1:9001/
You should see all three nodes listed as alive. Counters are zero. The
page is identical on :9002/ and :9003/ — each node aggregates the
cluster view independently from its own perspective.
In a fourth terminal, run the load generator against all three nodes:
./gradlew :tools:load-gen:run -Pargs="\
--targets http://127.0.0.1:9001,http://127.0.0.1:9002,http://127.0.0.1:9003 \
--rate 50 \
--duration 1m \
--duplicate-ratio 0.25"Within a couple of seconds the UI's NEW and DUPLICATE counters should
start climbing on every node, and the cluster totals should track the
generator's sent count. See
how-to-run-simulated-traffic.md for
all flags.
Try simulating a node failure mid-run:
curl -X POST http://127.0.0.1:9001/ring/nodes/freyja-9002/fail
# ...later
curl -X POST http://127.0.0.1:9001/ring/nodes/freyja-9002/recoverThe failed node turns red in the UI; routing skips it until you recover it.
src/main/resources/application.properties
freyja.ring.replication-factor=3
freyja.ring.sync-enabled=false
freyja.ring.nodes-url=
freyja.ring.sync-interval-ms=30000
# Self-identity (optional overrides; sensible defaults derived from server.port)
#freyja.node.id=
#freyja.node.host=
#freyja.node.port=
freyja.node.self-register=true
# Cache eviction sweep cadence; the 300s entry TTL itself is fixed by the spec.
freyja.cache.sweep-interval-ms=30000When sync is enabled, the app periodically GETs freyja.ring.nodes-url and reconciles local membership to exactly match the remote list.
Expected remote payload:
[
{ "id": "n1", "host": "10.0.0.1", "port": 9001 },
{ "id": "n2", "host": "10.0.0.2", "port": 9001 }
]- Single process and in-memory only (no gossip, no persistence)
- Single token per node (no virtual nodes yet)
- No hinted handoff, quorum (
R/W), or failure detector yet - Sync fetch failures are logged and retried at next interval
