Skip to content

Repository files navigation

SwiftTrack

A prototype delivery/logistics tracking platform built as a set of independent microservices coordinated through a message broker, rather than one monolithic app. It models a real-world scenario: a client submits a delivery order, which then has to clear three separate backend systems — a Cargo Management System (CMS), a Warehouse Management System (WMS), and a Route Optimisation System (ROS) — each speaking its own protocol, without ever making the client wait for that to finish.

Why it's built this way

The core idea is decoupling via a message queue, combined with an orchestrated Saga for the multi-step backend transaction:

  • The client should never block on CMS/WMS/ROS — it gets a 202 Accepted immediately, and the real work happens asynchronously.
  • If one of the three backend steps fails partway through, whatever already succeeded needs to be undone (compensation), not left in a half-finished state.
  • Delivery-status changes (a driver marking a package delivered) need to reach the client in real time, not on the next page refresh.

Architecture

flowchart LR
    CP[Client Portal<br/>client-portal/] -- "POST /login, /orders" --> OS[Order Service<br/>:5000]
    DA[Driver App<br/>driver-app/] -- "POST /login,<br/>/deliveries/:id/status" --> OS
    OS -- "INSERT order PENDING" --> PG[(Postgres)]
    OS -- "publish order.created" --> MQ{{RabbitMQ<br/>swifttrack exchange}}
    MQ -- "order.created" --> SW[Saga Worker]
    SW -- "XML/HTTP" --> CMS[mock-cms :5001]
    SW -- "JSON/HTTP" --> ROS[mock-ros :5002]
    SW -- "TCP/JSON" --> WMS[mock-wms :6000]
    SW -- "UPDATE order CONFIRMED/FAILED" --> PG
    SW -- "publish order.completed / order.failed" --> MQ
    OS -- "publish delivery.updated" --> MQ
    MQ -- "order.#, delivery.#" --> NS[Notification Service<br/>:5003]
    NS -- "WebSocket push" --> CP
    NS -- "WebSocket push" --> DA
Loading
Component Role Protocol it speaks
Order Service (order-service/) Thin API: validates, persists PENDING, publishes an event, returns 202 immediately. Also handles JWT login and delivery-status updates. HTTP/JSON
Saga Worker (saga-worker/) Separate consumer process. Picks up order.created, drives CMS → WMS → ROS in sequence, compensates on failure, updates Postgres. AMQP consumer
mock-cms Stands in for a legacy on-prem CMS. XML over HTTP
mock-ros Stands in for a modern cloud route optimiser. JSON/REST
mock-wms Stands in for a proprietary warehouse protocol. Newline-delimited JSON over raw TCP
Notification Service (notification-service/) Subscribes to all order.*/delivery.* events, rebroadcasts over WebSocket. AMQP consumer + Socket.IO
Client Portal (client-portal/index.html) Log in, submit orders, watch them go live PENDING → CONFIRMED. Static HTML/JS
Driver App (driver-app/index.html) Log in, see confirmed orders as a manifest, mark delivered/failed. Static HTML/JS
RabbitMQ Topic exchange (swifttrack) decoupling every producer from every consumer. AMQP
Postgres Source of truth for order/delivery state and idempotency keys.

Patterns implemented

  • Orchestrated Saga + compensationsaga-worker/worker.py's run_saga(). If ROS fails after CMS/WMS already succeeded, it calls WMS's and CMS's cancel operations in reverse order.
  • Circuit breakerpybreaker wraps the ROS call; 3 consecutive failures trips it, 20s cooldown before retrying.
  • Competing consumers — the Saga Worker's queue is durable with manual ack, so redelivery on a crash doesn't lose an order.
  • Idempotent consumerprocess_order() checks the order's current status before reprocessing, guarding against RabbitMQ redelivery.
  • Idempotency on the API — a repeated Idempotency-Key header on POST /orders returns the original order instead of creating a duplicate, backed by a Postgres table so it survives a service restart.
  • Publish–subscribe — the swifttrack topic exchange with order.# and delivery.# routing keys, so the Saga Worker and Notification Service each get only what they care about.
  • Protocol/data-translation adapters — real XML built for CMS, JSON for ROS, newline-delimited TCP for WMS.

Prerequisites

  • Docker Desktop (running)
  • curl (or any HTTP client) for the demo commands below

1. Configure and start the complete stack

