MiniTrue is a distributed time-series database built in Go for IoT-style telemetry workloads. It combines leaderless cluster membership, deterministic partitioning, compressed local storage, distributed aggregation queries, and a React UI for querying and live monitoring.
The current codebase runs as a three-node local cluster by default:
polaris: HTTP:8080, TCP:9000sirius: HTTP:8081, TCP:9001vega: HTTP:8082, TCP:9002
All three nodes are symmetric peers. There is no permanent coordinator, no central metadata service, and no required master node.
MiniTrue is designed around four core ideas:
-
Leaderless cluster membershipNodes discover each other through peer-to-peer gossip over TCP. Any node can join by contacting known peers. -
Deterministic data placementRecords are mapped to owners using a consistent hash ring with virtual nodes. Each key resolves to an ordered preference list of peers. -
Fast local analyticsData is stored in chunked in-memory series with pre-aggregated chunk metadata, then persisted to a compressed custom columnar file format. -
Distributed query executionAny reachable HTTP node can accept a query, fan it out to the responsible peers, and merge the returned aggregate stats.
Sensors / Simulator / Arduino Serial
|
v
MQTT Broker (:1883)
|
v
+-------------------------------+
| MiniTrue cluster |
| |
| polaris sirius vega |
| :8080 :8081 :8082 |
| :9000 :9001 :9002 |
| |
| gossip + hash ring + storage |
+-------------------------------+
|
v
React frontend / curl / tools
Membership: peer-to-peer gossip via TCP.Placement: CRC32-based consistent hashing with150virtual nodes per physical node.Replication view: the code currently queries and routes writes using a preference list of size2.Query coordination: temporary and request-scoped only. The node that receives a request coordinates that request and nothing more.Bootstrap: in local development, nodes auto-discover peers from the known local TCP port set if--seedsis omitted.
When you run go run cmd/minitrue-server/main.go -mode=all with no explicit --node_id, the server auto-assigns the first free local slot:
| Node ID | HTTP | TCP |
|---|---|---|
polaris |
8080 |
9000 |
sirius |
8081 |
9001 |
vega |
8082 |
9002 |
That is why the cluster scripts can start all three nodes with the exact same command.
Publisher -> MQTT topic iot/sensors/{metric}
-> every node receives the message
-> each node computes the preference list for device_id:metric_name
-> primary owner persists as primary
-> next preferred node persists as replica
-> primary batches records in memory
-> batch flush writes compressed columnar data to disk
- Every node subscribes to
iot/sensors/#. - Incoming payloads are expected to contain:
device_idmetric_nametimestampvalue
- Routing key:
device_id + ":" + metric_name - Primary records are appended to the disk write batch.
- Replica records are kept in memory for query availability but are not added to the primary disk batch path.
- Batch size is
10primary records. - A periodic flush runs every
5seconds if the batch is non-empty.
Client -> POST /query on any healthy node
-> receiving node resolves owners from the hash ring
-> parallel POST /query-aggregated to responsible peers
-> local node also executes the same aggregate lookup
-> stats are merged
-> avg / sum / min / max is returned
- Main endpoint:
POST /query - Internal fanout endpoint:
POST /query-aggregated - Sample endpoint:
POST /query-samples - If distributed fanout fails, the query handler falls back to a local aggregate query.
- Query time is reported back as
duration_ns.
Client -> POST /delete
-> remove series from in-memory map
-> filter pending primary batch
-> read on-disk records
-> rewrite file without matching device_id + metric_name
-> delete file entirely if no records remain
Delete is handled in place. The current implementation does not restart the node after deletion.
MQTT -> backend websocket hub -> /ws on any node -> React real-time monitor
- The frontend rotates across
ws://localhost:8080/ws,ws://localhost:8081/ws, andws://localhost:8082/ws. - On disconnect, it retries another node after
3seconds. - The monitor keeps up to
100recent data points in the UI and can render a temperature graph.
MiniTrue stores time-series data in two layers:
-
In-memory series- Keyed by
device_id|metric_name - Each series contains chunk objects
- Each chunk stores:
StartTimeEndTimeSumMinMaxCount- raw
Samples
- Keyed by
-
On-disk columnar file- One file per node, for example
data/polaris.parq - Custom file format written by
internal/storage/storage_engine.go - Columns:
timestampvaluedevice_idmetric_name
- One file per node, for example
The storage layer uses chunk pre-aggregation:
- If a chunk is fully inside the requested time range, aggregate stats are read in
O(1)from chunk metadata. - Only boundary chunks fall back to binary search plus sample scanning.
- Raw sample queries use binary search within each relevant chunk.
The on-disk format uses custom compression helpers from internal/compression/gorilla.go:
- timestamps: delta-of-delta style integer compression
- values: Gorilla-style XOR floating point compression
The main server supports:
--mode=ingestion--mode=query--mode=all
all is the normal local development mode.
go run cmd/minitrue-server/main.go [options]| Flag | Purpose |
|---|---|
--mode |
ingestion, query, or all |
--node_id |
explicit node identity |
--port |
HTTP port override |
--tcp_port |
TCP gossip port override |
--broker |
MQTT broker URL |
--data_dir |
storage directory |
--seeds |
comma-separated peer TCP addresses |
Notes:
- If you set
--portor--tcp_port, you should also set--node_id. - If
--seedsis omitted in the default local setup, the node automatically tries the other local peer ports.
The React frontend lives in frontend/src/App.js and has two main tabs:
Query DataReal-Time Monitor
The query form supports:
- device selection
- metric selection
- aggregate operation selection
- quick time presets:
- last hour
- last 24 hours
- last week
- all data
- manual 12-hour timestamp input with validation
- delete-all-data for a selected
device_idandmetric_name
The delete UX is implemented as a guarded confirmation flow with success and failure dialogs.
HTTP failover is implemented in frontend/src/clusterClient.js:
- request order:
8080 -> 8081 -> 8082 - retries continue when:
- the node is unreachable
- the node returns a
5xx
- this is used for query and delete actions
The live monitor:
- opens a websocket to one local node at a time
- auto-fails over to the next node if a connection cannot be established
- shows:
- connection status
- total message count
- messages per second
- recent data feed
- optional temperature graph
Accepts:
{
"device_id": "sensor_1",
"metric_name": "temperature",
"operation": "avg",
"start_time": 0,
"end_time": 0
}Rules:
device_id,metric_name, andoperationare required.operationmust be one of:avgsummaxmin
start_time == 0means unbounded start.end_time == 0means unbounded end.
Returns:
{
"device_id": "sensor_1",
"metric_name": "temperature",
"operation": "avg",
"result": 23.47,
"count": 1543,
"duration_ns": 2847293
}Accepts the same request shape and returns raw sample values:
{
"samples": [22.1, 22.4, 22.6]
}Internal aggregation endpoint used during distributed fanout. It returns sum, count, min, and max stats for a local store.
Accepts:
{
"device_id": "sensor_1",
"metric_name": "temperature"
}Behavior:
- removes the selected series from memory
- filters pending primary write batches
- rewrites the node file without matching records
WebSocket endpoint for live sensor stream updates.
Returns websocket hub metadata such as connected client count and service status.
Topic pattern:
iot/sensors/{metric_name}
Example message:
{
"device_id": "sensor_1",
"metric_name": "temperature",
"timestamp": 1710000000,
"value": 24.2
}- Go
1.21+ - Node.js and npm
- an MQTT broker on
tcp://localhost:1883
go mod download
cd frontend
npm install
cd ..Linux / macOS:
chmod +x run_cluster.sh
./run_cluster.shWindows:
.\run_cluster.batThe scripts:
- clean ports
8080,8081,8082,9000,9001,9002 - start three symmetric MiniTrue nodes
- start the simulator publisher
- start the React dev server
Terminal 1:
go run cmd/minitrue-server/main.go -mode=allTerminal 2:
go run cmd/minitrue-server/main.go -mode=allTerminal 3:
go run cmd/minitrue-server/main.go -mode=allPublisher:
go run cmd/publisher/main.go --sim=trueFrontend:
cd frontend
npm startgo run cmd/minitrue-server/main.go \
--mode=all \
--node_id=node_a \
--port=8090 \
--tcp_port=10090 \
--seeds=localhost:9000,localhost:9001curl -X POST http://localhost:8080/query \
-H "Content-Type: application/json" \
-d '{
"device_id": "sensor_1",
"metric_name": "temperature",
"operation": "avg",
"start_time": 0,
"end_time": 0
}'The same request can be sent to :8081 or :8082.
mosquitto_pub -t iot/sensors/temperature \
-m '{"device_id":"sensor_1","metric_name":"temperature","timestamp":1710000000,"value":23.5}'cmd/
minitrue-server/ main cluster node entrypoint
publisher/ simulator and Arduino serial publisher
internal/
cluster/ gossip, hash ring, Merkle sync, message handling
compression/ Gorilla-style compression helpers
ingestion/ MQTT subscriber and write routing
logger/ terminal log formatting
models/ shared structs
mqttclient/ MQTT wrapper
network/ TCP client and server
query/ HTTP API and distributed query logic
storage/ in-memory series and on-disk file engine
websocket/ websocket hub for live monitor
frontend/
src/ React app, cluster-aware client, query UI, live monitor
run_cluster.sh local automation for Unix-like systems
run_cluster.bat local automation for Windows
- The project is
leaderless, but each individual key still has a deterministic primary owner and ordered preference list. - The current gossip protocol is peer-to-peer and state-based. It is not backed by Raft, etcd, or an external consensus service.
- The current UI tries
/devicesand/metrics, but the backend does not currently expose those endpoints, so the frontend falls back to built-in defaults. - The current simulator publishes only
temperaturereadings forsensor_1,sensor_2, andsensor_3. - The in-memory live monitor is cluster-aware at the client level, not globally load-balanced by a reverse proxy.