Internals: The Publish Path
This page traces a publish function by function, from Publisher::publish()
to the message landing in every subscriber’s queue. It is for contributors who
change or debug this path. The API reference is the
Client SDK.
Code references are path/to/file.rs:function_name. Use your editor’s “go to
definition” from there.
The cast of types
Section titled “The cast of types”| Type | Where | What it is |
|---|---|---|
Publisher / PublisherInner |
crates/sdk/felix-client/src/publish.rs |
Client-side handle; owns a pool of PublishWorkers and a byte-budget PublishAdmission |
PublishRequest |
crates/sdk/felix-client/src/publish/writer.rs |
Enum sent over an mpsc channel to a PublishWorker’s writer task. Carries the encoded message and an admission permit |
PublishJob |
services/felix-broker-service/src/serving/quic/handlers/publish.rs |
Broker-side unit of work: a PublishTarget, payloads, optional ack channel, optional admission permit |
StreamHandle |
crates/server/felix-broker/src/broker/shards.rs |
A cheap Arc<StreamState> clone with a dense numeric id. Resolved once and reused, so the hot path does not hash stream names |
StreamState |
crates/server/felix-broker/src/stream/state.rs |
Per-stream state: subscriber registry, in-memory replay ring, the durable log handle, the CommitSequencer, queue policy |
ClaimedPublish |
crates/server/felix-broker/src/broker/publish.rs |
A batch whose offsets and commit turn are taken but which is not yet durable or delivered |
DeliveryEnvelope |
crates/server/felix-broker/src/stream/delivery.rs |
An Arc-wrapped batch handed to every subscriber of a stream. The same Arc, not a copy per subscriber |
Client side: Publisher::publish()
Section titled “Client side: Publisher::publish()”File: crates/sdk/felix-client/src/publish.rs
pub async fn publish( &self, tenant_id: &str, namespace: &str, stream: &str, payload: Vec<u8>, ack: AckMode,) -> Result<()>-
Worker selection. Every publisher works out the shard of each publish: 0 without a key, the caller’s shard for a
ClusterClient, and for a keyed publish through a plainClientthe shard the key maps to, from the stream’s width asked of the broker (publish/widths.rs). Against a broker withFEATURE_PUBLISH_WIDTHthe width also comes back as a framedpublish_width, which the writer sends ahead of a keyed batch whenever it differs from the last one that stream carried; the broker refuses a batch whose declared width is not the stream’s (resolve_declared_shardinhandlers/publish/route.rs), and the client asks the width again every 5 s (over one shared control stream,rechecking_with) and on astale_widthrefusal. A writer whose ownauthdid not negotiate the bit (PublishWorker::takes_width) is never sent one.route()then asksShardStreams(publish/shard_streams.rs) for the shard’s own stream, opening it on the shard’s first publish on the least-loaded connection. A pipelining stream is answered in request order, so a shard that shared a stream with others would hold back their answers while it waited on a quorum, and one stream’s shards on separate streams spread over the client’s connections and listeners. Pastpublish_shard_streams(16) shards,select_worker()picks one of the pool’sPublishWorkers, either round-robin or by hashing(tenant_id, namespace, stream)(PublishSharding::HashStream, the mode that keeps a stream’s messages on one QUIC stream, preserving order). The hash result is cached per-connection instream_cache: Mutex<StreamShardCache>so repeat publishes to the same stream skip re-hashing. -
Encoding. Publishes are binary-encoded by default, acked or not. With
ack == AckMode::Nonethat isfelix_wire::binary::encode_publish_batch(flag0x0001). WithPerMessage/PerBatchit isencode_acked_publish_batch_bytes(flags0x0001 | 0x0008), which prefixes the same body with arequest_idand the ack mode. The broker replies with a binary ack frame (0x0010) rather than a JSONPublishOk/PublishError, but only when the broker advertised0x0008during the auth handshake. Otherwise the acked publish falls back topublish_batch_json. If you want JSON instead (debugging, a client that hasn’t implemented the binary decoder), callpublish_json/publish_batch_jsonexplicitly. See Wire Protocol.Both encodings converge on the same handler: the binary path decodes the frame and then calls
handle_publish_batch_messagewithAckEncoding::Binary, so admission, authorization, overload shedding and commit-ack semantics are shared and only the reply framing differs. The encoding travels with the publish job because a commit ack is sent by the task that settles the job, long after the request frame is gone. -
Admission. Before the message is handed to the worker’s channel, the caller awaits
PublishAdmission::acquire(estimated_bytes), atokio::sync::Semaphoresized in bytes, shared across every worker in the pool (publish_inflight_bytes, default 4 MiB). This is a second, independent bound from the worker’s mpsc channel depth (publish_queue_depth, default 64 items): a handful of large messages can fill the byte budget long before they fill the item-count queue. TheOwnedSemaphorePermitreturned here is attached to thePublishRequestand travels with it. It is released only when the worker finishes processing, not when it’s merely queued. See Internals: Backpressure for why that timing matters. -
Enqueue. The encoded
PublishRequest(carrying the permit) goes onto the worker’s mpsc channel.run_publisher_writer_with_limit, one task per QUIC stream, pulls requests off that channel one at a time. It is a single-writer loop, so a stream’s messages are always written in the order they were enqueued. This is whypublish_conn_pool/publish_streams_per_conncontrol your actual publish parallelism: each stream is strictly serial internally.
sequenceDiagram
participant App as Your code
participant Pub as Publisher
participant Adm as PublishAdmission (bytes)
participant Wkr as PublishWorker mpsc
participant Writer as run_publisher_writer_with_limit
App->>Pub: publish(tenant, ns, stream, payload, AckMode::None)
Pub->>Pub: select_worker() (hash or round-robin)
Pub->>Pub: encode (binary, since unacked)
Pub->>Adm: acquire(payload_len) [awaits if budget full]
Adm-->>Pub: permit
Pub->>Wkr: send(PublishRequest { bytes, permit, .. })
Wkr->>Writer: recv()
Writer->>Writer: write_all to QUIC stream
Note over Writer: permit dropped here, budget released
Broker side: from QUIC frame to PublishJob
Section titled “Broker side: from QUIC frame to PublishJob”File: services/felix-broker-service/src/serving/quic/handlers/publish/ (control.rs, uni.rs, ingress.rs, admission.rs, ack.rs, scheduler.rs, worker.rs)
The broker runs publishes through one process-wide publish scheduler, not one
per connection. A small fixed set of executors (pub_workers_per_conn,
default 4) does the work for every connection, so more publisher connections
do not mean more concurrent callers contending on shared stream state.
-
Routing.
resolve_route()(handlers/publish/route.rs) is the one chokepoint every publish passes through. It checks whether this broker owns the shard and answersLocalwith aStreamHandle,Forwardto the owner, orRefused. The handle comes from a short-lived cache (StreamHandleCacheinstream_cache.rs, TTL’d) keyed on a scratch string built without extra allocation, and travels asPublishTarget::Resolved. Ownership is checked outside that cache, on every publish, because it changes the moment the control plane says so. A handle’s lane (step 3) is keyed byhandle.id()rather than a string hash. -
Admission. Mirrors the client exactly:
enqueue_publish()computesjob_bytes = payloads.iter().map(Bytes::len).sum()and acquires from a broker-sidePublishAdmission(byte semaphore,pub_inflight_bytes, default 64 MiB, process-wide) before the job is queued. The policy for what happens when admission or the queue is full is an explicit enum:pub(crate) enum EnqueuePolicy {Drop, // shed: fire-and-forget trafficFail, // refuse at once: enqueue-acked trafficWait, // bounded wait (publish_queue_wait_timeout_ms): commit-ack trafficBackpressure, // wait until room or the connection goes: pub_ingress_wait}Unacked publishes use
Drop(orBackpressureifpub_ingress_waitis set, see Internals: Backpressure). An acked publish that finds no room is answered with a retryableoverloaded(detail.reason = "publish_queue_full", a shortretry_after_ms, and nothing queued), and every refusal or shed is counted against its tenant infelix_tenant_publish_queue_full_total. This is where “overload becomes visible instead of silently buffering” is enforced on ingest.A
WaitorBackpressurepublish that finds no room is parked: it takes its place in the queue’s line of waiters at once, and the wait itself runs in a task the control stream owns (handlers/publish/parked.rs). The stream goes on reading, so a full queue holds up its own publish and not the publishes behind it to shards with room. Line order is per tenant and first come, first served, so a later publish on the same stream never overtakes a parked one. A parked acked publish keeps its byte budget, arms its ack timeout only once it is admitted, and is answeredpublish_queue_fullif its wait runs out. A stream may have 1024 acked publishes parked but only 16 unacked ones; past that, or with the byte budget spent, it waits inline and stops reading, which leaves the backpressure to QUIC stream flow control. The unacked cap is small because no publish window holds a fire-and-forget client back: with a deep one, a busy broker’s streams park until the shared byte budget is spent, and then every publish waits on that budget, which is slower than waiting on the queue.The trade-off is isolation. A stream whose fire-and-forget publishes hit one full shard stops reading after 16 frames, and since
pub_stream_recv_windowdefaults to the connection window, its unread bytes can take the whole connection’s flow-control credit and stall the connection’s other streams. A larger cap reaches the same state once the byte budget runs out; the small cap reaches it sooner. On a stream that carries both kinds, acked frames behind the 17th unacked publish wait with it. Parked publishes are counted infelix_broker_ingress_parked_total. -
Lanes and the fair queue (
handlers/publish/scheduler.rs). Every job belongs to a lane: the shard it writes (keyed byhandle.id()), or the remote shard it is forwarded to (keyed by a hash of its name). A lane runs one job at a time in arrival order, which is what keeps a shard’s offsets claimed in the order its publishes arrived. Different lanes run side by side. Which ready lane an executor takes next is decided per tenant by deficit round robin, weighted by bytes (a 64 KiB quantum per turn), so a tenant sending many or large batches cannot crowd out one sending a few. The queue holdspub_queue_depth × pub_workers_per_connjobs. Every tenant is guaranteedpub_queue_depthof them, and a tenant past that may borrow idle room but never the lastpub_queue_depthslots, so a flooding tenant is refused while a quiet one still gets in. Withcore_shardsenabled there is one such queue per shard, with its executors on that shard’s core, and a stream’s lane lives on the shard that owns it. See Internals: Backpressure & Core Sharding. -
What a job does on its lane (
handlers/publish/worker.rs:LaneWork). An executor holds a lane only for the part that has to be ordered, and only while that part is not waiting on something outside the broker:Job Ordered, on the lane Handed to its own task Durable publish fence check, claim_publish(offsets taken) for it and the durable publishes queued behind itdevice flush, fanout, quorum wait, the answers Ephemeral publish fence check, publish_batch_with_outcomequorum wait, the answer Idempotent publish sequence check and append (offsets taken), off the executor device flush, quorum wait, the answer Forward the whole round trip, off the executor nothing A durable publish does not go alone if more are queued on its lane. The executor takes the plain durable publishes queued behind it, in order and up to 64 of them or 1 MiB of payload, and claims them all with one
claim_publish. That is one write, one commit wait, one commit turn and one fanout for the lot, which is most of what an unbatched durable publish costs. Each publish still gets its own answer: its offset is the claim’s first plus the records ahead of it, and the answers go out in lane order once the claim is durable. The tenant is charged for every publish it took, and pays any excess in later turns. Taking stops at the first queued job that cannot join (an idempotent or empty publish), so nothing overtakes it.felix_broker_publish_claim_jobsreports the group sizes.Failures follow what the members share. The fence and lease are checked per publish: one the fence refuses is answered with the refusal and left out, and the rest are written around it. Everything after that is shared: a failed append, flush or quorum wait fails every publish in the claim, because none of them can have succeeded without the others. Every member gets the same error. A quorum timeout leaves the outcome unknown for each of them, so none is told it is safe to send again. Exactly-once needs the idempotent producer, whose publishes are never grouped. A publish whose caller stopped waiting keeps its place, as it would alone.
queued_publishes_on_one_lane_are_claimed_as_one_append: eight publishes queued on one lane, one of them three records long, are answered with offsets 0, 1, 2, 5, 6, … and reach the subscriber as one delivery.no_publish_in_a_claim_is_answered_before_an_earlier_one: when the last publish of a claim is answered, every earlier one already is.a_publish_the_fence_refuses_is_left_out_of_the_claim: the refused publish getsShardUnavailable, and the others are written at 0, 1, 2.a_claim_whose_quorum_wait_times_out_leaves_every_member_unknown: both members of a claim whose quorum wait timed out getQuorumTimeoutwith retry classOutcomeUnknown.A durable shard may have
pub_flush_concurrencyflushes outstanding. Past that, its next claim waits for one off the executor, holding only its own lane. A forward keeps its lane for the round trip because a forward can be retried or redirected, and a later batch sent before an earlier one is answered could land ahead of it. Only that remote shard’s later publishes wait. Either way a slow disk, a slow peer, or a quorum that has not formed holds up its own shard and nothing else.a_stalled_forward_does_not_hold_up_another_shard: with one executor, a publish to a local shard is written and acknowledged while a forward to a peer that never answers is still waiting.one_shards_publishes_are_answered_in_order_across_a_full_queue: one shard’s acks come back in the order the publishes were sent, and the log holds exactly the accepted publishes in that order, while the queue keeps refusing others in between.The lane is released by a guard, so a job that panics still frees it, and the executor is replaced (
felix_broker_publish_worker_restarts_total).An executor yields to the runtime after every job. Taking a queued job and writing an ephemeral publish never suspend, so an executor working through a backlog would otherwise fan out job after job while the subscriber feeders it woke waited for its thread, and a subscriber that reads fast enough would still overflow its bounded queue.
subscribers_drain_between_an_executors_queued_publishes: with a backlog of 32 publishes queued and a subscriber queue of 4 on one thread, the subscriber receives all 32.
Broker core: claim, then complete
Section titled “Broker core: claim, then complete”File: crates/server/felix-broker/src/broker/publish.rs
The core splits a publish into an ordered half and a half that can overlap
with other publishes. publish_batch_with_outcome runs both back to back. The
broker service calls them separately so it can release the lane between them.
lane held ─────────────────────────┐ own task ─────────────────────────────────────────────┐claim_publish │ complete_publish │ handle active? │ log.commit(pending) flush under fsync policy │ durable.begin_append(payloads) │ turn.wait() every earlier batch done │ -> offsets consumed │ hold if Quorum mark behind │ commit_sequencer.reserve_owned │ append_batch_at replay ring, sequence = │ -> commit turn for the range │ log offset │ │ fan_out one DeliveryEnvelope, │ │ one clone per subscriber │───────────────────────────────────┘ turn released ─────────────────────────────────────────┘ then, on Quorum: await_quorum, then the ack-
claim_publishchecks the handle is active. On a durable stream it callsbegin_append, which writes the records and consumes their offsets, and immediately reserves that offset range in the stream’sCommitSequencer(crates/server/felix-storage/src/commit_order.rs). The turn is claimed before the durability wait, not after a successful one, so a publish that fails or is cancelled still releases its place and later publishes are not stranded behind it. The order claims return in is the order records land on disk. On an ephemeral stream the claim only checks the handle. -
complete_publish(publish/completion.rs) waits for the log’scommit, the flush under the stream’s fsync policy, which group commit shares with other waiters. It then waits for its commit turn, so every earlier batch has been appended and fanned out first. On aQuorumstream a batch the committed mark has not passed is held back from readers and released once a majority holds it. The release is woken when the mark moves and also rechecks the bound every 250 ms while anything is held, since a route or lease change can make the bound cover a batch without the mark moving. -
append_batch_atappends to the in-memory replay ring under one lock per batch and trims it tolog_capacity. On a durable stream the ring’s sequence numbers are the log’s offsets. The same lock pairs the append with the subscriber list it fans out to, so a subscriber joining at that moment gets the batch either in its backlog or live, never neither. -
Fanout reads the
ArcSwap<Vec<SubscriberEntry>>snapshot. The subscriber registry itself (aSlabbehind a mutex) is touched only on subscribe and unsubscribe. OneDeliveryEnvelopecarries the payloads, the base offset and the skip count, plus lazily filled encoded-frame slots, and each subscriber gets a clone: anArcbump, not a payload copy or a re-encode. The send is gated bySubQueuePolicy(Block,DropNew,DropOld, whereDropOldbehaves asDropNew). This is the first of two backpressure checkpoints on the subscribe side. See Internals: Backpressure for the second.
The commit turn is held until fanout finishes, so delivery order matches log
order. Cancelling complete_publish does not cancel the batch: once its
offsets are claimed its records exist, so the ring append and fanout finish on
a detached task.
Worked example
Section titled “Worked example”Publishing one message to a stream with 3 active subscribers, unacked,
core_shards disabled:
- Client hashes
(t1, orders, updates)to worker 2, encodes binary, awaits the 4 MiB byte budget, enqueues on worker 2’s channel. run_publisher_writer_with_limitfor worker 2 (a dedicated task owning one bidirectional QUIC stream, opened withopen_biand authenticated once at connect time) writes the frame. The broker’s stream handler on the other end decodes it intoPublishJob { target: PublishTarget::Resolved { handle, .. }, .. }.- The job joins the stream’s lane. An executor takes it on tenant
t1’s turn and callsclaim_publish, which on a durable stream takes one offset and a commit turn, then releases the lane. complete_publishwaits for the flush and the turn, appends the payload to the replay ring, reads the subscriber snapshot (3 entries) without a lock, and creates oneDeliveryEnvelope.- The envelope is
.clone()d 3 times (3Arcbumps) and sent to 3 differentmpsc::Sender<DeliveryEnvelope>, one per subscriber. - Each subscriber’s feeder task independently calls
envelope.shared_event_frame(). The first one to call it pays the encode cost and caches the result in the envelope, and the other two get the cachedBytesfor free. See Internals: Subscribe & Fanout.
If you want to change…
Section titled “If you want to change…”| You want to… | Look at |
|---|---|
| Change how publishes are encoded (binary vs JSON, new wire format) | crates/sdk/felix-client/src/publish.rs (publish) and publish/send.rs (encoding and the JSON fallback), crates/protocol/felix-wire/src/client/ |
| Change client-side publish backpressure | PublishAdmission in crates/sdk/felix-client/src/publish/admission.rs; publish_queue_depth/publish_inflight_bytes in crates/sdk/felix-client/src/config.rs |
| Change broker ingest admission/shedding behavior | EnqueuePolicy in handlers/publish/ack.rs and enqueue_publish() in handlers/publish/ingress.rs |
| Change routing or stream resolution/caching | resolve_route in handlers/publish/route.rs, StreamHandleCache in handlers/publish/stream_cache.rs; StreamHandle and resolve_stream_handle in crates/server/felix-broker/src/broker/shards.rs |
| Change the durability step or commit order | Broker::claim_publish and complete_publish in crates/server/felix-broker/src/broker/publish.rs and publish/completion.rs; CommitSequencer in crates/server/felix-storage/src/commit_order.rs |
| Change fanout/queue policy for subscribers | SubQueuePolicy match in Broker::complete_publish, crates/server/felix-broker/src/broker/publish.rs |
| Change the in-memory replay log | StreamState::append_batch_at, crates/server/felix-broker/src/stream/state.rs |
| Add a new client publish worker sharding strategy | PublishSharding in crates/sdk/felix-client/src/publish/routing.rs |
| Change broker publish scheduling (lanes, tenant fairness, queue bounds) | PublishScheduler in handlers/publish/scheduler.rs, FairQueue in scheduler/fair_queue.rs; what each job does on its lane in handlers/publish/worker.rs |
Next: Internals: Subscribe & Fanout picks up where
this page leaves off: what happens to the DeliveryEnvelope after it lands
in a subscriber’s queue.