First create your local configuration file and replace its demo values:

cp .env.example .env
docker compose up --build -d

This starts every component in its own container:

  • RabbitMQ: localhost:5672 (management UI at localhost:15672; use the credentials in .env)
  • PostgreSQL: localhost:5432 (credentials and database name from .env) — orders, idempotency_keys, and users are created automatically from init.sql on first start only (see Troubleshooting if you change the schema later).
  • CMS / ROS / WMS mocks: localhost:5001, localhost:5002, and localhost:6000
  • Order Service / Notification Service: localhost:5000 and localhost:5003
  • Client Portal: http://localhost:8090 (not :8080 — pick whatever's free on your machine and adjust the port mapping in docker-compose.yml and ALLOWED_ORIGINS in .env if 8090 is already taken)
  • Driver App: http://localhost:8081

Follow the services with:

docker compose ps
docker compose logs -f saga-worker

docker compose up waits for RabbitMQ and PostgreSQL health checks before starting dependent services. No local Python installation or separate service terminals are required.

Scaling the Saga Worker — since it's a competing consumer on a durable queue, you can run several instances with no code change:

docker compose up -d --scale saga-worker=3

Submit a few orders and each lands on whichever replica RabbitMQ hands it to next (prefetch_count=1 on the queue). Scale back down with docker compose up -d --scale saga-worker=1 when you're done.

2. Try it end to end

Log in and submit an order, either through the Client Portal at http://localhost:8090, or directly:

CLIENT_TOKEN=$(curl -s -X POST http://localhost:5000/login \
  -H "Content-Type: application/json" \
  -d '{"username":"client-demo","password":"change-this-client-password"}' | python -c "import sys,json;print(json.load(sys.stdin)['token'])")

curl -i -X POST http://localhost:5000/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $CLIENT_TOKEN" \
  -H "Idempotency-Key: demo-key-1" \
  -d '{"clientName": "Kandy Traders", "addresses": ["123 Galle Rd", "45 Duplication Rd"]}'

For the driver-only delivery endpoint, obtain a separate driver token:

DRIVER_TOKEN=$(curl -s -X POST http://localhost:5000/login \
  -H "Content-Type: application/json" \
  -d '{"username":"driver-demo","password":"change-this-driver-password"}' | python -c "import sys,json;print(json.load(sys.stdin)['token'])")

The response comes back 202 immediately with "status": "PENDING" — before CMS/WMS/ROS have even been called. A moment later:

  • The Saga Worker's terminal logs the CMS/WMS/ROS calls happening.
  • client-portal/index.html flips that order's row to CONFIRMED live.
  • Or query it directly: curl http://localhost:5000/orders/<orderId>

Mark it delivered, either via "Mark delivered" on the order's card in driver-app/index.html, or:

curl -X POST http://localhost:5000/deliveries/<orderId>/status \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $DRIVER_TOKEN" \
  -d '{"status": "DELIVERED"}'

Watch client-portal/index.html pick up the delivery status live in the same row.

