queen-kafka is a separate binary that speaks the Kafka wire protocol on one port and plain
HTTP to Queen on another. It is a client of Queen like any SDK is: it holds no database
connection, embeds no broker, and stores nothing durable of its own. What a Kafka client can do
through it, and what it deliberately cannot, is the protocol reference.
The two shapes
Beside the broker (self-hosted). The facade runs next to a broker, points QUEEN_URL at it,
and carries one QUEEN_TOKEN for the whole listener. Every Kafka client on that port reaches
Queen as that one identity, so the Kafka port inherits exactly the trust boundary the broker’s
own port has: keep it on the same private network, behind the same perimeter. This is the shape
the compatibility rig runs and the one to start with.
Beside the proxy (Cloud). The facade points QUEEN_URL at a cell’s proxy, turns on TLS and
SASL/PLAIN, and each connection presents its own tenant token as the SASL password. That token
is then the bearer on every call that connection makes, so tenancy, quota, metering and freeze
are the proxy’s, unchanged. QUEEN_KAFKA_FORWARD_SNI_HOST turns the TLS server name the client
dialled into the Host header of those calls, which is how a proxy that routes by hostname
finds the cluster.
The Cloud shape produces and consumes. POST /api/v1/fetch, the route the facade’s Fetch and
ListOffsets both ride, is classified Consume by the proxy (proxy/src/routes.rs), which is
exactly the authority of the pop it stands in for: a consume-scoped API key reaches it, and a
dashboard Viewer who may not pop cannot read every message by offset instead. It is never
quota-blocked, because a block there would strand a consumer at an offset it can never move past
while the backlog it would drain keeps growing. It is also POST and that exact path only, so every
other spelling fails closed. Two things about that route are worth knowing before they surprise
somebody: a fetch is metered as a request and not as a delivery, so Kafka consumption does not debit
the delivery bucket today, and the long poll is maxWaitMs in the body rather than wait=true in
the query, so the proxy’s parked-connection gauge does not see it.
Committed offsets no longer need the kv feature. They ride POST /api/v1/kv, but a batch
that touches only the facade’s reserved qk: key prefix is reclassified Consume at the gateway,
so a consumer commits and reads its offsets on a plan that carries no kv feature at all, and a
tenant over its storage quota can still move its cursor. Refusing that would strand a consumer at an
offset it can never move past while the backlog it would drain keeps growing. A batch that touches
anything else, or one whose body cannot be read, is gated exactly as before.
Build and run
The crate is standalone, so it builds on its own manifest and produces one binary. Running it inside the broker’s own process tree instead is embedded mode.
cargo build --release --manifest-path protocols/queen-kafka/Cargo.toml
QUEEN_URL=http://localhost:6632 \
QUEEN_KAFKA_ADVERTISED_ADDR=kafka.internal.example.com:9092 \
./protocols/queen-kafka/target/release/queen-kafkaEvery value is validated before the listener binds, and a bad one is a fatal boot with a message naming the variable. That is deliberate: a Kafka facade that starts on bad configuration does not fail, it lies. Clients bootstrap successfully and then fail on the connection they were told to make, one step away from the cause.
Two rules hold for every variable below. A value that does not parse is a boot failure and never a silent fall back to the default, because the default is not there to paper over a typo. And a variable that is present but empty or whitespace-only counts as unset, because an empty value in a Compose file or a Helm template is a variable somebody meant to set.
Embedded in the broker
QUEEN_KAFKA_EMBEDDED=true makes the broker spawn and supervise the facade as a child process,
wired to the broker’s own listener over loopback and derived from the address that listener actually
bound, so PORT=0 and a wildcard bind both come out right. One deployment, two processes, one
image.
It is a child and not a library on purpose. The facade is a protocol server with its own accept
loop, its own connection budget and its own decompression arena, and the broker is built with
panic = "abort", so a malformed Produce that walked the decompressor into an allocation failure
would take the broker down rather than one task. A child keeps the blast radius at the facade: it
dies, the broker keeps serving, and the supervisor brings it back on a backoff that doubles from one
second to a thirty second ceiling and resets after an hour of healthy running.
These three are read by the broker, not by the facade:
| Variable | Default | What it is |
|---|---|---|
QUEEN_KAFKA_EMBEDDED |
false |
true spawns the facade. Off, nothing in the supervisor is read and the broker behaves exactly as it always did, which is why the binary can ship in the default image without changing what the default image does. |
QUEEN_KAFKA_BIN |
the queen-kafka file beside the broker executable |
Where the binary is. The default is resolved from the running executable’s own directory at boot rather than written down as a path, and it is what makes the image work with no configuration at all. |
QUEEN_KAFKA_SHUTDOWN_GRACE_MS |
5000 |
How long a stopping child has between SIGTERM and SIGKILL. Floored at 100 ms, because a grace of zero is a SIGKILL with extra steps. Five seconds covers a process mid-syscall: the facade’s shutdown is closing sockets, not draining work, since every offset it holds is already in Queen. |
Every other QUEEN_KAFKA_* variable means exactly what it means when the facade runs alone. The
environment forwards verbatim, QUEEN_TOKEN included, because that is the child’s own credential.
Four of the broker’s secrets are stripped before exec and the facade reads none of them:
PG_PASSWORD, JWT_SECRET, QUEEN_ENCRYPTION_KEY and QUEEN_SYNC_SECRET.
Two mistakes are refused at boot rather than discovered as a crash loop: a QUEEN_KAFKA_BIN that is
not a file, and QUEEN_KAFKA_EMBEDDED=true without QUEEN_KAFKA_ADVERTISED_ADDR. A third is a
warning and not a refusal. With JWT_ENABLED=true and neither QUEEN_TOKEN nor
QUEEN_KAFKA_SASL=plain, every produce and fetch the child makes is answered 401: the hop the
child makes is authenticated like any other client’s, whether it is the loopback the supervisor
injects or a QUEEN_URL an operator set, because a private door into the broker is the kind of thing
that is later found open from somewhere else.
The loopback is the default, not the only answer. An explicitly set, non-empty QUEEN_URL in the
broker’s own environment wins, and the child is given that instead. This is what makes embedded mode
usable in Queen Cloud, where the facade has to reach the broker through the cell proxy; see
In Queen Cloud. An empty QUEEN_URL= is treated as unset, because that is a Helm
template that resolved to nothing rather than an operator’s decision. The boot line says which branch
was taken:
queen-kafka facade started (embedded) pid=18847 bin=/app/bin/queen-kafka
queen_url=http://queen-proxy:6711 queen_url_from="QUEEN_URL (explicit)"queen_url_from is either QUEEN_URL (explicit) or loopback (bound listener). An operator
debugging a hairpin should be able to read which one it is out of the boot log rather than inferring
it from an address.
The child’s stdout and stderr are forwarded into the broker’s log, stripped of escape sequences and
control bytes, truncated at 4 KiB a line, and budgeted at 200 lines per ten second window with a
suppressed count when the budget is spent. GET /status grows a kafka block when embedded mode
is on, and only then: mode, phase, pid, restarts, lastExit, uptimeMs and backoffMs.
What a stop guarantees, and what it does not
The child is put in its own process group, so a stop signals the whole group and a grandchild dies with it rather than being re-parented.
- The broker gets SIGTERM or Ctrl-C: guaranteed. The supervisor runs after the broker’s serve loop drains, sends SIGTERM to the group, and escalates to SIGKILL after the grace window.
- The broker panics or drops the handle: the child is reaped with it.
- The broker is itself SIGKILLed: this is where the platforms differ, and the honest answer is
two answers. On Linux the child carries
PR_SET_PDEATHSIG, so the kernel kills it whatever the parent died of. On macOS and BSD there is no equivalent: a SIGKILLed broker leaves the facade running, re-parented to init and still holding its Kafka port. That is a development machine caveat rather than a production one, and it is stated rather than papered over.
The image
The repository’s Dockerfile builds the facade in a stage of its own and copies it next to the
broker binary in /app/bin. That adjacency is the contract: with QUEEN_KAFKA_EMBEDDED=true and no
QUEEN_KAFKA_BIN, the supervisor resolves the child out of its own executable’s directory, so
embedded mode needs no extra configuration in that image. The same image runs the facade alone
instead, with docker run … queen-mq ./bin/queen-kafka. EXPOSE 9092 is documentation and
publishes nothing on its own.
That image is published: ghcr.io/queen-mq/queen has carried the facade since 1.4.0, on the same
tags as the broker, because it is the same build. The repository ships no Helm chart, so a
Kubernetes deployment is a manifest an operator writes, and cluster mode below has
the shape that one has to have.
Every variable
| Variable | Default | What it is |
|---|---|---|
QUEEN_URL |
http://localhost:6632 |
The broker or proxy the facade calls. Must be an http/https URL; a trailing slash is normalised away. Checked at boot, not on the first metadata refresh. |
QUEEN_TOKEN |
none | Bearer token for that broker. Optional, and never logged: the boot line reports whether there is one, not what it is. With SASL on, a connection’s own token replaces it. |
QUEEN_KAFKA_ADDR |
0.0.0.0:9092 |
The address the Kafka listener binds. host:port, or [v6]:port. |
QUEEN_KAFKA_ADVERTISED_ADDR |
required | The host:port other machines use to reach this process. No default. See below. |
QUEEN_KAFKA_DEFAULT_PARTITIONS |
1024 |
Partition count for a topic the facade auto-creates, and the floor for the width it advertises to topics that declared none of their own. A topic created through CreateTopics with a numPartitions carries that number as its own floor instead, so this knob stops applying to it. Integer in 1 to 100,000. |
QUEEN_KAFKA_MAX_CONNECTIONS |
4096 |
How many connections the listener serves at once. Integer in 16 to 1,000,000. Past it a new connection is closed immediately, which is a client that reconnects rather than a facade accepting work it cannot do. |
QUEEN_KAFKA_TLS_CERT |
none | PEM certificate chain. Both this and the key, or neither. |
QUEEN_KAFKA_TLS_KEY |
none | PEM private key. Read and parsed at boot, so a key that does not match its certificate fails the process and not every client. |
QUEEN_KAFKA_SASL |
none | The only value is plain, case-insensitive. Unset is a listener that authenticates nobody and reaches Queen with QUEEN_TOKEN. It names a mechanism rather than being a boolean so that adding SCRAM later is a value here, not a second variable that can disagree with this one. |
QUEEN_KAFKA_FORWARD_SNI_HOST |
false |
true or false. Forwards the TLS server name as the Host header of that connection’s calls to Queen. Requires TLS: a plaintext connection carries no SNI, so true without a certificate is a fatal boot rather than a knob that quietly does nothing. |
QUEEN_KAFKA_GROUP_JOIN_DELAY_MS |
3000 |
Kafka’s group.initial.rebalance.delay.ms: how long the first join of an empty group waits for company. Integer in 0 to 3,600,000. Zero is legitimate for a single-consumer deployment. |
QUEEN_KAFKA_GROUP_MIN_SESSION_TIMEOUT_MS |
6000 |
Kafka’s group.min.session.timeout.ms. Integer in 1 to 3,600,000. |
QUEEN_KAFKA_GROUP_MAX_SESSION_TIMEOUT_MS |
300000 |
Kafka’s group.max.session.timeout.ms. Integer in 1 to 3,600,000, and never below the minimum: a minimum above the maximum would refuse every consumer INVALID_SESSION_TIMEOUT whatever it asked for, so that pair is a fatal boot. |
QUEEN_KAFKA_TXN_MAX_BYTES |
8388608 |
Staged record bytes one transaction may hold. A transaction is held in memory until its commit, so this is the cap Kafka has no analogue for. Never above the process budget below, which is a fatal boot. |
QUEEN_KAFKA_TXN_MAX_RECORDS |
50000 |
Staged records in one transaction. |
QUEEN_KAFKA_TXN_MAX_STAGED_BYTES |
134217728 |
Staged bytes across every open transaction in this process. |
QUEEN_KAFKA_TXN_MAX_OPEN |
1024 |
Open transactions this process holds at once. |
QUEEN_KAFKA_TXN_MAX_TIMEOUT_MS |
900000 |
The ceiling on a producer’s requested transaction timeout, which is Kafka’s own default for transaction.max.timeout.ms. A one second sweep expires a transaction past its own timeout. |
LOG_LEVEL |
info |
Tracing filter, EnvFilter syntax. |
RUST_LOG |
none | Same, and it wins over LOG_LEVEL. |
QUEEN_LOG_JSON |
false |
true switches the log format to JSON. Any other value is read as false. |
Four more variables belong to cluster mode and three to embedded mode; each is in the table of its own section, because each is inert unless that mode is on.
A consumer asking for a session timeout outside the min/max pair is refused
INVALID_SESSION_TIMEOUT, which is the same contract Apache Kafka’s two settings have. Past a
transaction cap the producer is answered MESSAGE_TOO_LARGE or INVALID_COMMIT_OFFSET_SIZE and has
to abort rather than retry, which is the protocol reference’s subject rather than
this page’s.
The advertised address is the footgun
A Kafka client connects once to a bootstrap address, asks for Metadata, and from then on talks
only to the addresses the broker advertised. Get this wrong and every symptom appears one step
away from its cause: bootstrap succeeds, kcat -L prints a broker list, and every produce and
fetch then hangs or refuses against an address the operator never typed.
There is no default that is right more often than it is wrong, so there is none, and two shapes are refused outright:
- unset, with an error that names the variable, says what it is for, and points at
QUEEN_KAFKA_ADDRas the thing it is not; - a wildcard host (
0.0.0.0,::), because a client handed a wildcard as the address to connect to fails immediately after a successful bootstrap. The wildcard belongs inQUEEN_KAFKA_ADDR, which is what gets bound.
An address with no port, an empty host, a port that is not a TCP port number, and a bare IPv6 address are all refused too, the last one with the bracketed form to use instead.
Set it to what your clients resolve and route to. In Kubernetes that is the Service DNS name and the Service port, not the pod IP.
TLS and SASL
TLS is both files or neither. A half-configured listener that silently served plaintext would be a certificate an operator believes in, which is the failure mode worth failing the boot over. No ALPN protocol is advertised, because Kafka’s wire protocol has no registered ALPN id and no client offers one.
SASL/PLAIN maps a Kafka credential onto a Queen one, bluntly and on purpose:
- the password is the Queen bearer token, verbatim. The same string an SDK would put in
Authorization: Bearer. There is no directory here to look anything up in, and inventing one would be inventing a second credential store beside the proxy’s; - the username is a label. It is logged, which is what makes a connection identifiable without the token being in the log, and it is checked against nothing, because nothing here could check it.
The token is verified by one authenticated call to GET /api/v1/resources/queues when the
connection authenticates. A 401 or 403 from the broker is SASL_AUTHENTICATION_FAILED and
the connection closes, which every client treats as fatal, correctly: no amount of retrying makes
a wrong password right. Anything else, unreachable, a 429, a 5xx, closes the connection with
no error code at all, because that is what every Kafka client has always treated as retriable,
and answering a fatal code while Queen was restarting would make a fleet give up permanently.
Authentication also asks Queen who the credential is, once per credential, with
GET /auth/me. That answer is what the connection’s consumer groups and its cached topic list
are filed under, so that two credentials of one tenant, a key rotation or a per-service key,
share one coordinator instead of running two groups over one set of committed offsets. Queen
answers the question for a bearer only where its identity surface authenticates one: a broker
with JWT_ENABLED unset answers a standalone identity to any caller, while a broker with auth
on and the proxy’s own session endpoint both answer 401 to a bearer. When it is not answered
the credential is filed under itself, which is exactly what the facade did before it asked, and
the facade logs a warning if two such credentials ever run one group id, because those two are
either two tenants, which is fine, or one tenant’s two keys, which is duplicate consumption
nobody can see from here. A failure to ask never fails an authentication.
Before a connection has authenticated it may negotiate versions and it may authenticate, and nothing else: any other API closes the connection with no response, which is what Apache Kafka’s own authenticator does. Pre-authentication frames are capped well below the general frame ceiling.
Onboarding a client
Everything a client changes is its bootstrap address and, in the Cloud shape, its credentials. No library changes, no rebuild.
bootstrap.servers=kafka.example.com:9093
security.protocol=SASL_SSL
sasl.mechanism=PLAIN
sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required \
username="orders-service" \
password="<the tenant's Queen token>";The same two credentials in librdkafka spelling, which covers the Confluent Python, C# and JavaScript clients:
bootstrap.servers=kafka.example.com:9093
security.protocol=SASL_SSL
sasl.mechanisms=PLAIN
sasl.username=orders-service
sasl.password=<the tenant's Queen token>Against a self-hosted facade with no SASL, the whole change is bootstrap.servers. Drop the
four sasl/security lines.
There is no producer property to add and none to turn off. enable.idempotence=false was mandatory
here until 2026-08-29 and is not any more: a stock producer negotiates InitProducerId and gets the
idempotent path, and a producer carrying a transactional.id gets a real transaction, as long as
this facade is not in cluster mode. What a client still cannot do is Kafka Streams, Connect’s
exactly-once source, or a two-phase commit finished by another process; the reasons are
the protocol reference’s.
In Queen Cloud
In Cloud the facade does not talk to a broker. It talks to the cell proxy, and the proxy talks to the broker:
Kafka client --TCP--> queen-kafka --QUEEN_URL--> queen-proxy --> queen
(facade) auth, tenant (broker)
scoping, quotas,
meteringThat is the whole of the difference, and it is one variable. Point QUEEN_URL at the proxy’s Service
rather than at the broker’s. Every Kafka request then crosses the proxy’s authentication, tenant
scoping, quotas and metering, exactly as an SDK’s request does. A facade wired straight to the broker
serves Kafka clients that are not tenants of anything: no quota, no metering, no isolation.
In embedded mode the supervisor injects a loopback address by default, which is right for a single
deployment and wrong for a cell, so an explicit QUEEN_URL on the broker wins over it. That makes the
broker pod call the proxy Service and the proxy call the broker back: a deliberate hairpin, and it
needs a NetworkPolicy that permits it.
This shape is measured, not designed. A real franz-go client produces, consumes, forms groups,
commits offsets, runs admin and runs transactions through a whole cell, and two tenants on one shared
listener cannot see each other’s topics, records, groups or committed offsets. The suite is
protocols/queen-kafka/compat/cloud, its rig stands the entire cell up from nothing, and it was 16 of 16 green
on 2026-08-30.
A Kafka consumer group is also no longer invisible to the people operating the cell. Its committed
offsets live in KV rather than in queen.log_consumers, so the consumer-group views used to show
native groups only; they now read both stores and every row carries a kind of queen or kafka,
with the same lag arithmetic applied to each. The lag a Kafka group reports there matches what
Kafka’s own admin API reports for it. Nothing on that path writes an offset: KV stays the single
source of truth for a Kafka group’s position.
Two things to set that nothing tells you about until every request fails
Neither is visible from the Kafka side. Both are one row or one variable.
The proxy has to know the listener’s host is shared. On a host listed in
QUEEN_PROXY_SHARED_HOSTS the proxy resolves the cluster from the credential; on a host it does not
recognise it resolves nothing. The alternative is a per-cluster hostname to route on, which needs one
facade process per cluster, because the address a facade advertises is per process rather than per
SNI lane (see the advertised address). The consequence is
worth stating plainly: the tenant of a Kafka connection is the tenant of the SASL password, and
nothing else.
The broker has a second, independent KV gate. With QUEEN_TENANCY_HEADER=true, which every cell
runs, the broker derives kv_require_grant, and the absence of a queen.kv_quota row is a
denial rather than a permission. Without one row per broker tenant, every offset commit is refused
403 at the broker, past everything the proxy already allowed:
INSERT INTO queen.kv_quota (tenant_id, enabled) VALUES ('<broker tenant uuid>', TRUE)
ON CONFLICT (tenant_id) DO UPDATE SET enabled = TRUE;The read scope is not optional
This is the single most useful sentence on this page for anyone issuing a Cloud key.
The facade checks a credential by calling GET /api/v1/resources/queues, which the proxy classifies
as a read route. Every Kafka client issues Metadata before anything else, and Metadata is that
queue listing. So a key scoped consume alone is refused 403 at SASL, the connection never opens,
and the client never reaches Fetch at all.
| Client | Scopes it needs |
|---|---|
| Consumer | consume, read |
| Producer | produce, read |
| Transactional producer | produce, consume, read |
| Admin (topic create, delete, alter) | admin, read, plus the verbs it also uses |
The transactional producer’s consume is not a typo. Its transaction marker is a POST /api/v1/kv
batch under the facade’s reserved qk: key prefix, and the proxy classifies a batch that touches
only that prefix as consume rather than as a gated KV write. The same rule is what lets a consumer
commit and read its offsets on a plan that carries no kv feature at all, and what lets a tenant over
its storage quota still move its cursor: refusing that read would strand a consumer at an offset it
can never move past while the backlog it would drain keeps growing.
What a refusal looks like
A 403 from the proxy reaches the client as a Kafka error code and the proxy’s own sentence, in
the response’s error_message field, bounded and stripped of control bytes. The code alone leaves an
operator with no next step.
| Kafka API | Code on a 403 |
Where the reason lands |
|---|---|---|
| SaslAuthenticate | SASL_AUTHENTICATION_FAILED |
error_message, naming the scope and saying it is not a bad password |
| Metadata, Fetch, ListOffsets | TOPIC_AUTHORIZATION_FAILED |
per partition; Fetch and ListOffsets carry no message field |
| Produce | TOPIC_AUTHORIZATION_FAILED |
error_message, Produce v8 and up |
| CreateTopics, DeleteTopics | TOPIC_AUTHORIZATION_FAILED |
error_message |
| AlterConfigs, IncrementalAlterConfigs | TOPIC_AUTHORIZATION_FAILED |
error_message |
| OffsetCommit, OffsetFetch, group APIs | GROUP_AUTHORIZATION_FAILED |
the log; these responses have no message field |
A 429 is different in kind and is never an error: it becomes throttle_time_ms on Produce, Fetch
and Metadata, carrying the proxy’s own Retry-After clamped to thirty seconds. Every Kafka client
obeys that natively and reports nothing to the application, which is the point. The offset APIs are
deliberately left out of that mapping, because a consumer that sleeps its commit sleeps its whole
poll loop.
Kafka deliveries are not message metered
A Kafka Produce is metered like any other push: it books messages and bytes. A Kafka Fetch books a request and zero messages. A tenant consuming a million records through the Kafka wire is billed for the requests that carried them, not for the records. That is a pricing decision rather than a gap, and it is stated here so nobody discovers it from an invoice.
One more thing an operator should know rather than find out: a long polling Fetch takes no parked
slot at the proxy, so the gauge that shows consumer pressure reads zero however many Kafka consumers
are waiting. The facade clamps its own wait to thirty seconds and the proxy’s upstream timeout
defaults to thirty five, which is five seconds of headroom. Do not shrink
QUEEN_PROXY_UPSTREAM_TIMEOUT_MS below 35000 while Kafka consumers are long polling.
What is still undecided
The Cloud path works and is measured. Three things about it are choices nobody has ratified, and they are listed here rather than left to be inferred from behaviour:
| Undecided | Where it shows |
|---|---|
The two spellings of “commit an offset” travel through different route classes: a plain commit is a qk:-prefixed KV batch reclassified Consume, a transactional one is a POST /api/v1/transaction classified Produce. Both work. |
A transactional offset commit is metered as a push; its non-transactional twin is not. |
| A Fetch is metered as a request rather than as a delivery. | The invoice, unless it is read here first. |
| The parked-pop gauge is native-only. | An operator watching consumer pressure sees zero Kafka consumers, however many are long polling. |
There is one documentation gap of the same vintage: the three consumer-group views now return an
additive kind field of queen or kafka on every row, and
their reference page does not describe it yet.
What the boot log says
One line, and it is the one to compare against what the clients actually use:
queen-kafka starting queen_url=… authenticated=true listen=0.0.0.0:9092
advertised=kafka.example.com:9092 default_partitions=1024 group_join_delay_ms=3000
tls=true sasl=plain forward_sni_host=true node_id=0 cluster=queennode_id=0 is the single-node identity and can be nothing else, so that one field says both whether
this facade is clustered and which node it is.
authenticated is whether there is a token, never the token. No credential reaches the log at
any level, and the SASL username is there precisely so a connection can be identified without
one.
Ceilings that are not configurable
These are compiled in. They are here because they explain a refusal an operator will otherwise have to guess at.
| Ceiling | Value | What hits it |
|---|---|---|
| Request or response frame | 100 MiB | A frame above it cannot be encoded, so the answer is bounded before it is built rather than after. |
| Pre-authentication frame | 64 KiB | An unauthenticated peer cannot make the facade allocate. |
| Fetch response | 32 MiB | Partitions fill in request order until it is gone; the rest are answered as empty reads with their real watermarks, which is a Kafka broker’s own soft-limit behaviour. |
| Decompressed produce batch | one frame’s worth | Compression buys a client no more room than it already had. A request costs the sender what it costs the facade. |
| Advertised partitions per topic | 100,000 | A wider Queen queue stays fully readable natively; the lanes above the clamp are not addressable by a Kafka client. |
| Partitions in one all-topics listing | 200,000 | kcat -L against a cell with tens of thousands of queues. Topics past the budget are omitted with a log line, because a short listing is something a client can act on and a connection reset is not. |
| Topics auto-created per Metadata request | 100 | The 101st is LEADER_NOT_AVAILABLE, the client retries, and the next request creates the next hundred. |
| Consumer groups | 10,000 | Past it a new group is COORDINATOR_NOT_AVAILABLE, which every client retries. |
| Group id length | 255 characters | The same bound the broker puts on a queue name. Longer is INVALID_GROUP_ID. |
| Idle connection | 10 minutes | With a 30 second ceiling on a half-received frame. |
| One call to Queen | 10 seconds | Below every client’s 30 second request.timeout.ms, so the client’s own timer is never the first to fire. |
| Partitions in one transaction | 200 | Derived from the commit’s own shape rather than chosen, and not settable. |
| Offsets in one transaction | 62 | The same: the key/value bundle’s operation ceiling, less the fence and the group index. |
Restarting it
A facade restart is a broker restart and needs no ceremony. Group membership is in memory and
dies with the process; committed offsets are in Queen and outlive it. A restarted facade knows
no members, answers UNKNOWN_MEMBER_ID to the first heartbeat of every survivor, and they
rejoin and resume from where they had committed. That is the sequence a real broker failover
produces, and every Kafka client already implements it.
More than one facade in front of one Queen deployment needs one more decision, because two facades that each advertise themselves as the only broker will split a group between them. Either give each facade its own set of consumers, or turn on cluster mode.
Cluster mode
One facade needs none of this and is unchanged by all of it. With QUEEN_KAFKA_NODE_ID unset,
nothing in the cluster code is read, spawned, allocated or written, and the bytes on the wire are
what they always were. That is asserted rather than assumed: the facade’s own acceptance suite runs
its whole body against a facade with the cluster configuration absent.
Two facades in front of one Queen deployment already share everything durable, because the committed
offsets are in Queen’s key/value store and the log is in PostgreSQL. What they did not share was who
arbitrates a group, and that produced two defects. Every facade answered FindCoordinator with
itself, so one group formed twice and each generation assigned every partition; and an offset commit
was an unconditional upsert, so the loser of a race silently overwrote the winner, leaving a commit
of 16 on top of a commit of 50. Setting QUEEN_KAFKA_NODE_ID on each facade turns the group RPCs
into a redirect to the one node a shared rendezvous hash names as owner. The redirect is
NOT_COORDINATOR, which every client answers by re-running FindCoordinator, and the commit itself
carries a compare-and-set fence for the window in which a node’s view is stale.
| Variable | Default | What it is |
|---|---|---|
QUEEN_KAFKA_NODE_ID |
unset | The one switch. An integer in 1 to 64. Unset is single mode. 0 is reserved for the single-node identity, so “am I clustered” is never ambiguous. Apache Kafka numbers brokers from 0 and this deviates deliberately. |
QUEEN_KAFKA_CLUSTER |
queen |
The cluster name: the registry key prefix, and the cluster_id clients see. 1 to 64 characters of [A-Za-z0-9._-]. Setting it without a node id is a boot failure rather than a silent second axis. |
QUEEN_KAFKA_CLUSTER_HEARTBEAT_MS |
2000 |
How often a node refreshes its registry row and re-reads the live set. Integer in 500 to 30,000. |
QUEEN_KAFKA_CLUSTER_TTL_MS |
10000 |
How long a node stays live after its last successful write, and therefore the failover budget. Integer in 3,000 to 120,000, and never below three times the heartbeat: under 3x, one slow call to Queen evicts a healthy node and moves every group it coordinates. |
The registry is Queen’s own key/value store, one row per node under qk:node:<cluster>:<id>
carrying that node’s advertised host and port and an incarnation. Two requirements follow and the
boot check enforces both. QUEEN_TOKEN is required in cluster mode, because the registry is
written with this process’s own credential rather than a client’s, and the broker list every client
is handed has to be one list. And every facade of one cluster must present a credential of one
Queen tenant, because queen.kv is keyed by tenant: two tenants are two registries, and each
facade would see only itself.
Give every facade its own address
This is the requirement the boot check cannot enforce, and the one whose symptom is furthest from
its cause. Each node’s QUEEN_KAFKA_ADVERTISED_ADDR must be its own, individually resolvable
and routable by every client.
In Kubernetes the shape that works is a StatefulSet behind a headless Service, which gives every
pod a stable DNS name of its own, plus QUEEN_KAFKA_ADVERTISED_ADDR set per pod to that name and
the listener port:
env:
- name: POD_NAME
valueFrom: { fieldRef: { fieldPath: metadata.name } }
- name: POD_NAMESPACE
valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
# queen-kafka is the headless Service; each pod resolves to itself through it.
- name: QUEEN_KAFKA_ADVERTISED_ADDR
value: "$(POD_NAME).queen-kafka.$(POD_NAMESPACE).svc.cluster.local:9092"
# QUEEN_KAFKA_NODE_ID is the ordinal plus one, which a manifest cannot compute:
# derive it from $POD_NAME in the entrypoint before exec'ing the facade.A second, ordinary Service in front of the same pods is what goes in bootstrap.servers. A client
uses that address once, for its first Metadata, and from then on talks only to the per-pod names
the answer carried. A bootstrap address may be load balanced; an advertised one may not.
Node ids are 1 to 64 and a StatefulSet ordinal starts at 0, so the ordinal plus one is the natural mapping and the entrypoint is where that arithmetic belongs. The repository ships no Helm chart, so the manifest above is the whole of it.
Operating it
- Leadership is an advertisement, not an access control. Every node serves Produce, Fetch,
ListOffsets and OffsetFetch for every partition, whatever
Metadatasaid the leader was. A non-leader has the data, since it is one shared PostgreSQL, so refusing would cost availability for nothing. What is gated at a non-owner is JoinGroup, SyncGroup, Heartbeat, LeaveGroup, OffsetCommit, DescribeGroups and DeleteGroups. OffsetFetch is deliberately not gated: its answer is the same at every node, and anassign()-based consumer holding any connection would break if it were refused. - Failover is one TTL plus the join delay. Measured at
QUEEN_KAFKA_CLUSTER_TTL_MS=3000in two independent runs: ownership moved from a SIGKILLed node to a survivor both survivors agreed on in 3.4 s and 3.2 s, with no loss, no redelivery and no offset rewind. On the default TTL that step is 10 s, plusQUEEN_KAFKA_GROUP_JOIN_DELAY_MSfor the group to re-form. - A stop hands the node id back, so a rolling deploy no longer waits the TTL out. SIGTERM and Ctrl-C are both handled: the listening socket closes so no new connection is accepted, the connections already being served drain as tasks of their own, and then this node’s registry row is deleted, fenced on the version this process holds it with, inside a two second budget. Peers drop the node on their next registry read rather than one TTL later. The TTL stays as the backstop for the stop nobody got to run, which is a SIGKILL, an OOM kill or a severed node, and a stop that misses its budget is only slower, never wrong.
- A replacement that meets its predecessor’s row waits it out rather than exiting. If the row is still there, the replacement watches its version for one TTL plus one heartbeat. If the version moves, somebody is refreshing it, which means a second live facade configured with this node id, and that is a fatal boot with the observation that proved it in the message. If the row expires or the version never moves, the replacement adopts the id. So a pod restarting on the same ordinal inside the TTL costs a wait, not a crash loop.
- A registry that cannot be reached is not fatal. The facade logs an error naming the
consequence and keeps serving produce and fetch. Every group RPC is answered
COORDINATOR_NOT_AVAILABLE, which is retriable, until a heartbeat succeeds. - Two Queen tenants running a group of the same name are coordinated by the same node. The
ownership hash takes the group id and never the tenant, because a tenant-aware hash would never
converge across facades. It is harmless: they stay two coordinator entries over two
queen.kvrows. - Transactions are refused in cluster mode. A transaction is a stage held in one process, and a
node that does not hold it cannot honour the commit, so
initTransactions()is answered a fatal code in milliseconds rather than looping. It is a refusal by configuration and not a capability gap; the protocol reference has the detail. - Ordering across a leadership move. A producer with
max.in.flight.requests.per.connection > 1and idempotence off can have two batches land out of order when its metadata moves. Apache Kafka has the identical hazard on a leader change, and the client-side fix is the same one.