A small Flask-based distributed key-value store that demonstrates the CAP theorem trade-off between consistency and partition tolerance (CP) and availability and partition tolerance (AP). The project runs three cooperating nodes with Docker Compose, replicates writes between peers, and resolves conflicts with a last-write-wins timestamp strategy.
- Overview
- Architecture
- Consistency Modes
- Project Structure
- Requirements
- Quick Start
- API Reference
- Usage Examples
- Testing CAP Behavior
- Configuration
- Data Model
- Conflict Resolution
- Operational Notes
- Troubleshooting
- Limitations and Future Improvements
This repository contains a minimal distributed in-memory key-value service. Each node exposes the same HTTP API and knows about its peers through environment variables. A write submitted to any node is replicated to the other nodes, while reads can be performed against any node.
The application is intended for learning and experimentation. It is useful for observing how distributed systems behave when peers are reachable, unavailable, or recovering after a partition.
Key capabilities:
- Store arbitrary JSON-compatible values under string keys.
- Replicate writes across multiple Flask nodes.
- Switch each node between CP and AP behavior at runtime.
- Synchronize a recovered node with peers.
- Run a three-node local cluster using Docker Compose.
The default Docker Compose setup starts three containers on a shared bridge network:
| Node | Container | Host URL | Internal peer address | Default mode |
|---|---|---|---|---|
| Node 1 | node1 |
http://localhost:5001 |
node1:5000 |
CP |
| Node 2 | node2 |
http://localhost:5002 |
node2:5000 |
CP |
| Node 3 | node3 |
http://localhost:5003 |
node3:5000 |
CP |
Each node runs the same Flask application from app.py. The node identity, peer list, and startup mode are supplied through environment variables in docker-compose.yml.
Host machine
localhost:5001 localhost:5002 localhost:5003
| | |
v v v
+---------+ +---------+ +---------+
| node1 | <-----> | node2 | <-----> | node3 |
| Flask | | Flask | | Flask |
| store{} | <-----> | store{} | <-----> | store{} |
+---------+ +---------+ +---------+
\_____________________________________/
Docker bridge network
Replication is performed over HTTP by calling each peer's /replicate endpoint.
The service supports two runtime modes: CP and AP.
In CP mode, the node prioritizes consistency. A write is accepted only if the receiving node can replicate the update to all configured peers first.
Behavior:
- Client sends
PUT /data/<key>to a node. - The node sends the update to every peer using
POST /replicate. - If every peer confirms the write, the node writes the value locally and returns success.
- If any peer is unreachable or returns an error, the write is rejected with HTTP
503.
This means CP mode can refuse writes during a peer failure or network partition so that the cluster avoids divergent state.
In AP mode, the node prioritizes availability. A write is accepted locally even if some peers are unavailable.
Behavior:
- Client sends
PUT /data/<key>to a node. - The node writes the value locally immediately.
- The node attempts best-effort replication to each peer.
- The response includes per-peer replication results.
This means AP mode keeps accepting writes during peer failures, but nodes may temporarily hold different values until synchronization occurs.
.
├── app.py # Flask application and distributed key-value logic
├── docker-compose.yml # Three-node local cluster definition
├── Dockerfile # Python container image definition
├── requirements.txt # Python dependencies
└── README.md # Project documentation
For the recommended Docker workflow:
- Docker
- Docker Compose v2 (
docker compose) curlor another HTTP client
For running a single node directly on your machine:
- Python 3.12 or compatible Python 3 version
pip
docker compose up --buildThe first startup builds the Python image and starts all three nodes.
Open a second terminal and query each node:
curl http://localhost:5001/data
curl http://localhost:5002/data
curl http://localhost:5003/dataEach response should include the node name, mode, and current in-memory store.
curl -X PUT http://localhost:5001/data/message \
-H 'Content-Type: application/json' \
-d '{"value":"hello distributed world"}'curl http://localhost:5001/data/message
curl http://localhost:5002/data/message
curl http://localhost:5003/data/messageIf replication succeeds, each node returns the same value.
docker compose downGET /dataReturns the full local store for the node receiving the request.
Example response:
{
"node": "node1",
"mode": "CP",
"store": {
"message": {
"value": "hello distributed world",
"timestamp": 1788012345.123
}
}
}GET /data/<key>Returns one key from the node's local store.
Successful response:
{
"node": "node1",
"key": "message",
"value": "hello distributed world",
"timestamp": 1788012345.123
}Missing key response:
{
"error": "Key not found"
}PUT /data/<key>
Content-Type: application/json
{
"value": "any JSON-compatible value"
}Writes a value to the receiving node and attempts replication according to the node's current mode.
CP success response:
{
"status": "ok",
"node": "node1",
"key": "message",
"value": "hello distributed world",
"timestamp": 1788012345.123,
"mode": "CP",
"message": "All nodes consistent"
}CP failure response when a peer is unavailable:
{
"error": "Could not reach node2:5000",
"reason": "Write rejected to maintain consistency (CP mode)"
}AP success response:
{
"status": "ok",
"node": "node1",
"key": "message",
"value": "hello distributed world",
"timestamp": 1788012345.123,
"mode": "AP",
"replication": [
{
"peer": "node2:5000",
"status": "replicated"
},
{
"peer": "node3:5000",
"status": "unreachable"
}
]
}POST /replicate
Content-Type: application/json
{
"key": "message",
"value": "hello distributed world",
"timestamp": 1788012345.123
}This endpoint is used internally by peer nodes. It accepts a replicated update if the incoming timestamp is newer than or equal to the local timestamp for the same key.
POST /syncFetches all peer stores and merges newer values into the local node using last-write-wins conflict resolution.
Example:
curl -X POST http://localhost:5002/syncExample response:
{
"status": "ok",
"node": "node2",
"merged_keys": 1,
"store": {
"message": {
"value": "hello distributed world",
"timestamp": 1788012345.123
}
}
}GET /modeReturns the current mode for the node receiving the request.
Example:
{
"node": "node1",
"mode": "CP"
}POST /mode
Content-Type: application/json
{
"mode": "AP"
}Valid mode values are CP and AP. The value is normalized to uppercase.
Example:
curl -X POST http://localhost:5001/mode \
-H 'Content-Type: application/json' \
-d '{"mode":"AP"}'curl -X PUT http://localhost:5001/data/greeting \
-H 'Content-Type: application/json' \
-d '{"value":"hello"}'curl -X PUT http://localhost:5001/data/user:42 \
-H 'Content-Type: application/json' \
-d '{"value":{"name":"Ada","role":"admin"}}'curl http://localhost:5003/datafor port in 5001 5002 5003; do
curl -X POST "http://localhost:${port}/mode" \
-H 'Content-Type: application/json' \
-d '{"mode":"AP"}'
donefor port in 5001 5002 5003; do
curl -X POST "http://localhost:${port}/mode" \
-H 'Content-Type: application/json' \
-d '{"mode":"CP"}'
doneThe following scenarios can be used to observe the difference between CP and AP behavior.
Start the cluster:
docker compose up --buildStop one peer:
docker stop node2Attempt a write through node 1:
curl -i -X PUT http://localhost:5001/data/cp-test \
-H 'Content-Type: application/json' \
-d '{"value":"should be rejected"}'Expected result: node 1 returns HTTP 503 because it cannot replicate to every configured peer.
Restart node 2:
docker start node2Switch node 1 to AP mode:
curl -X POST http://localhost:5001/mode \
-H 'Content-Type: application/json' \
-d '{"mode":"AP"}'Stop one peer:
docker stop node2Write through node 1:
curl -X PUT http://localhost:5001/data/ap-test \
-H 'Content-Type: application/json' \
-d '{"value":"accepted locally"}'Expected result: node 1 returns success and reports node 2 as unreachable in the replication results.
Restart node 2:
docker start node2Synchronize node 2 after it comes back:
curl -X POST http://localhost:5002/syncRead the key from node 2:
curl http://localhost:5002/data/ap-testThe application reads these environment variables at startup:
| Variable | Default | Description |
|---|---|---|
NODE_NAME |
node1 |
Human-readable node name returned in API responses. |
PEERS |
empty | Comma-separated list of peer hostnames and ports, such as node2:5000,node3:5000. |
MODE |
CP |
Initial consistency mode. Supported values are CP and AP. |
PORT |
5000 |
Flask port inside the container or local process. |
When using Docker Compose, these values are already configured for the three-node cluster.
You can run a single local node directly for API exploration:
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
PORT=5000 NODE_NAME=node1 MODE=AP python app.pyFor a multi-node non-Docker setup, start multiple processes on different ports and configure PEERS to point at the other local processes.
Example node 1:
PORT=5001 NODE_NAME=node1 PEERS=localhost:5002,localhost:5003 MODE=CP python app.pyExample node 2:
PORT=5002 NODE_NAME=node2 PEERS=localhost:5001,localhost:5003 MODE=CP python app.pyExample node 3:
PORT=5003 NODE_NAME=node3 PEERS=localhost:5001,localhost:5002 MODE=CP python app.pyThe in-memory store is a Python dictionary. Every key maps to an object with two fields:
{
"value": "stored value",
"timestamp": 1788012345.123
}valuecontains the client-provided JSON value from the write request.timestampis generated by the receiving node withtime.time()and is used for conflict resolution.
All data is stored in memory only. Restarting a container clears that node's local store.
The project uses last-write-wins conflict resolution:
- Incoming replicated values are accepted when their timestamp is newer than or equal to the local value's timestamp.
- Synchronization merges peer stores and keeps the newest timestamp for each key.
- Clock differences between nodes can affect which value wins because timestamps come from each node's local system clock.
- The Flask development server is used for simplicity and demonstration. It is not intended as a production WSGI server.
- CP writes use a two-second timeout per peer.
- AP replication uses a one-second timeout per peer.
- Runtime mode changes are process-local. Changing node 1 to AP does not automatically change node 2 or node 3.
- The
/syncendpoint pulls state into the node receiving the request; it does not push that node's state to peers.
Another process may already be bound to 5001, 5002, or 5003. Stop the conflicting process or edit docker-compose.yml to use different host ports.
This is expected if any configured peer is unavailable or returns a non-200 response. Check container health and logs:
docker ps
docker logs node1
docker logs node2
docker logs node3Data is in memory and is lost when a node process restarts. If peers still have the data, call /sync on the restarted node:
curl -X POST http://localhost:5002/syncThis can happen in AP mode or after a node outage. Use /sync on the stale node, then read the key again.
This project intentionally keeps the implementation small. Potential improvements include:
- Persistent storage so data survives restarts.
- Health checks and automatic peer discovery.
- Background anti-entropy synchronization.
- Vector clocks or version vectors instead of wall-clock timestamps.
- Authentication and authorization for HTTP endpoints.
- Request validation for malformed or missing JSON payloads.
- Unit and integration tests.
- Production-ready serving with a WSGI server such as Gunicorn.
No license file is currently included. Add a license before using this project in a public or production context.