A message broker built from scratch on PostgreSQL, with at-least-once delivery, topic-based fan-out, explicit acknowledgements, a dead-letter queue, API-key security and a Java SDK. Inspired by RabbitMQ's core concepts, and built to understand them from the inside out.
Live demo: https://miniqueue.onrender.com (sign in with GitHub: any account gets read-only access)
API explorer: https://miniqueue-backend.onrender.com/swagger-ui/index.html
Java SDK: sdk/, a plain Java client and a Spring Boot starter with @MiniQueueListener
MiniQueue decouples producers from consumers with a topic → queue → consumer model. A producer publishes a message to a topic. The broker fans it out to every queue bound to that topic. Each queue delivers its copy independently. A message only counts as done after an explicit ack: if a consumer crashes before acking, the message is redelivered.
That is the guarantee that makes distributed systems reliable: at-least-once delivery. Consumers should therefore be idempotent, because a message can occasionally arrive twice.
Producer (API key with publish:order.placed)
│
│ POST /api/v1/topics/order.placed/messages
▼
┌─────────────────────────────────────────┐
│ MiniQueue broker │
│ │
│ Topic order.placed │
│ │ │
│ ├──── binding ────▶ email-queue │
│ │ │
│ └──── binding ────▶ stock-queue │
└─────────────────────────────────────────┘
│ │
▼ ▼
email-service stock-service
(consume:email-queue) (consume:stock-queue)
Pull (REST polling, used by the SDK) Push notification (WebSocket)
POST /api/v1/queues/{queue}/consume /ws/consume?queue=email-queue&ticket=...
POST /api/v1/messages/{id}/ack → "message X is ready" → POST .../consume
PENDING ──── consumed ────▶ UNACKED ──── acked ────▶ ACKED
▲ │
│ ├── nack (requeue) or ack timeout ──▶ back to PENDING
│ │
│ └── nack (no requeue) or retries exceeded ──▶ DEAD
│ │
└──────────────────────────── replayed from DLQ ───────────────────────────┘
An ACKED message is final. Every transition locks the message row first (see Concurrency guarantees).
Producer publishes → broker saves the message as PENDING and commits
↓
NewMessageEvent fires after the commit
↓
WebSocketEventListener picks the next connected consumer (round-robin)
↓
Pushes { "queue": "email-queue", "message_id": "..." } (the id only, never the payload)
↓
Consumer calls POST /api/v1/queues/email-queue/consume with its API key → UNACKED
↓
Consumer processes, then POST /api/v1/messages/{id}/ack → ACKED
Dashboard asks POST /api/v1/auth/ws-ticket → opens /ws/monitor?ticket=wst_...
↓
Every state transition fires a BrokerActivityEvent (after commit)
↓
MonitorEventListener broadcasts it to every connected dashboard
↓
The live activity feed renders it
Two kinds of callers, one permission model.
| Caller | Proves who it is with | Gets it from | Lasts |
|---|---|---|---|
| Programs (producers, consumers, scripts, Prometheus) | API key mq_... |
An admin registers the program on the dashboard's Clients page; the key is shown once | Until rotated or revoked |
| People (the dashboard) | Session token mqs_... |
Sign in with GitHub | 8 hours, or until sign-out |
| Browsers opening a WebSocket | Ticket wst_... in the URL |
POST /api/v1/auth/ws-ticket with a session or key |
30 seconds, one use |
Every credential becomes the same thing on the server: a name plus a set of scopes.
| Scope | Allows |
|---|---|
publish:<topic> / publish:* |
Publishing to that topic / any topic |
consume:<queue> / consume:* |
Consuming, acking and nacking on that queue / any queue |
view |
Reading the dashboard: overview, queues, messages, DLQ, live feed |
metrics |
Scraping /actuator/prometheus |
admin |
Everything: topics, queues, replay, clients |
GitHub usernames listed in MINIQUEUE_ADMINS sign in with admin. Anyone else who signs in with GitHub gets view, which is what makes the public demo safe to share. Anything not explicitly allowed is denied.
1. Dashboard → GET /api/v1/auth/github/login
Broker → random state in a 10-minute HttpOnly cookie → redirect to GitHub
2. GitHub → user authorizes → redirect to /api/v1/auth/github/callback?code=...&state=...
3. Broker → state must match the cookie (blocks login CSRF)
→ exchanges the code with GitHub server to server, reads { id, login }, discards GitHub's token
→ admin or view, from MINIQUEUE_ADMINS → creates an mqs_ session (stored as a SHA-256 hash)
→ redirects to the dashboard with a one-time login code (60 seconds), never the token itself
4. Dashboard → POST /api/v1/auth/exchange { code } → session token → sent as Bearer on every call
- Keys and tokens are stored as SHA-256 hashes. A database leak does not leak working credentials. Keys are 256 random bits, so a fast hash is enough; BCrypt's slowness only matters for human-chosen passwords, and there are none.
- Opaque session tokens, not JWTs. A JWT cannot be revoked before it expires. A stored token can: sign-out deletes the row and the token is dead everywhere. The cost is one indexed lookup per request, which this single-service broker already pays for API keys.
- Tokens in a header, not cookies. The dashboard and the API are different sites on
onrender.com(a public suffix), so browsers treat backend cookies as third-party and block them. A header also rules out CSRF: a malicious page cannot make the browser attach it. - Single-use codes wherever a credential must travel in a URL (the sign-in redirect, the WebSocket handshake). URLs end up in logs and browser history; by then the code has been used or has expired.
- Consumer identity comes from the key, plus an optional
MiniQueue-Instanceheader to tell replicas apart, never from the request body. One client cannot ack another client's message.
| Layer | Technology | Why |
|---|---|---|
| Backend | Spring Boot 3.5, Java 21 | Production-grade framework, LTS release |
| Database | PostgreSQL 16 + Flyway | Durable storage, row-level locking (FOR UPDATE SKIP LOCKED), versioned schema |
| Security | Spring Security, GitHub OAuth | API keys and scopes for programs, GitHub sign-in for people |
| ORM | Spring Data JPA / Hibernate | Type-safe database access |
| SDK | Java 17 HttpClient + Jackson, Spring Boot starter |
Retries with backoff, a polling consumer, @MiniQueueListener |
| Frontend | React + TypeScript, Tailwind CSS + shadcn/ui | Operations and management dashboard |
| Observability | Micrometer, Prometheus + Grafana | Metrics collection and visualization |
| Containers | Docker + Docker Compose | Single-command local stack |
| Testing | JUnit 5, Testcontainers, MockMvc | Integration tests against a real PostgreSQL, through the real security chain |
| CI/CD | GitHub Actions, Render deploy hooks | Test, deploy the tested commit, smoke-test production |
Sign in with GitHub. Admins see everything; everyone else sees the same data without the buttons that change it.
Overview: broker-wide message counts by status (pending, unacked, acked, dead). Refreshes on every broker event.
Queue breakdown: per-queue counts with color-coded status columns. Click a queue to inspect its messages inline.
Dead letter queue: dead messages with their last consumer, retry count and timestamp. Admins can replay a message back to PENDING.
Live activity feed: every broker event as it happens, through /ws/monitor. Capped at 50 events, with a connection indicator and automatic reconnect (a fresh ticket each time).
Topics and queues: create topics, bind queues to them, and inspect messages.
Publish: publish to any topic, with JSON validation and payload templates.
Clients: register the programs that publish or consume, choose their scopes, and copy the new key (shown once). Rotate a lost key or revoke a client; both take effect immediately.
Messages must survive a broker restart. An in-memory store like Redis would lose every unacked message on a crash. PostgreSQL gives durable storage with ACID guarantees: a committed message is still there after a restart. That is the foundation of at-least-once delivery.
Sequential IDs are generated by the database, which becomes a coordination point when several broker instances run in parallel. UUIDs are generated by the application, are globally unique, and cannot be guessed by callers.
PENDING, UNACKED, ACKED and DEAD as readable strings make the database self-documenting: SELECT * FROM message WHERE status = 'DEAD' needs no explanation. Integer enums save a few bytes and cost readability, which is not worth it at this scale.
Each queue gets its own copy, so email-queue and stock-queue consume and ack independently, and one queue's progress never affects another. The trade-off is storage: a publish to a topic with 10 queues writes 10 rows. For a broker, that is the right trade-off. The rows are written in one transaction, so either every queue gets the message or none does.
If the application set the timestamp, clock skew between broker instances could reorder messages. The database has one clock, so published_at DEFAULT now() gives consistent ordering within a queue.
Everything lives under /api/v1, so a future v2 can change shapes without breaking v1 clients. URLs name resources and the HTTP method says what happens. Consume is a POST, not a GET, because it changes state (PENDING → UNACKED), and HTTP allows browsers and proxies to repeat a GET.
A producer's retry after a lost response would otherwise store the message twice. With Idempotency-Key: <key>, the broker stores at most one message per key and publisher: the publish transaction starts with INSERT ... ON CONFLICT DO NOTHING on (client_id, idempotency_key), so a concurrent duplicate waits for the first and then gets the original message ids with "duplicate": true. If the first publish fails, its claim rolls back with it. Reusing a key for a different topic or payload is a 409. Keys are remembered for 24 hours. The Java SDK sends a fresh key per publish call and the same key on every retry; publish(topic, payload, key) lets a producer derive the key from the event itself, so even a re-send after a crash is stored once.
Marking a message UNACKED at push time looks simpler, but if the consumer crashes between receiving the push and starting work, the message sits in UNACKED with nobody holding it until the redelivery timeout. It would also duplicate the claim logic in a second code path.
So the WebSocket only says "message X is ready". The consumer then calls the authenticated consume endpoint, the single, tested path that claims a message. If two consumers race for the same notification, the row lock on that path guarantees exactly one wins; the other gets 204. The notification carries the message id only, because payloads may be confidential and pass through logging proxies.
Sending every notification to every connected consumer would make them all race for one message. Round-robin spreads notifications evenly with a per-queue atomic counter: thread-safe, and no coordination between consumers.
/ws/consume is for delivery to one queue's consumers; /ws/monitor is for observability across the whole broker. Mixing them would make the dashboard subscribe to every queue and send consumers events they don't need. They also need different permissions: consume:<queue> versus view.
Counting messages in Java would mean transferring every row first; a queue with 100,000 messages would move 100,000 rows to produce one number. GROUP BY ... COUNT() computes the counts in one scan and returns a handful of integers.
MiniQueue is safe with many competing consumers on one queue, and with more than one broker instance on the same database: a message is never handed to two consumers at once, and an ack is never silently overwritten. An integration test proves it.
The original consume path was look, then act: read the oldest PENDING message, then update it to UNACKED in a separate step. Two consumers polling at the same moment could read the same row, and both received the message. The redelivery scheduler had the same shape, so it could overwrite a message a consumer had just acked.
@Transactional does not fix this. A transaction makes changes all-or-nothing, but a plain SELECT takes no lock in PostgreSQL, so two transactions can still read the same row and both act on it.
Every path that changes a message's state locks the row before deciding anything:
| Path | Lock | Why this lock |
|---|---|---|
| Consume | SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1 |
Each consumer locks a different row and skips rows others hold, so N consumers work in parallel instead of queueing behind one row |
| Redelivery scheduler | FOR UPDATE SKIP LOCKED, in batches |
Two broker instances never redeliver the same message, and a message that is mid-ack is skipped, not overwritten |
| Ack / nack / replay | FOR UPDATE (pessimistic write) |
These target one message, so they wait for the lock and then check its real, current status |
The lock is held only for the short claim transaction, never while the consumer works. After the claim, UNACKED with a consumer_id and an unacked_at timestamp acts as a lease: if it isn't acked before the timeout, the scheduler returns it to PENDING, and a late ack from the original consumer is rejected by the consumer_id check (fencing).
The same look-then-act race existed when creating topics and queues; there, a UNIQUE constraint is the fix, and a duplicate gets 409.
ConcurrencyIntegrationTest starts a real PostgreSQL 16 with Testcontainers, publishes 100 messages, releases 10 consumer threads at the same instant, and asserts that no message was delivered twice, every message was delivered, and the database shows 0 PENDING and 100 UNACKED. Removing SKIP LOCKED makes the test fail with duplicates, which shows it detects the race rather than passing by luck.
SKIP LOCKED gives approximately FIFO delivery, not strict FIFO. With several consumers, a later message can finish before an earlier one, as with an SQS standard queue. Strict per-key ordering would need partitioning, which MiniQueue does not implement.
- One-time codes live in memory. WebSocket tickets and sign-in codes work for a single broker instance; several instances would need them in PostgreSQL or Redis. (Message delivery itself is already safe across instances.)
- WebSocket notifications are per instance. The session registry is in memory, so a push only reaches consumers connected to the instance that took the publish. Polling consumers are unaffected.
- Open WebSockets outlive sign-out and revocation. Credentials are checked at the handshake only.
- Dashboard roles are decided at sign-in. Removing someone from
MINIQUEUE_ADMINStakes effect when their session expires (at most 8 hours). - No strict ordering or partitioning. Delivery across competing consumers is approximately FIFO.
- No retention yet. Acked and dead messages are never deleted, and the per-queue message listing is not paginated.
- No rate limiting yet, and the payload size limit is checked after the request body has been read.
- Timestamps have no time zone (
LocalDateTime); they are correct while the server and database run in UTC, as they do on Render. - Unbenchmarked at scale. PostgreSQL was chosen for correctness over a purpose-built log; where write throughput or lock contention becomes the bottleneck has not been measured.
All REST endpoints are versioned under /api/v1. Programs send Authorization: Bearer mq_<api key>; the dashboard sends Authorization: Bearer mqs_<session token>.
| Method | Path | Body | Needs | Purpose |
|---|---|---|---|---|
POST |
/api/v1/topics/{topic}/messages |
{ "payload": "{\"orderId\": 42}" } |
publish:{topic} |
Publish to every queue bound to the topic. Optional Idempotency-Key header: a repeat returns the original message_ids with "duplicate": true |
POST |
/api/v1/queues/{queue}/consume |
consume:{queue} |
Claim the oldest pending message: 200 with it, or 204 if none |
|
POST |
/api/v1/messages/{id}/ack |
the consumer holding it | Mark processed | |
POST |
/api/v1/messages/{id}/nack |
{ "requeue": true } (optional) |
the consumer holding it | Reject: true (default) retries, false moves it to the DLQ |
POST |
/api/v1/topics |
{ "name": "order.placed" } |
admin |
Create a topic |
POST |
/api/v1/queues |
{ "name": "email-queue", "topic_name": "order.placed" } |
admin |
Create a queue bound to a topic |
GET |
/api/v1/topics, /api/v1/queues |
view |
List topics / queues | |
GET |
/api/v1/queues/{queue}/messages |
view |
Messages in a queue | |
GET |
/api/v1/dashboard/overview, /queues, /dlq |
view |
Counts by status, per queue, and dead messages | |
POST |
/api/v1/dashboard/dlq/{id}/replay |
admin |
Move a dead message back to PENDING |
The optional MiniQueue-Instance: <id> header on consume, ack and nack tells replicas that share one key apart. Topic and queue names are lowercased and must be 1 to 100 characters from a-z, 0-9, ., _ and -. Payloads are limited to 256 KB by default (miniqueue.limits.max-payload-bytes).
| Method | Path | Body | Needs | Purpose |
|---|---|---|---|---|
POST |
/api/v1/clients |
{ "name": "email-service", "scopes": ["consume:email-queue"] } |
admin |
Register; the response holds api_key, shown once |
GET |
/api/v1/clients |
admin |
Active clients: key prefixes and last use, never keys | |
POST |
/api/v1/clients/{name}/rotate |
admin |
New key; the old one stops working immediately | |
DELETE |
/api/v1/clients/{name} |
admin |
Revoke |
| Method | Path | Needs | Purpose |
|---|---|---|---|
GET |
/api/v1/auth/github/login |
nobody | Starts GitHub sign-in (a browser navigation) |
GET |
/api/v1/auth/github/callback |
nobody | GitHub returns here; redirects to the dashboard |
POST |
/api/v1/auth/exchange |
a login code | { "code": "lc_..." } → session token |
GET |
/api/v1/auth/me |
any credential | Who am I, and which scopes |
POST |
/api/v1/auth/logout |
a session | Ends the session everywhere |
POST |
/api/v1/auth/ws-ticket |
any credential | A 30-second, single-use WebSocket ticket |
| Path | Needs |
|---|---|
/actuator/health, /actuator/info (deployed commit) |
nobody |
/actuator/prometheus |
metrics or admin |
/swagger-ui/index.html |
nobody (click Authorize and paste a key to try calls) |
/ws/consume?queue=email-queue&ticket=wst_... needs consume:email-queue
/ws/monitor?ticket=wst_... needs view or admin
Programs that can set headers may send Authorization: Bearer mq_... on the handshake instead of a ticket. Connections are only accepted from the allowed origins (CORS_ALLOWED_ORIGINS).
Consumer notification:
{ "queue": "email-queue", "message_id": "3f2a9c1e-5b7d-4e8a-9c21-7d4e5f6a8b90" }Monitor event:
{
"eventType": "PUBLISHED",
"queueName": "email-queue",
"detail": "Message 3f2a9c1e-... published to email-queue",
"timestamp": "2026-10-25T10:00:00"
}Event types: CREATED, PUBLISHED, CONSUMED, ACKED, NACKED, REDELIVERED, DEAD, REPLAYED.
Every error has the same shape:
{
"status": 404,
"message": "No Topic found with the name order.placed",
"errors": null,
"response_body": null
}| Status | Meaning | Retry? |
|---|---|---|
204 |
Consume found nothing claimable right now | Poll again later |
400 |
Invalid input: bad name, missing field, malformed JSON | No, fix the request |
401 |
Missing, invalid, expired or revoked credential | No, fix the key or sign in again |
403 |
Valid credential, but its scopes don't allow this | No, an admin must grant the scope |
404 |
Topic, queue or message does not exist | No |
409 |
Conflicts with current state: duplicate name, message not UNACKED, held by another consumer |
No |
413 |
Payload over the size limit | No, send less |
500 |
Unexpected server failure (details are logged, never returned) | Yes, with backoff |
- Java 21, and Maven (or the bundled
./mvnw) - PostgreSQL 16
- Node.js 20.19+ or 22
- Docker, or a compatible runtime such as Rancher Desktop in
dockerdmode: needed for the integration tests and for Docker Compose - A GitHub account, for signing in to the dashboard
GitHub allows one callback URL per OAuth app, so use one app for local development and another for production.
- GitHub → Settings → Developer settings → OAuth Apps → New OAuth App
- Homepage URL:
http://localhost:5173 - Authorization callback URL:
http://localhost:8080/api/v1/auth/github/callback - Register, copy the Client ID, then Generate a new client secret and copy it (it is shown once).
The backend reads its settings from environment variables; nothing secret goes into application.yaml.
| Variable | Example | Purpose |
|---|---|---|
SPRING_DATASOURCE_URL |
jdbc:postgresql://localhost:5432/miniqueue |
Database |
SPRING_DATASOURCE_USERNAME / _PASSWORD |
postgres / ... |
Database credentials |
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET |
from step 1 | Dashboard sign-in |
MINIQUEUE_ADMINS |
your-github-username |
Comma-separated GitHub usernames that get admin |
MINIQUEUE_PUBLIC_VIEWER |
true (default) |
false: only admins may sign in |
MINIQUEUE_DASHBOARD_URL |
http://localhost:5173 (default) |
Where sign-in returns to |
GITHUB_CALLBACK_URL |
http://localhost:8080/api/v1/auth/github/callback (default) |
Must match the OAuth app |
CORS_ALLOWED_ORIGINS |
http://localhost:5173 (default) |
Origins allowed to call the API and open WebSockets |
Create the database (CREATE DATABASE miniqueue;); Flyway creates the tables on first start. Then:
cd backend
export SPRING_DATASOURCE_PASSWORD=your_password
export GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... MINIQUEUE_ADMINS=your-github-username
./mvnw spring-boot:runIn IntelliJ, put the same variables in Run → Edit Configurations → MiniqueueApplication → Environment variables.
cd ui
npm install
npm run devOpen http://localhost:5173 and click Sign in with GitHub. The Vite dev server proxies /api and /ws to localhost:8080.
On the Clients page, register one client per program and copy its key (shown once):
| Client | Scopes | Used by |
|---|---|---|
scripts |
admin |
The load test, the smoke test and the SDK live checks (ADMIN_KEY / MINIQUEUE_ADMIN_KEY) |
prometheus |
metrics |
Prometheus, from monitoring/miniqueue-api-key (git-ignored) |
e.g. shop |
publish:order.placed |
A producer |
e.g. email-service |
consume:email-queue |
A consumer |
Then, with a topic order.placed and a queue email-queue created on the Manage page:
B=http://localhost:8080
SHOP=mq_key_of_shop
MAILER=mq_key_of_email_service
curl -s -X POST $B/api/v1/topics/order.placed/messages -H "Authorization: Bearer $SHOP" \
-H "Content-Type: application/json" -d '{"payload":"{\"orderId\":42}"}'
curl -s -X POST $B/api/v1/queues/email-queue/consume -H "Authorization: Bearer $MAILER"
curl -s -X POST $B/api/v1/messages/<message_id>/ack -H "Authorization: Bearer $MAILER"For real programs, use the Java SDK instead of curl.
docker compose up --build starts PostgreSQL, the backend, the dashboard (nginx, on http://localhost), Prometheus (:9090) and Grafana (:3000). Put the variables above in a git-ignored .env file next to docker-compose.yml, plus POSTGRES_PASSWORD. Sign-in through http://localhost needs its own OAuth app with the callback http://localhost/api/v1/auth/github/callback, and MINIQUEUE_DASHBOARD_URL=http://localhost.
cd backend && ./mvnw test # needs Docker: each class starts its own throwaway PostgreSQL
cd sdk && ./mvnw test # in-process stub servers: no Docker, no broker
cd ui && npm run build # type-checks and builds the dashboard| Test | Proves |
|---|---|
ConcurrencyIntegrationTest |
10 consumers racing over 100 messages never get the same message twice |
TopicQueueIntegrityTest |
Concurrent creates make exactly one topic; names are validated; the payload limit counts bytes |
SecurityIntegrationTest |
Through the real filter chain: 401 vs 403, scopes and wildcards, identity from the key, rotation and revocation |
DashboardAuthIntegrationTest |
GitHub sign-in with GitHub mocked: state check, admin vs viewer, single-use codes and tickets, sign-out, metrics scope |
SDK MiniQueueClientTest, RetryPolicyTest, MiniQueueConsumerTest |
HTTP contract, error mapping, retry backoff, the polling consumer |
Starter MiniQueueAutoConfigurationTest |
@MiniQueueListener discovery, ack/nack, startup failures, instance ids |
IdempotentPublishIntegrationTest |
10 concurrent retries with one key store exactly one message; a reused key with a different payload is a 409; a failed publish frees its key |
# 28 end-to-end checks: auth, scopes, publish → consume → ack, revocation, metrics, CORS, dashboard routes
BACKEND_URL=http://localhost:8080 ADMIN_KEY=mq_key_of_scripts ./scripts/smoke-test.sh
# Burst of parallel publishes and consumes (watch the dashboard's live feed while it runs)
ADMIN_KEY=mq_key_of_scripts ./load-test.shThe SDK's LiveBrokerCheck, LiveConsumerCheck and LiveStarterCheck (in sdk/) run against a live broker with MINIQUEUE_ADMIN_KEY set to the same admin key.
| Workflow | When | What |
|---|---|---|
ci.yml |
Every pull request and push to main |
Backend tests (Testcontainers), SDK tests, dashboard build |
Push to main, all green |
Deploys that exact commit to Render through deploy hooks, waits until /actuator/info reports it, then runs the smoke test |
|
smoke.yml |
Daily at 06:00 IST, and on demand | The smoke test against production |
Render's auto-deploy is off: production only ever runs a commit that passed the tests.
Prometheus scrapes /actuator/prometheus every 15 seconds, authenticating with a metrics-scoped key read from a credentials file:
| Metric | Type | Description |
|---|---|---|
miniqueue_messages_published_total |
Counter | Messages published |
miniqueue_messages_consumed_total |
Counter | Messages consumed |
miniqueue_messages_acked_total |
Counter | Messages acknowledged |
miniqueue_messages_dead_total |
Counter | Messages dead-lettered |
miniqueue_messages_redelivered_total |
Counter | Messages redelivered |
miniqueue_queue_depth{queue="..."} |
Gauge | Pending messages per queue |
Grafana at http://localhost:3000 connects to Prometheus automatically when running with Docker Compose.