All posts

Introducing Felix: one log for streams, caches and counters

What Felix is, how a publish travels from a client to disk to every subscriber, why it is built that way, and how to run it on your laptop in five minutes.

Felix is a replicated log that serves streams, caches, counters and consumer groups from one system, over QUIC. You publish to it, subscribe to it, read the newest value for a key from it and add to a counter in it, with one client, one wire protocol and one set of permissions.

I built it because most services I've worked on end up running three systems for what is really one job, and the bugs live in the gaps between them. This post is the introduction I'd give an engineer who has never heard of Felix: what problem it removes, how a publish gets through it, why it is built the way it is, and how to have it running on your laptop in about five minutes.

One log, three readings

Take an order service. It needs a record of what happened, so other services can react and so you can replay it. It needs to answer "what is order 42 right now?" without walking that history. And it needs to hand out work, like sending the receipt, to exactly one worker at a time.

The usual answer is an event log for the first, Redis for the second and a work queue for the third. Each is good at its job. But that is three deployments, three permission models, three ways to fail, and some glue code that writes the same fact to two places. When the second write fails, the log says refunded and the cache says paid. Neither system can tell, because only your code knew they were supposed to agree.

Left, labelled three systems: an app wired to an event log, a cache and a queue. Glue code between the log and the cache has failed with a red cross, so the log says order 42 refunded while the cache still says order 42 paid. Right, labelled one log: a row of records numbered 0 to 6 with the Felix cat sitting on it, smiling. Three markers point into the same row: stream at the newest record, queue partway back, and cache: order-42 at the record holding that key. One write, three readings.THREE SYSTEMSONE LOGappevent logcachequeueorder 42 refundedorder 42 paidsend receiptgluecode0123456streamqueuecache: order-42one write, three readings
Figure 1Three systems need glue to agree. One log has nothing to disagree with.

Felix stores one thing: an append-only log, where writes land at the end and nothing already written changes. A stream, a cache and a queue are three ways of reading it. A subscriber is a position that walks forward over every record. A cache key is a marker on the newest record carrying that key. A consumer group is a shared position that only moves when a worker says a record is done.

Because they read the same kind of log, they share everything under it: one storage engine, one recovery path, one replication path and one answer to "is this write safe yet?" Access is granted per tenant and checked at the broker, and that one check covers publishing, subscribing and cache operations. When you get paged, it is about one system.

Counters sit beside the cache. counter_add applies a signed delta and answers with the new sum in the same round trip, which is what you want for rate limits, quotas and live tallies. The sum is rebuilt by folding the log, so it survives a restart and a leader failover. Felix also has an atomic commit that writes an event and the state that goes with it as one record on one shard, so a reader never sees one without the other.

How a publish travels

A cluster runs two kinds of process. Brokers hold the data and serve clients over QUIC. A control plane holds the metadata, places each shard of a stream on a broker, and decides which broker leads it. A publish only touches the brokers, and on its way through one it crosses four steps in a fixed order.

Four stations left to right. 1 decode: a QUIC frame with a header and a payload. 2 claim offsets: three tickets, 41, 42 and 43, taken in order. 3 durable log: older records in a segment, then 41 42 43 appended and an fsync check mark. 4 fan out: one encoded frame with arrows to three subscribers. A bracket under stations 2 and 3 says order is fixed before the flush, so 42 never lands ahead of 41. Under station 4: encoded once, shared by all. The Felix cat points up at the stations and says: ten subscribers, still one encode.1 DECODE2 CLAIM OFFSETS3 DURABLE LOG4 FAN OUThdrpayloadQUIC frame414243next offsets,taken in order41 42 43fsyncframesubsubsuborder is fixed before the flush,so 42 never lands ahead of 41encoded once,shared by allTen subscribers, still one encode.
Figure 2Decode, claim offsets, persist, fan out. The order is the design.

First the broker decodes the frame from the client's QUIC stream and checks the client's token allows publishing there. Then, on a durable stream, it claims the batch's offsets from the log before it waits for anything to reach disk. That fixes the batch's place in the stream the moment its offsets are taken. A sequencer then makes later batches wait behind earlier ones, whether the earlier one succeeds, fails or is cancelled, so delivery order always matches log order.

Next the batch is persisted under the stream's fsync policy, and only then fanned out. Fanout builds one delivery envelope per batch and hands every subscriber a reference to it. The encoded frame is cached in the envelope, so the first subscriber to need it encodes it and everyone else gets a cheap copy of the same bytes. Ten subscribers or five hundred, the publish is encoded once.

That last part is why fanout is what Felix is best at. On three 4-vCPU Azure brokers, with a real identity provider verifying every token, one stream on one broker delivered 256-byte messages to 500 subscribers at 1,004,273 messages a second with zero loss. The publisher's acknowledgement p50 went from 0.18 ms with one subscriber to 0.21 ms with five hundred. The conditions and the full table are on the real-network performance page.

A slow subscriber only slows itself

Every subscriber has its own bounded queue on the broker. When one fills, the default policy, DropNew, drops the arriving record for that subscriber alone. The publisher never waits on it, and neither does anyone else. An operator can choose Block instead, which trades that isolation for never dropping.

In the same Azure setup, 50 subscribers read one stream and 10 of them were slowed to 20 ms per delivery. The publisher's acknowledgement p50 stayed at 0.20 ms, the 40 healthy subscribers each received all 42,000 messages, and the slow ones dropped about 98% of theirs. The loss landed only where the reader fell behind.

Dropping is only safe if you can tell it happened. On a durable stream, every delivered event carries its log offset, so a jump in offsets is exactly a drop. The subscription ends at the first drop with the offset to resume from, and the cluster client resumes there from the log. I wrote up how that path works, and a bug in it, in One slow subscriber shouldn't slow anyone else.

The log on disk

Streams are in memory unless you create them with durable: true and the broker has a storage directory. A durable stream's log is a series of segment files, and those segments are the data. There is no separate write-ahead log in front of them, because the log already is one.

The rule the storage layer is built on is that records are never rewritten. That one property makes recovery simple to reason about: valid bytes end at end of file. Indexes are derived from the segments, never trusted, so a missing or stale index is just rebuilt. Preallocation reserves disk blocks without changing the file's size, so a preallocated tail never looks like data.

Two rows of records numbered 100 to 105. Top row, crash mid-write: the last record is torn, a jagged half record; a green dashed line cuts it off at the last whole record, because nothing could have acknowledged it, and the log is repaired and starts. Bottom row, damage in the middle: record 102 fails its CRC with acknowledged records on both sides of it, so the broker refuses to start and names the segment and position. The Felix cat looks on, concerned.CRASH MID-WRITE: TORN TAIL100101102103104105cut to the last whole recordnothing could have acked itrepaired, startsDAMAGE IN THE MIDDLE100101CRC103104105acknowledged recordssit on both sides of itrefuse to startnames the segmentand the position
Figure 3A torn tail is cut away. Damage between acknowledged records stops the broker.

Recovery tells two kinds of damage apart. A crash in the middle of an append leaves a partial record at the end of the newest segment. Nothing could have acknowledged a record that was never finished, so it is truncated and the broker starts. A bad checksum with records after it is different: those later records may have been acknowledged, and guessing would silently lose them. So the broker refuses to start and names the segment and position.

Three fsync policies decide when a write counts as safe. OnCommit flushes before the acknowledgement and has no loss window. Periodic, the default, flushes every 250 ms, so a machine crash can lose up to one interval. None leaves it to the operating system, which still survives a process crash because the page cache belongs to the kernel.

OnCommit would be slow if every append paid for its own flush, so Felix uses group commit. An fsync flushes the whole file, so appends that arrive while one flush is running all ride on the next one. On a Mac Studio that took the same code path from 253 durable appends a second at concurrency 1 to 14,387 at concurrency 64. The details are in the durable storage design.

Replication

Each shard has a leader and followers. The control plane grants the leader a time-bounded lease for the shard, and the leader ships its log to the followers. I chose leases and log shipping over running Raft per shard because a broker holds many shards, and a consensus round trip on every publish is a cost the common path should not pay.

A stream or cache picks its consistency. Under Leader, a publish is acknowledged once the leader has it. Under Quorum, it is acknowledged once a majority of the replica set holds it durably, whichever broker the client reached, and one unreachable replica does not stall it. When a leader is lost, a replica that held the log takes over under a new generation, and the generation fences the old leader so it cannot commit under the new one.

The control plane decides every failover, so it has to be reachable for a dead leader to be replaced. It keeps its metadata in Postgres, or in a Raft group embedded in its own instances with no external database. The replication protocol is model-checked in TLA+, and fault-injection tests kill brokers and simulate power loss mid-write. Acknowledged, then forgotten is a good example of what that combination catches.

Growing the protocol without version bumps

Felix's wire protocol is framed over QUIC with TLS 1.3 always on. Each frame has a small header with a flags field, and the flags decide how the payload is laid out. That makes an unknown flag dangerous: a peer that ignored it would confidently misread the body. So a broker rejects an unknown flag bit instead of masking it off.

New features are added as bits that both sides agree on, not as protocol versions. The client offers what it can read on the auth message, which is already the first round trip on every stream, so agreeing costs nothing extra. The broker answers with what it supports, and the client uses only what was advertised.

The auth handshake. On the left the client offers client_flags as a row of five bits, all set: what I can read. On the right the broker answers server_flags with four bits set and the last clear. Arrows meet in the middle at both sides use: the four bits both set. A new feature is a new bit, not a new protocol version. The Felix cat on the left holds out its paw. A sticky note on the right reads: old broker? Plain ok means the original three bits only.auth: client_flagsauth_ok: server_flags1111111110what I can readwhat I can readBOTH SIDES USE11110a new feature is a new bit,not a new protocol versionOld broker?Plain "ok" meansthe originalthree bits only.
Figure 4Capabilities are agreed on the first round trip. Silence means the original set.
{"type":"auth","tenant_id":"t1","token":"...","client_flags":25}
{"type":"auth_ok","server_flags":25}

A broker that predates negotiation ignores the field and replies with a plain ok. The only safe reading of that silence is the original three flag bits, so that set is frozen and nothing is ever added to it. Optional fields default to the old behaviour, so an old peer and a new one exchange identical bytes. The result is that a newer client runs against older brokers without whatever they predate, and nobody has to upgrade in lockstep. The full rules are in the wire protocol reference.

Kafka clients and browsers

You don't have to rewrite every client to try Felix. Each broker can run a Kafka listener, and a durable stream then looks like a Kafka topic: partitions are shards and offsets are Felix's own. Kafka producers write to it, idempotent ones included, and consumers that assign their own partitions read from it, so programs built on librdkafka or kcat can be pointed at Felix one at a time.

Consumer groups and transactions are refused on purpose, with an error that says why. That means Kafka Connect, Streams and ksqlDB don't work against it. Point your Kafka client at Felix walks through how a Kafka request becomes a Felix operation and exactly where compatibility stops.

Browsers can't open QUIC connections to a broker, so felix-gateway sits between them. A browser opens a WebSocket, names one scope, such as a room or a board, and signs in. The gateway exchanges that sign-in for a Felix token that reaches only that scope's streams, caches and counters, and relays messages both ways without decoding payloads. It holds no application state, so one binary serves any app built from those pieces.

For everything else there are native clients. The Rust client is the reference, and Python and TypeScript bind to it rather than reimplement it. All three are published as felix-client on crates.io, PyPI and npm.

What people build on it

The best test of "one system" is an application with no database next to it. The projects page collects the ones in the GetFelix organization, each written partly to find where Felix falls short.

felix-canvas is a multiplayer whiteboard. Every edit goes to a durable stream and the canvas is a fold of that log, while cursors go to an in-memory stream because a cursor from 40 ms ago is worthless. Presence is a cache whose entries expire, and a counter hands out sequence numbers. felix-webhook-relay stores each webhook before answering, then delivers it to every endpoint through its own consumer group, with retries, dead letters and replay by offset.

felix-arena is a browser team deathmatch whose simulator commits every tick to a durable stream, so the kill cam is a replay of the log. felix-metro is a live 3D city simulator, and felix-blocks, a voxel builder, is in design; both use the same pieces.

Where it fits

Felix fits best where many processes need the same fast-changing data: live feeds, dashboards, presence, configuration and state pushed to a large number of consumers, and services that need an event, a current value and some work queued, all at once. If you're building something new and can see those needs coming, one system you learn once is a real saving.

It is not a drop-in for systems you already run well. If you need the Kafka ecosystem, Redis data structures beyond a value per key, AMQP routing, or history kept longer than your brokers' disks hold, use the tool built for it. The status page marks every capability as built, partial or planned, and it is the page to trust when another disagrees.

Try it in five minutes

You need Docker, or Podman with the docker-compose provider. The compose file in the repository runs a control plane, one broker with durable storage, and development credentials for a tenant called dev, with a durable stream orders and two caches, config and stats.

curl -fsSLO https://raw.githubusercontent.com/GetFelix/felix/main/compose.yaml
docker compose up

When the broker is ready it prints its connection settings. In a second terminal, subscribe, and in a third, publish:

docker compose run --rm felixctl sub orders
docker compose run --rm felixctl pub orders 'hello'

The subscriber prints hello. The same stream is a log, so you can read it again from the start, and the caches and counters are right there:

docker compose run --rm felixctl sub orders --from earliest --count 10
docker compose run --rm felixctl cache watch config   # leave running: prints every change
docker compose run --rm felixctl cache put config theme 'dark'
docker compose run --rm felixctl counter add stats page-views 1

Every port is bound to 127.0.0.1, and the credentials are for local use only. docker compose down -v throws everything away. The Docker Compose guide shows how to point an installed felixctl at the stack, and the quickstart goes further, into the client libraries.

Where to go next

The docs cover the architecture, the wire protocol, configuration and benchmarks, and Why Felix? makes the case at more length, including when not to use it. The source is on GitHub. The broker, control plane and felixctl are AGPL-3.0, and the wire protocol and client libraries are Apache-2.0.

The rest of this blog goes deeper into single pieces: why Felix caps its QUIC packet size on Linux in Ten parcels, one envelope, how a passing TLA+ check can mean nothing in Our model said everything passed, and how a release is held to no measurable slowdown in How we catch a 1% regression.

Try Felix

Write the compose file from the Docker Compose guide, then start a broker and its control plane. With Podman, use podman compose.

docker compose up -d
Felix on GitHub RSS feed More posts