Internals: The Publish Path
This page traces exactly what happens, function by function, when a client
publishes a message — from Publisher::publish() to the message landing in
every subscriber’s queue. It’s written for contributors who need to change
or debug this path, not as an API reference (see the
Client SDK for that).
Every code reference below is path/to/file.rs:function_name as of this
writing — line numbers drift, function names are stable, use your editor’s
“go to definition” from there.
The cast of types
Section titled “The cast of types”Keep these in your head; everything below is these types moving data around.
| Type | Where | What it is |
|---|---|---|
Publisher / PublisherInner |
crates/felix-client/src/client/publisher.rs |
Client-side handle; owns a pool of PublishWorkers and a byte-budget PublishAdmission |
PublishRequest |
same | Enum sent over an mpsc channel to a PublishWorker’s writer task — carries the encoded message and an admission permit |
PublishJob |
services/broker/src/transport/quic/handlers/publish.rs |
Broker-side unit of work — a resolved PublishTarget, payloads, optional ack channel, optional admission permit |
StreamHandle |
crates/felix-broker/src/lib.rs |
A cheap Arc<StreamState> clone — the dense, pre-resolved identity of a stream. Resolving this once and reusing it is what removed string hashing from the hot path (see below) |
StreamState |
same | The actual per-stream state: subscriber registry, in-memory replay log, queue policy |
DeliveryEnvelope |
same | An Arc-wrapped batch of payloads 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/felix-client/src/client/publisher.rs
pub async fn publish( &self, tenant_id: &str, namespace: &str, stream: &str, payload: Vec<u8>, ack: AckMode,) -> Result<()>-
Worker selection.
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 is carried on the ack-waiter message because a commit ack is emitted from a different task, 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 — 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— one task per QUIC stream — pulls requests off that channel one at a time: it’s 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
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/broker/src/transport/quic/handlers/publish/ (control.rs, uni.rs, ingress.rs, admission.rs, ack.rs)
The broker’s publish workers are a global, process-wide pool — not
per-connection. The comment at conn.rs:build_publish_context explains why:
per-connection pools meant more publisher connections multiplied concurrent
Broker::publish_batch callers and caused lock contention on shared broker
state. One fixed pool, sharded by stream, avoids that.
-
Stream resolution.
resolve_stream_cached()turns(tenant_id, namespace, stream)into aStreamHandle, backed by a short-lived cache (StreamHandleCache, TTL’d) keyed on a scratch string built without extra allocation. Once resolved, the handle travels asPublishTarget::Resolved(handle)— no more string hashing orRwLockreads for this stream until the cache entry expires.Stream resolution: why a handle, not a string
Section titled “Stream resolution: why a handle, not a string”Before this existed, every publish re-hashed
(tenant, namespace, stream)and read through anRwLock<HashMap<..>>to find the stream’s state. AStreamHandleis justArc<StreamState>with anid()— clone it, pass it around, and worker/shard selection becomeshandle.id() % worker_countinstead of a string hash. Seecrates/felix-broker/src/lib.rs:StreamHandle. -
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 hard-committed to a worker channel. The policy for what happens when admission or the queue is full is an explicit enum:pub(crate) enum EnqueuePolicy {Drop, // shed silently — fire-and-forget trafficFail, // reject immediately — acked traffic, fail fastWait, // bounded wait (publish_queue_wait_timeout_ms) — commit-ack traffic}Unacked publishes use
Drop(orWaitifpub_ingress_waitis set — see Internals: Backpressure). This is where “overload becomes visible instead of silently buffering” is enforced on ingest. -
Worker dispatch.
job.target’s handle id picks a worker index (handle.id() as usize % worker_count). Withcore_shardsenabled, this is also the shard index — the worker for a given stream always runs on the same OS thread, pinned to the same core. See Internals: Backpressure & Core Sharding. -
The worker loop (
conn.rs:build_publish_context, spawned once per worker) does the actual work:while let Some(job) = publish_rx.recv().await {match &job.target {PublishTarget::Resolved(handle) => {broker.publish_batch_to_handle(handle, &job.payloads).await}// ..}}The
job’s admission permit is a field onPublishJoband is dropped here, at the end of the loop iteration — afterpublish_batch_to_handlereturns. That’s the “released only when actually processed” guarantee.
Broker core: Broker::publish_batch_to_handle
Section titled “Broker core: Broker::publish_batch_to_handle”File: crates/felix-broker/src/lib.rs
This is where the message actually becomes visible to subscribers.
pub async fn publish_batch_to_handle( &self, handle: &StreamHandle, payloads: &[Bytes],) -> Result<usize> { if !handle.state.active.load(Ordering::Acquire) { return Err(BrokerError::StreamHandleInactive(handle.id())); } let stream_state = &handle.state; stream_state.append_batch(payloads, self.log_capacity); // 1 let senders = stream_state.subscriber_snapshot(); // 2 let envelope = DeliveryEnvelope::new(payloads); // 3 for subscriber in senders.iter() { // 4 // match on stream_state.subscriber_queue_policy: Block / DropNew / DropOld subscriber.sender.send(envelope.clone()).await; // or try_send / try_reserve }}-
append_batch: appends to an in-memoryVecDeque<LogEntry>under oneMutexlock — one lock acquisition per batch, not per payload — then trims tolog_capacity. This log exists for cursor-based replay (subscribers reading from an offset); it is not durable storage. -
subscriber_snapshot: reads anArcSwap<Vec<SubscriberEntry>>— lock-free on the hot path. The actual subscriber registry (Mutex<SubscriberRegistry>, aSlab) is only touched on subscribe/unsubscribe; every publish just clones the currentArcsnapshot. This is why adding/removing subscribers doesn’t contend with in-flight publishes. -
DeliveryEnvelope::new: wrapspayloadsin oneArc<[Bytes]>plus aMutex<Option<Bytes>>cache slot for the encoded wire frame (filled lazily, once, by whichever subscriber’s feeder task encodes it first — see Internals: Subscribe & Fanout). Every subscriber gets a.clone()of thisDeliveryEnvelope— anArcbump, not a payload copy, and critically not a per-subscriber re-encode. This is the single biggest fanout-cost change in Felix’s history: fanout used to be O(fanout) encode calls per publish; it’s now O(1). -
Per-subscriber send, gated by
stream_state.subscriber_queue_policy(SubQueuePolicy::Block | DropNew | DropOld) — this is the first of two backpressure checkpoints on the subscribe side. See Internals: Backpressure for the complete picture, including the second checkpoint (the writer lane) further downstream.
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_writerfor worker 2 (a dedicated task owning one bidirectional QUIC stream — publish streams areopen_bi, opened and authenticated once at connect time) writes the frame; the broker’s stream handler on the other end decodes it intoPublishJob { target: PublishTarget::Resolved(handle), .. }.- Global publish worker
handle.id() % worker_countpicks up the job, callspublish_batch_to_handle. - One payload is appended to the log; the subscriber snapshot (3 entries)
is read without a lock; one
DeliveryEnvelopeis created. - 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; 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/felix-client/src/client/publisher.rs (publish/publish_json), crates/felix-wire/src/lib.rs |
| Change client-side publish backpressure | PublishAdmission in publisher.rs; publish_queue_depth/publish_inflight_bytes in crates/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 stream resolution/caching | resolve_stream_cached, StreamHandleCache in publish.rs; StreamHandle in crates/felix-broker/src/broker.rs and resolve_stream_handle in crates/felix-broker/src/registry.rs |
| Change fanout/queue policy for subscribers | SubQueuePolicy match in Broker::publish_batch_to_handle, crates/felix-broker/src/lib.rs |
| Change the in-memory replay log | StreamState::append_batch/snapshot_range, crates/felix-broker/src/lib.rs |
| Add a new publish worker sharding strategy | PublishSharding in crates/felix-client/src/client/sharding.rs; publish_worker_index in publish/ingress.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.