Demo idempotency — repeat the exact same curl command with the same Idempotency-Key. You get the same orderId back and no second order.created event fires (nothing new appears in the Saga Worker's terminal).

Demo saga compensation + the circuit breaker:

curl -X POST http://localhost:5002/routes/toggle-failure -H "X-API-Key: change-this-ros-api-key"

curl -i -X POST http://localhost:5000/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $CLIENT_TOKEN" \
  -H "Idempotency-Key: demo-key-2" \
  -d '{"clientName": "Colombo Mart", "addresses": ["10 Marine Drive"]}'

Watch the Saga Worker's terminal: CMS and WMS both succeed, ROS fails, and you'll see the compensating calls fire against CMS's /cancel and WMS's cancel handling. The order ends up FAILED with failedStep: "ros". Submit two or three more orders (new Idempotency-Key each time) while failure mode is still on — a later attempt should trip the circuit breaker, and the failure reason will say "circuit breaker open" instead of a raw connection error. Toggle failure mode off again afterwards (curl -X POST http://localhost:5002/routes/toggle-failure -H "X-API-Key: change-this-ros-api-key").

Running the tests

A pytest suite covers order-service (auth/role enforcement, request validation, idempotent order submission, order history/listing and ownership scoping on GET, delivery status updates) and saga-worker (the saga's success path, all three compensation paths, and process_order's claim/update/publish control flow). Postgres and RabbitMQ are mocked out, so it runs with nothing started — no Docker required.

pip install -r requirements-test.txt
pytest

API reference

Method Path Auth Notes
POST /login Username + password Issues a JWT with the configured client or driver role
POST /orders Client JWT + Idempotency-Key header Returns 202 immediately
GET /orders JWT History/list: clients get their own orders, drivers get every order (operational manifest). Used to seed the UI on login.
GET /orders/<id> JWT Clients can view only their own orders; drivers can view operational orders
POST /deliveries/<id>/status Driver JWT status must be DELIVERED or FAILED
GET /health On every service
POST /routes/toggle-failure ROS API key mock-ros only, flips simulated ROS outage on/off

Project structure

order-service/       Order API: validation, 202-immediately, auth, idempotency
saga-worker/          Saga orchestration, compensation, circuit breaker
mock-cms/, mock-ros/, mock-wms/   Simulated backend systems, one protocol each
notification-service/ RabbitMQ → WebSocket bridge
client-portal/        Client-facing static UI (+ Dockerfile, nginx)
driver-app/            Driver-facing static UI (+ Dockerfile, nginx)
shared/               CSS shared by both UIs
tests/                pytest suite — see "Running the tests" above
Dockerfile             Shared image for the five Python services
docker-compose.yml     Every service containerized: RabbitMQ, Postgres,
                       the three mocks, order-service, saga-worker,
                       notification-service, and both UIs
init.sql              Schema: orders, idempotency_keys, users

Known limitations

This is a prototype built incrementally across a multi-week assignment. Current gaps (tracked in more detail in ROADMAP.md):

  • Browser connections use JWTs and client events are scoped to the owner; drivers receive the operational manifest. Driver assignment per individual driver is not yet implemented.
  • .env is deliberately excluded from Git. .env.example contains only replaceable local demo values.
  • CMS uses HTTP Basic Authentication, ROS uses an API key, and the WMS TCP protocol uses a shared service token. These are demonstration controls; production deployment still requires TLS or mTLS/VPN protection.
  • No TLS anywhere (acceptable for local dev; a real deployment would terminate TLS at a gateway/load balancer).
  • No retry/backoff on the WMS TCP call specifically (RabbitMQ connections do retry with backoff — see Troubleshooting below).

Troubleshooting

RabbitMQ's healthcheck can pass slightly before its AMQP listener is actually ready for connections. rabbitmq-diagnostics -q ping (used in docker-compose.yml's healthcheck) confirms the node is up, not that the AMQP port is accepting new client connections yet — so saga-worker and notification-service can still hit pika.exceptions.AMQPConnectionError in the few seconds after RabbitMQ reports healthy. Both now retry that connection with backoff (up to 10 attempts, 3s apart) instead of crashing, so this resolves itself on its own within docker compose up; you'll see RabbitMQ not ready yet (attempt N/10) in docker compose logs saga-worker if it happens. order-service does the same (shorter backoff) around publish_event on the request path. restart: unless-stopped on every app service is a second line of defense if a container exits for any other reason.

Changed init.sql but Postgres doesn't have the new table/column. Docker only runs docker-entrypoint-initdb.d scripts the first time a volume is created. If you add a table to init.sql after Postgres has already been running, apply it manually instead of recreating the volume (which would drop existing data):

docker exec swifttrack-postgres psql -U swift -d swifttrack -c "<your new CREATE TABLE statement>"

The client portal / driver app table was empty after a refresh — fixed. Both pages used to be pure live views with no history: they only rendered events received over the WebSocket after the page connected, so logging out and back in showed nothing until you submitted a fresh order. Both UIs now call GET /orders once on login to seed the table with real history before the WebSocket takes over for live updates — see the API reference below. The data was always in Postgres regardless; you can still check it directly with GET /orders/<id> or the query below.

Inspecting Postgres directly:

docker exec -it swifttrack-postgres psql -U swift -d swifttrack -c "SELECT * FROM orders;"

Or connect a GUI (DBeaver, pgAdmin, the PostgreSQL VS Code extension) using the PostgreSQL host, user, password, and database values in .env.

About

Event-driven middleware integrating three legacy logistics systems (SOAP, TCP socket, REST) behind one API. Orchestrated saga with compensating transactions, idempotent consumers, and circuit breakers. Flask · RabbitMQ · PostgreSQL · Docker.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages