Deployment¶
For the full list of parameters accepted by Turbine(...) and @app.subscribe(...), see Configuration. This page focuses on operational concerns: deployment topology, performance tuning, and rolling upgrades.
Packaging an app¶
A Turbine app is an ordinary Python process. The engine, the state store, the REST API and the console all live inside the installed package — there is no cluster manager to stand up and no job to submit. Deploying means shipping your own program plus its dependencies, and running it.
Two constraints shape the image. The interpreter must be free-threaded, and there is
no official free-threaded CPython base image, so the shortest path is an image that has
uv and lets it install the interpreter your project pins. And the version should be
pinned: Turbine is alpha, and an unpinned pre-release resolves to whatever is newest
the day the image happens to be rebuilt.
# pyproject.toml
[project]
name = "my-pipeline"
requires-python = ">=3.14"
dependencies = ["turbine-fast==0.2.0a2"]
[tool.uv]
prerelease = "allow" # a property of the project, not of one command line
FROM ghcr.io/astral-sh/uv:debian-slim
WORKDIR /app
COPY pyproject.toml uv.lock .python-version ./
RUN uv sync --frozen --no-dev
COPY app.py ./
ENV TURBINE_API_PORT=8400
EXPOSE 8400
CMD ["uv", "run", "--frozen", "--no-dev", "python", "app.py"]
.python-version holds 3.14t; uv sync downloads that interpreter and resolves the
cp314t wheel. --frozen on both commands is what stops the container from
re-resolving against PyPI every time it starts — a startup that reaches the network is a
startup that can fail for reasons unrelated to your app. Expect an image in the 400–500 MB
range: pyarrow and the compiled engine dominate it.
Stopping it. Turbine shuts down on SIGTERM as well as SIGINT — it finishes the
batch in flight and writes a final checkpoint. That is what docker stop and a
Kubernetes pod termination send, and it reaches the app through uv run. Give it a
termination grace period comfortably longer than one batch plus one checkpoint (how to
size it: Sizing the shutdown); killing it earlier is
a crash, and costs you the recovery path instead of the shutdown one. In a
cluster, a stop is not a departure: the node stays a member and its partitions wait
for it (see When a node goes down). To take a node out for good,
see Removing a node.
What the container needs. A reachable broker; the API port published if you want the
console or Prometheus; and — for anything that can be rescheduled onto another host — a
checkpoint_url on shared storage, since state_dir is a working directory, not
durable storage. Configuration can come from the environment rather than code: see the
environment variable summary and, for
object storage, the AWS_* variables below. Wire
/health and /ready to the liveness and readiness probes — see Health
probes, which explains why they must not be the same one.
Deployment Modes¶
Standalone (single node)¶
No extra configuration needed. One process handles all partitions.
The same kafka handle builds every input=/output= topic reference, so the
cluster address appears once — see Configuration for the
full constructor.
When this process can move to another host, configure checkpoint_url on shared
storage and keep the same URL across deployments. Turbine stores subscription
identities there alongside worker checkpoints, so changing declaration order or
removing a subscription does not change surviving workers' recovery paths after
the local working directory is lost. A checkpoint URL/prefix belongs to one
application history; only one standalone process may run that history at a time.
Raft cluster (dynamic)¶
Nodes coordinate via embedded Raft consensus. Partition assignment is replicated and rebalanced automatically.
Every node gets the same list of peers — every node of the cluster, itself included — and the same command serves its first start and every restart:
kafka = KafkaBroker(bootstrap="kafka:9092")
app = Turbine(
kafka,
raft_node_id=1, # 2 and 3 on the other nodes
raft_listen_addr="http://10.0.0.1:8400", # this node's own address
raft_peers=[(1, "http://10.0.0.1:8400"),
(2, "http://10.0.0.2:8400"),
(3, "http://10.0.0.3:8400")],
)
All Raft parameters have TURBINE_RAFT_* environment variable equivalents:
export TURBINE_RAFT_NODE_ID=1
export TURBINE_RAFT_LISTEN_ADDR=http://10.0.0.1:8400
export TURBINE_RAFT_PEERS="1=http://10.0.0.1:8400,2=http://10.0.0.2:8400,3=http://10.0.0.3:8400"
# optional: how long a node that goes down keeps its partitions (default 60000)
export TURBINE_RAFT_REBALANCE_DELAY_MS=60000
How a node finds its cluster. At startup a node asks its peers about their cluster:
- The very first start forms the cluster once every peer has answered that it has
none: the node with the lowest id forms it, and the others join it. Until every peer
answers, nothing is formed — start the nodes together (on Kubernetes,
podManagementPolicy: Parallel, see Kubernetes). A cluster is never formed on silence: a node that cannot tell whether a cluster exists waits, rather than risk two clusters side by side. If a peer will never answer (a host that is gone for good), tell the lowest-id node to form the cluster without it: It is refused once the node is part of a cluster, and a cluster found among the peers still wins: the node joins it instead. But only a cluster it can reach stops it: sent while a running cluster is merely unreachable from this node (a network split, a firewall rule), it forms a second cluster beside it, and both then process the same partitions. Use it only when you know no cluster is running. - Adding a node to a running cluster: give it a list with the existing nodes and itself. It finds the cluster through them and joins it. The other nodes' lists do not need to change for that; update them at their next restart so they stay accurate. The new node first copies the cluster's history without a vote. It becomes a voter only after its subscription names, topics and order match the cluster's declaration. A refused new node therefore leaves the existing voting quorum unchanged. A node already in the voter set keeps voting while its application configuration is checked on restart. If startup is refused, open the console's Nodes view on a surviving node to read the retained reason. Correct the startup error and restart that node; successful admission clears the refusal. If the cluster could not retain it, inspect the failing node's startup logs.
- A node that left (
/cluster/leave) and is started again joins back on its own: starting a node means wanting it in the cluster. To take a node out for good, leave and stop it (see Removing a node).
Use the same list on every node. A node that does not list some of its peers may form a cluster of its own with the ones it knows.
Keep each node's state directory. A node keeps its cluster membership —
its Raft vote and log, and the replicated assignment table — in state_dir,
next to its partitions' state. Put state_dir on storage that survives a
restart (a persistent volume, not the container's filesystem):
- A node restarted on its directory resumes as the member it was, whether it was stopped or crashed. The cluster restarts the same way after a full outage: membership and assignments are read back, nothing is rebuilt from scratch.
- A restarted node starts no partition until it has caught up with the cluster. What its directory remembers is how the cluster looked when it stopped; a partition moved meanwhile is never resumed from that memory. A node that cannot reach a leader therefore waits, and runs nothing.
- Each node needs its own directory. A node refuses to start on a directory another node id created — check the node id and the volume mounted on that node.
- If a node's directory is lost while it is still a member, start it again as usual: it sees that the cluster still counts it while it remembers nothing, takes no part in elections, has itself removed and joins again as a new member. A node that voted with no memory of its earlier votes could break the cluster's consistency; this is what prevents it.
Protect the API with a token. Nodes talk to each other over the same API
port, so it must be reachable between them — and without a token, anyone else
who reaches it can read state and change membership. Give every node the
same TURBINE_API_TOKEN, from your secret store (a Kubernetes Secret, for
example):
A node started with a different token cannot join (the error names the token),
and the cluster views list it as unauthorized. Changing the token means
restarting every node. See Securing the API for what
the token covers and what it does not (it is not encryption).
When a node goes down¶
A node can go down two ways: it is stopped (SIGTERM: a rolling update, a drain, a
rescheduled pod) or it crashes (an OOM kill, a lost host, a SIGKILL). Either way it
stays a member of the cluster, and its partitions wait for it for
raft_rebalance_delay_ms (default 60 000, env TURBINE_RAFT_REBALANCE_DELAY_MS):
- Back within the delay: it resumes its own partitions where it left off. Nothing moved, nothing was downloaded.
- Still down after the delay: its partitions move to the other nodes. When it comes back, it gets a share again.
- Meanwhile nobody consumes those partitions. Their lag grows, and event-time
windows on them stop closing until someone resumes them.
/cluster/nodesshows the node as waiting, with the time its partitions move (see the rebalance view), and theturbine_partitions_awaiting_rebalancegauge counts them.
A stop counts from the moment it happens; a crash counts from the last time the node answered. A node that is the leader hands its role to another node before it stops, so the cluster does not pause for an election.
Choosing the delay is a trade between two costs. A long delay keeps restarts cheap but
leaves a dead node's partitions unconsumed for that long. A short one reacts quickly but
moves partitions on every restart that takes longer, and moves them a second time when
the node comes back. Size it to how long one of your restarts takes (pulling the image
plus starting). 0 moves them as soon as the node is seen down, about two seconds after
its last answer.
With processing_guarantee="exactly_once", a partition that changes owner waits for its
previous owner to confirm the handover. A stopped node confirms on its way out. A crashed
node cannot, so its partitions stay blocked on their new owner until the crashed node
comes back (it confirms then, for the process that died) or until you release them by hand
(Recovering a stalled partition handoff).
With processing_guarantee="at_least_once", the new owner starts without waiting for
the previous owner to finish. Both may process the partition and publish outputs
until the previous owner stops; downstream consumers must tolerate duplicates.
Stateful checkpoints keep each owner's file contents separate when uploads
overlap. A draining worker can still publish its final checkpoint while it
holds its claim. A released claim or a newer published epoch fences late
publications. The checkpoint store can retain bytes from a refused upload
until a later collection.
Revoked workers receive their stop requests together and finish their current work
and final checkpoints concurrently. A node stop during that handover also signals
all its remaining workers immediately. Draining still takes time: an idle worker
may wait out its batch timeout, and processing and checkpoint uploads must finish.
Size the pod's shutdown grace period for that work, including shared upload bandwidth.
If startup fails or is cancelled before application workers are launched, the node relinquishes its unstarted assignments and the other nodes take over without a manual release. They still wait for any earlier owner that is finishing a handover. A hard kill before startup cleanup, or a loss of quorum that prevents that cleanup from committing, retains the crash-recovery behavior above.
A new stateful worker in cluster mode needs a Raft quorum to authorize its assignment and obtain approved recovery checkpoints. Exactly-once stateless workers also need authorization. At-least-once workers still start without waiting for the predecessor to finish. When Raft is unavailable, an ordinary stateful worker that already completed recovery can restart at the same epoch from valid local state. Exactly-once still validates that state against the broker receipt before a local-only retry. Each worker releases its own assignment after draining. A successor can finish first while the predecessor still holds its claim. Cancellation retries transient coordination errors for up to five seconds. If the outcome remains unknown, the worker exits without accessing Kafka or releasing an unproven handoff.
Removing a node¶
A stopped node still counts as a member, so a node you take out for good — scaling down, decommissioning a host — has to leave first. Otherwise the cluster keeps waiting for it: its partitions move after the delay, but it still counts in the quorum, so the cluster tolerates one failure fewer. Ask the node itself to leave, then stop it:
curl --fail-with-body -X POST http://10.0.0.3:8400/cluster/leave # the node that leaves, not another one
It hands its partitions over (each one through a final checkpoint) and leaves the voters. To remove a node that is already gone, ask any other node:
curl --fail-with-body -X POST http://any-node:8400/cluster/remove_voter \
-H 'Content-Type: application/json' -d '{"remove_voter_ids": [3]}'
Both operations retain the removed voter as a learner: it no longer votes or receives partitions, but the leader continues attempting to replicate to it. A new node refused during startup can also remain a learner. There is no automatic cleanup after a timeout, so a temporarily unavailable node keeps its membership.
When the node is permanently retired, keep its process stopped and remove its learner membership through any reachable node:
curl --fail-with-body -X POST http://any-node:8400/cluster/remove_learner \
-H 'Content-Type: application/json' -d '{"remove_learner_ids": [3]}'
Add -H "Authorization: Bearer $TURBINE_API_TOKEN" when a token is configured.
A successful removal returns HTTP 200 with {}; a refusal uses an HTTP error status
with the reason in its Problem Details detail field. The operation preserves the
target voting set and refuses the whole request if any id remains a voter in that
set. During a joint membership transition, an outgoing voter already absent from
the target set may be removed; keep its process stopped as above. An already absent
id is harmless, so retries are safe.
Removing a learner stops replication to it. It preserves partition ownership and handoff-release history, worker state and checkpoint pointers; it does not release a stalled handoff or drain a live worker. Use the separate handoff recovery procedure when needed. This is not a ban: starting the removed node again asks to rejoin the cluster, with its existing state and the usual startup admission checks.
Kubernetes¶
Run a cluster as a StatefulSet: each pod gets a stable name its peers can reach, a volume that follows it, and an ordinal that serves as its node id. The manifests, what each setting is for, the shutdown and memory sizing, opening the console, metrics and the day-to-day operations are on their own page: Kubernetes.
Checkpoint storage¶
Each partition keeps its working state in a local RocksDB and periodically
writes a snapshot to the checkpoint store named by checkpoint_url. That
store is what a different node reads when a partition moves to it — on
failover, on a rebalance, during a rolling upgrade.
Hence the requirement: in a cluster, the checkpoint store has to be readable
from every node. The default, file://{state_dir}/snapshots, is not — it is
a directory on one machine. With a node-local store, every partition that moves
loses its state twice over: the new owner finds no snapshot to restore, and
when the old owner gets that partition back later it resumes from a RocksDB
that has been going stale the whole time. This is not "state starts empty", it
is "state is wrong" — a window that under-counts, a cooldown key that has
silently expired, an alert that fires a second time and whose resolution never
comes.
Nothing about that depends on the delivery guarantee. at_least_once promises
no message is dropped; it promises nothing about reloading state. So a
cluster + stateful app refuses to start on a node-local checkpoint store,
whatever its processing_guarantee:
ValueError: Cluster mode + stateful processing requires a shared checkpoint_url
(e.g. 's3://bucket/path'). The current value None is local to this node, so
every partition that moves between nodes loses its RocksDB state: the new owner
has no snapshot to restore, and the old one comes back later with stale state —
wrong results, not empty ones.
Single-node deployments are untouched: nothing moves, local is the correct
answer, and the boot banner marks it (local to this node).
Escape hatch: several processes, one filesystem¶
Running a cluster of processes on a single host — where file:// really is
shared — is the one case the check gets wrong. Set
TURBINE_ALLOW_LOCAL_CHECKPOINT_IN_CLUSTER=1 to proceed; the app starts with a
warning, and the banner switches to
[LOCAL - every partition handoff loses state]. Do not use it across hosts.
S3 and S3-compatible endpoints¶
The store must support and enable atomic conditional writes (If-None-Match).
Turbine checks at startup that creating an existing object is refused without
replacing its contents. An endpoint without this capability cannot safely store
checkpoints and is refused before processing starts.
Manifest timestamps and collected SST generations have permanent retirement markers. Keep these markers: a delayed request can recreate deleted manifest bytes, but the timestamp marker prevents readers from selecting them. Pruned or corruption-purged timestamps cannot be reused.
Credentials and endpoint configuration come from the standard AWS_*
environment variables — there is no Turbine-specific setting for them. The same
variables are how you reach a non-AWS S3-compatible endpoint (OVH, MinIO,
Scaleway, Ceph, Cloudflare R2):
| Variable | Purpose |
|---|---|
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
Static credentials. ~/.aws/credentials and instance roles are also honoured. |
AWS_SESSION_TOKEN |
Temporary credentials. |
AWS_WEB_IDENTITY_TOKEN_FILE + AWS_ROLE_ARN |
Web-identity federation (IRSA on EKS, workload identity). |
AWS_REGION |
Region. AWS_DEFAULT_REGION is accepted too. Compatible endpoints usually want their own region name (gra, us-east-1, …). |
AWS_ENDPOINT_URL |
The endpoint of a non-AWS provider. Unset means real AWS. |
AWS_VIRTUAL_HOSTED_STYLE_REQUEST |
true if the provider requires the bucket in the hostname. Path-style is the default, which is what most compatible endpoints expect. |
AWS_ALLOW_HTTP |
true to allow a plaintext endpoint — a local MinIO, not production. |
OVH Object Storage:
export AWS_ENDPOINT_URL=https://s3.gra.cloud.ovh.net
export AWS_REGION=gra
export AWS_ACCESS_KEY_ID=...
export AWS_SECRET_ACCESS_KEY=...
Local MinIO:
export AWS_ENDPOINT_URL=http://minio:9000
export AWS_ALLOW_HTTP=true
export AWS_REGION=us-east-1
export AWS_ACCESS_KEY_ID=minioadmin
export AWS_SECRET_ACCESS_KEY=minioadmin
The bucket must already exist, and the credentials must be allowed to read and write objects under the URL's prefix. Turbine does not create the bucket.
Checked at boot, watched afterwards¶
A stateful app writes a small object to the store at startup, reads it back and deletes it. If that fails, the app does not start, and the error names what to check. A store you cannot write is a configuration error, and the first real checkpoint is driven by batch count — so without this check you would find out much later, quietly, with state accumulating that nothing is saving.
After startup the same failure is handled the opposite way: the app keeps running. An unreachable bucket is usually an outage that repairs itself, or one someone has to go fix; stopping the pipeline brings it back no sooner and costs you the processing too. What you get instead is state you can alert on:
| Signal | Meaning |
|---|---|
turbine_checkpoint_store_shared |
0 when the store is local to this node. Alert on it together with cluster mode — that pair is the trap above. |
turbine_checkpoint_store_writable |
0 when the store's last answer was a failure. |
turbine_checkpoint_consecutive_failures |
Failures since the last success. |
turbine_checkpoint_failures_total{operation} |
Cumulative failures, split by step (upload, prune, probe, …). |
/health → checkpoint |
The same facts as JSON: destination (credentials redacted), shared, writable, time of the last success, and the last error text. |
After three consecutive failures /health reports status: "degraded" with
reason: "checkpoint_store_failing". It stays 200 on /ready — the node is
still processing, just not saving; taking it out of rotation would not help. It
clears itself as soon as a write succeeds, with no restart.
The real snapshot uploads are the sensor. When they go quiet for a minute — an idle partition never checkpoints, because the cadence follows batches — a small probe keeps the answer current, so "no failures recently" never quietly means "nothing was tried".
Health probes¶
Two endpoints, and the difference between them matters for what you wire a probe to:
| Endpoint | Answers | HTTP status |
|---|---|---|
/health |
Is this process alive? | always 200 while it serves HTTP |
/ready |
Can this node do its job? | 503 when it cannot, 200 otherwise |
Both answer without a token even when the API requires one, so a probe
needs no credentials. Everything else does — including /metrics: give your
Prometheus scrape job a separate metrics-only token (see Scraping the metrics).
Both return the same JSON body, whose status field is the one thing to read:
status |
Meaning | Typical action |
|---|---|---|
healthy |
Participating normally. | — |
degraded |
Working, but part of this node is down — a partition halted (see Error handling), a partition's handler is failing batch after batch, or the checkpoint store stopped accepting writes. | Alert a human; restarting won't help. |
unavailable |
Stopping (reason: "stopping"), or cannot participate: no Raft leader elected, this node is not (yet) a voter, or it has just restarted and not yet caught up with the cluster (it runs no partition until it has — usually a few seconds). |
During shutdown, wait for drainage; otherwise investigate connectivity/quorum if it lasts. |
The second degraded case is the one that hides best: under on_error="skip"
or "dlq" a broken handler leaves the node looking busy — nothing is halted,
offsets keep advancing — while every row it reads is discarded. reason is
processing_errors and the processing_errors list names the partitions, so a
page tells you which ones and since when; see Knowing when a partition has
stopped producing
results.
When status is not healthy, reason names the cause in a fixed vocabulary:
stopping, no_raft_leader, not_a_voter, catching_up, halted_workers, processing_errors,
checkpoint_store_failing, close_backlog. In cluster mode the body also
carries leader_id (null when there is no leader) and voters, so one
request tells you whether the cluster has a quorum — no second endpoint, no
log reading:
# Node up but unable to work: peers unreachable, no leader elected.
curl -s http://10.0.0.3:8400/health
{"status":"unavailable","reason":"no_raft_leader","workers":0,
"node_id":3,"role":"follower","leader_id":null,"voters":3}
# Readiness probe — no JSON parsing needed.
curl -fsS http://10.0.0.3:8400/ready >/dev/null || echo "not ready"
A node with zero workers is not a fault: with fewer partitions than nodes, some nodes legitimately hold nothing. That is why readiness keys on cluster participation rather than on the worker count.
For Kubernetes, wire /health to the liveness probe and /ready to the
readiness probe (the StatefulSet does). Do not point
a liveness probe at /ready: a node without quorum is not fixed by being killed,
and restarting it makes the quorum problem worse.
Recovering a stalled partition handoff¶
Applies to cluster mode with processing_guarantee="exactly_once" only.
When a partition moves from one node to another, the new owner does not start processing immediately: it waits until the previous owner confirms it has finished and stepped away. Starting earlier would take over the partition's identity at the broker while the old owner still has a transaction in flight, and that transaction would be aborted — losing the outputs it had already produced. So the wait is deliberate, and it has no timeout: giving up after a delay would just re-introduce the problem it exists to prevent.
The consequence is the case to know about. If the previous owner dies hard —
power loss, SIGKILL, kernel panic — it never sends its confirmation, and the
new owner waits forever. That partition simply never starts. The rest of the
node keeps working, which is exactly what makes it easy to miss.
How you see it:
| Signal | What it shows |
|---|---|
turbine_handoff_barrier_waiting{sub_index,topic,partition} |
1 while waiting. Seconds during a normal rebalance; minutes means stuck |
| Node logs | A warning every 10 s naming the topic and partition |
turbine_consumer_lag |
Climbing on that partition while others are flat |
How to unblock it. Confirm that every earlier worker for this partition has stopped. The command releases all earlier assignments through the epoch immediately before the current owner, including any still-draining worker. It also removes their protection for checkpoints they may be reading.
Then, from any machine that can reach a cluster node — any node will do:
curl --fail-with-body -X POST http://10.0.0.1:8400/cluster/force_release \
-H "Authorization: Bearer $TURBINE_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"topic": "events", "partition": 3}'
When several subscriptions read that topic, include "sub_index": 1 in the
request (using the index shown in /assignments). The CLI accepts
--sub-index 1. Without a selector, an ambiguous request is refused.
It answers with what it resolved, so you can check it matched the partition you meant:
{"topic":"events","partition":3,"node_id":2,
"worker_id":"work-00-p03","epoch":4,"released_epoch":3}
A node that has just restarted, or that cannot reach the cluster's leader,
answers 503 instead: it will not act on its own possibly outdated view of who
owns the partition. Send the request to another node, or retry once it has
caught up.
If you run Turbine from a source checkout, turbine-cli wraps the same call and
refuses to publish until you pass --yes — so a bare invocation is a dry run
that shows you the partition's state first:
turbine-cli force-release --cluster http://10.0.0.1:8400 \
--topic events --partition 3 # reports, changes nothing
turbine-cli force-release --cluster http://10.0.0.1:8400 \
--topic events --partition 3 --yes # publishes
It reads the token from TURBINE_API_TOKEN (or a .env file); --token also
works, but a flag is visible to anyone listing processes.
The waiting node proceeds within a few seconds; watch the gauge drop back to
0. One run is enough even if several handoffs failed in a row on that
partition.
Do not reach for it as a way to speed up a handoff that is merely in progress: a wait of a few seconds is the normal path, and cutting it short is what causes the loss described above. The command is for a wait that has no end because the other side is gone.
Performance Tuning¶
The batch loop is broken down into phases, each exposed as a Prometheus histogram:
| Metric | Phase |
|---|---|
turbine_poll_seconds |
Waiting for messages from the broker |
turbine_decode_seconds |
Deserialising the batch into Arrow |
turbine_process_seconds |
Your handler (Python callback) |
turbine_produce_seconds |
Encoding + sending to the output topic |
turbine_commit_seconds |
Persisting state + advancing the input offset |
turbine_batch_duration_seconds |
Back-stage time (process → produce → commit) |
turbine_front_wait_seconds |
Time the worker waited for its next batch: poll + decode on serial loops, only the wait not hidden by the previous batch on pipelined loops. Empty polls are excluded |
With a pipelined front stage (one batch ahead), poll and decode overlap the previous batch, and turbine_front_wait_seconds counts only the remaining wait. On serial loops, including replay, subscriptions with several inputs and repartition hops, it counts the whole poll + decode. Empty polls are excluded in every mode. Compare it against turbine_batch_duration_seconds to see how much time is spent waiting for input; the batch duration covers processing, producing and committing, with poll and decode excluded. For repartition hops, processing is key extraction. The pipeline is adaptive: a front-bound worker (front_wait ≈ the whole front) tries the serial loop on its own and resumes the pipeline if a full-batch trial increases processing time per message by more than 5% — watch turbine_pipeline_front_active (1 = pipelined, 0 = serial) and the adaptive pipeline gate switched log line. TURBINE_PIPELINE=0/1 pins the mode.
Compare them on your workload to find the dominant phase before changing anything else.
The main knobs you control from the SDK:
| Knob | Where | What it changes |
|---|---|---|
batch_size / batch_timeout_ms |
@app.subscribe(...) |
Larger batches amortise per-batch overhead (commit, produce, encode); smaller batches lower end-to-end latency. |
parallelism + partition_key |
@app.subscribe(...) |
Scale CPU on a hot Kafka partition without changing the topic — see Partitioning. |
avro_schema vs schema (Arrow or Pydantic) |
kafka.topic(...) input |
Avro can reduce decoding work on decode-bound workloads. An explicit JSON schema can skip undeclared columns; inferred schemas are cached too, so a declaration alone does not imply faster decoding. See input schemas. |
processing_guarantee |
@app.subscribe(...) |
Stay on at_least_once if downstream is idempotent — EOS adds a per-batch transaction round-trip and a durable state flush. See Delivery Guarantees. |
state_format |
Turbine(...) |
"msgpack" is a compact alternative to the default JSON state codec — typically lower CPU for large state values, identical durability. |
Kafka client tuning (producer acks, linger.ms, compression, fetch sizing) is not exposed as a user-facing parameter today — Turbine picks defaults that are appropriate for the batch model.
Rolling Upgrade Procedure¶
First upgrade to HTTP membership errors
The membership API contract changes for node-to-node calls as well as external clients. Stop all nodes, upgrade them together, and restart with their existing state and checkpoints. Do not mix runtimes using the former 200 status envelope with runtimes using HTTP error statuses: an old refusal can be mistaken for success, including during recovery of a node that lost its state. A StatefulSet rolling update is not safe for this upgrade; use the full-stop commands.
First upgrade to startup-refusal diagnostics
These diagnostics add cluster messages that earlier runtimes cannot read. Stop all nodes before upgrading them together, retaining their existing state and checkpoints. Do not mix older runtimes with this runtime or downgrade their Raft stores after these messages have been committed. Existing ownership-format-5 and format-6 stores upgrade in place; upgrades from older formats still require the procedure below.
The rolling procedure below applies to compatible runtimes. A StatefulSet rolling update does not perform the coordinated stop required above; on Kubernetes, use the full-stop commands.
Restart the nodes one at a time, each with the same command and the same state_dir as
before. Stop the node, upgrade it, start it again, then wait until it is back before you
move on to the next one (with an API token set, add
-H "Authorization: Bearer $TURBINE_API_TOKEN" to the call below):
curl http://10.0.0.3:8400/ready # 200 once it is back in the cluster and caught up
curl http://10.0.0.3:8400/assignments # its partitions are back
There is nothing else to do. The node resumes as the member it was, and a leader hands its
role over before it stops. Its partitions wait for it, so keep each restart shorter than
raft_rebalance_delay_ms; otherwise they move to the other nodes and back again. On
Kubernetes, the StatefulSet's rolling update does all of this for you (see
Kubernetes).
Upgrading the cluster ownership format¶
This release upgrades cluster ownership storage to format 8 for ordinary checkpoint recovery and retention. It accepts formats 5 through 7, preserves their Raft stores, worker state and checkpoints, and records format 8 before the node can append new messages. Older binaries refuse the upgraded directory.
Every cluster requires a coordinated full stop for this upgrade, even when no subscription enables resident storage. Stop all nodes, back up every state directory, upgrade all runtime versions, then restart with the original state/checkpoint locations and subscription declarations. Do not mix old and new runtimes in a rolling upgrade or reopen upgraded Raft stores with older binaries. Retain the pre-upgrade backups for any separately planned rollback. On Kubernetes, use the full-stop commands.
Once every node runs the compatible format-8 runtime, normal compatible application updates may use the rolling procedure above.
After the upgrade, the offline turbine-cli rescale command refuses checkpoint histories enrolled in Raft-managed recovery. See Rescaling a topic.
Upgrading from version 4 or earlier still requires fresh Raft stores on every
node. Those stores are refused unchanged. Stop all nodes and move each node's
{state_dir}/_raft directory aside, retaining it as a backup. Re-form the cluster
with fresh Raft stores before restarting. Keep worker state directories and
checkpoint storage in place, and use the original subscription declaration for
that first startup. Older state or checkpoint formats may separately require the
migration described in their release notes.