Skip to main content

Documents ZigTSDB v1.5.0 · applies to the 1.5.x line

Dispatcher

A stateless router in front of a cluster: one address for clients, writes split by metric to the right group leader, queries routed to a replica that holds the data.

What it is

A cluster is complete without a dispatcher. Consensus runs node to node; a client can write to any node and follow its leader_url hint, or turn on write forwarding so any node accepts the write. The dispatcher exists for the cases where that is not enough:

  • One address. Clients see a single endpoint instead of a node list and leader hints.
  • Fan-in off the storage nodes. Heavy client concurrency lands on the dispatcher instead of competing with storage for the same CPU.
  • Subsetted clusters. When each node holds only some of the groups, no single node can answer every query; the dispatcher routes each query to a replica that holds the metric's group.

It keeps no data and no state beyond its view of the cluster's topology, which it refreshes from the nodes at most once per second and adopts only when the epoch moves forward. Losing a dispatcher loses nothing; start another.

Running it

The dispatcher is the same image as the nodes, started with a different command:

$ docker run --rm -p 8090:8090 \
    -e STORAGE_NODES=http://node0:8080,http://node1:8080,http://node2:8080 \
    -e TSDB_DISPATCHER_FORWARD_TOKEN="$TSDB_RAFT_ADMIN_TOKEN" \
    ghcr.io/samisoftb/zigtsdb:1.5.0 \
    /app/zigtsdb-dispatcher --port 8090

The binary is /app/zigtsdb-dispatcher. Its default port is 8081; --port (or -p) changes it, and --version (or -V) prints the version and exits. The compose files in this documentation put it on 8090 so it does not collide with the three nodes' host ports. The quickstart gives the compose override that adds it to the 3-node cluster.

Configuration

Everything is an environment variable. A _FILE variant, where listed, reads the value from a file (trimmed of surrounding whitespace, 1–512 bytes) and wins over the plain variable.

Variable Default Meaning
STORAGE_NODES http://localhost:8080 Comma-separated node endpoints, at most 16 (extras are ignored with a warning). https:// endpoints use the TLS material below. Leader hints are followed only to one of these exact URLs.
TSDB_BIND_ADDR 0.0.0.0 Listen address. Bind loopback when a TLS terminator sits in front.
TSDB_DISPATCHER_AUTH_TOKEN
…_TOKEN_FILE
unset (open) Bearer token clients must present on every token-gated route (Authorization: Bearer …, compared in constant time). Unset means every client is accepted. A wrong or missing token answers 401 with an empty body.
TSDB_DISPATCHER_FORWARD_TOKEN
…_TOKEN_FILE
unset Credential the dispatcher presents to the nodes on every forwarded write and topology read. It must match the nodes' dispatcher scope — a scoped token file if the nodes have one, otherwise their shared TSDB_RAFT_ADMIN_TOKEN. Without it a node with internal authentication configured answers 401 and the dispatcher acknowledges nothing.
TSDB_DISPATCHER_CA_CERT
TSDB_DISPATCHER_CLIENT_CERT
TSDB_DISPATCHER_CLIENT_KEY
unset CA bundle to verify https:// nodes, and the client certificate and key to present to them. Verification cannot be switched off; a key without a certificate refuses to start.
TSDB_DISPATCHER_SECURE_MODE unset required makes the dispatcher refuse to start unless a client token, a forward token and a non-wildcard TSDB_BIND_ADDR are all configured. Unconfigured, a dispatcher is an unauthenticated plaintext proxy holding a credential that can write to the whole cluster; this variable is how a deployment says that is not what it meant.

Routes

Paths are matched as prefixes in this order. token means the route requires TSDB_DISPATCHER_AUTH_TOKEN when one is set; open routes never require it.

Method Path Auth What it does
GET /metrics/prometheus open Dispatcher metrics in Prometheus text format (see Observability).
POST /dispatch/ingest, /ingest token Parse line protocol, split it by metric into groups, forward each batch to its group's leader. All-or-nothing on the input.
GET /query token Route a ZQL query to a replica of the metric's group and return its answer verbatim.
GET /health open {"is_healthy":true,"received":N,"forwarded":N}
GET /stats open {"total_received":N,"total_forwarded":N}

A path that matches no route answers 404 {"error":"not_found"}; a method the route does not accept answers 405 with an Allow header. The unauthenticated scrape surface discloses nothing about the cluster: the node label in the metrics is the index into STORAGE_NODES, never a URL.

Writes

Send line protocol to /ingest (or /dispatch/ingest), one point per line, at most 2 MiB and 50 000 points per request. The dispatcher parses every line, groups the points by the group their metric maps to, and forwards one batch per group to that group's leader — following a leader hint once if the node it asked has stepped down. The response tells you exactly what happened to the input:

Condition Status Body
Every line usable, every point forwarded 200 {"status":"ok","total":N}
A line is not valid line protocol 400 {"error":"malformed_line_protocol","rejected":R,"stored":0}
More than 50 000 points in one request 413 {"error":"too_many_points","limit":50000,"excess":E,"stored":0}
Body larger than 2 MiB 413
Accepted, then a node was unreachable or its circuit breaker open 503 {"status":"partial","total":N,"dropped":D}

The two failure classes call for different remedies. 400 and 413 mean the client must change the body before retrying — a partially usable body is never partially ingested, so nothing was stored. 503 means the same body is worth retrying: the input was valid and a node could not take it. A point is therefore either stored or named in the response, never silently absent.

The dispatcher re-serialises each point as metric value=<float> <timestamp>. Tags are part of the metric token and survive; any field other than value= is discarded.

A node that stops answering trips a circuit breaker after three consecutive failures and is skipped for ten seconds before it is tried again; while the breaker is open, points routed to that node are the dropped count in a 503.

Queries

GET /query?zql=… takes the same ZQL as a node. The dispatcher reads the first metric in the statement, resolves its group, and sends the query — body unchanged — to one replica of that group, walking the replica list in placement order from the current leader and failing over to the next replica if one does not answer. A statement that JOINs several metrics goes whole to the node owning the first metric.

There is no fan-out and no merge, by design: writes are replicated by consensus, so every replica of a group holds the same data, and summing across replicas would multiply every result by the replication factor. If every replica of the group is down, the answer is 503 {"error":"partial_result","failed_nodes":F,"responded":0}.

Observability

/health and /stats are the two counters most people need: received is points accepted from clients, forwarded is points a node acknowledged. /metrics/prometheus exposes the full set:

dispatcher_requests_total
dispatcher_forwarded_total
dispatcher_route_epoch
dispatcher_topology_epoch
dispatcher_topology_groups
dispatcher_group_routed_points_total{group="…"}
dispatcher_group_leader{group="…",node="…"}
dispatcher_route_refresh_failures_total
dispatcher_leader_hint_retries_total
dispatcher_circuit_breaker_state{node="…"}
dispatcher_circuit_breaker_failures_total{node="…"}

Two of these are worth an alert. dispatcher_route_refresh_failures_total rising means the dispatcher cannot read the cluster's topology from any node and is routing on a stale view; dispatcher_circuit_breaker_state at 1 for a node means that node is being skipped. The breaker transitions are also logged, one line per change.