We used a database as a message queue. Now we use Kafka.
FoundationDB was our only database, queue included. We built Apple's QuiCK design on top of it, hit the write limits, and moved async tasks to Kafka.
Contents
FoundationDB is the only database we use. This should surprise you since FoundationDB is pretty barebones, just a key-value store. It stores everything for us: tenants, object metadata, the replication log for data distributed across regions, etc. We also use it as a queue to handle async tasks, à la QuiCK, the queuing system Apple uses for CloudKit. This has scaled very nicely. I’m not surprised; it’s the same tech behind iCloud, a platform with at least 900 million users. Furthermore, keeping the queue inside FoundationDB means all transactions stay in the database, eliminating the dual-write problem.
So why start using Kafka now?
We’ve seen a few issues using our database as a message queue:
- Scheduling requires many writes and scans, which puts read load on FoundationDB that directly competes with user requests.
- Each task is expensive and needs multiple writes to complete (enqueue, claim, lease, etc). We have ever more tasks as we add more features.
- New team members have to learn all the custom code resulting from actually implementing the QuiCK paper. There’s no standard implementation, even though it’s a well known pattern in theory. Finesse is not something you can learn from a paper.
This isn’t a story of a neat 1:1 replacement. We still have the queues in FoundationDB. We moved asynchronous tasks like garbage collection to Kafka, we can reduce the read and write load on FDB and shave off a good amount of that pesky custom code. Read more to see how it all turned out for us!
You might tell us we should have “just used Kafka” the whole time. Beyond the fact that you used the j-word: have you ever waited for your not-even-that-big broker to catch up on a cold start? Do you know what a zookeeper is and why you don’t pay to take care of the animals? The poor zookeeper can’t even pet them. Have you ever felt like a plastic bag drifting through the wind but unable to start again because of the sheer madness that comes with spending months permuting JVM flags to try to eke out a spectre’s worth of performance so that your servers aren’t constantly on fire?
No? Just me?
Either way we kinda wanted to avoid Kafka because running it yourself is the administrative experience of finding yourself turned into a monstrous vermin and everyone around you is mildly annoyed at your experience and asking you to move on with life instead of understanding that you can’t work anymore because your arms have turned into dozens of legs. By the way, that’s actually what people mean when they call something “kafkaesque”, not a Qu’vatlh of paperwork.
Beyond the naïve way to build a queue on FoundationDB
So you need a queue. The FoundationDB docs contain tutorials for making simple queues in multiple languages. At a high level you put messages in on one end of the keyspace (namespace for keys) and then read them out of the other end of the keyspace. This works fairly well (if you’ve ever used Sidekiq, this model should be very familiar), but the main problems come with naming the entries in the queue.
The naïve way to do it is to use the FoundationDB equivalent of MySQL’s
AUTO INCREMENT where you assign each queue item its own atomically increasing
integer ID, but what happens when you have more than one producer?
Given a sufficiently distributed system, it's easy for two jobs to have conflicting IDs, such as two tasks getting the ID 67 and conflicting with eachother on insert. Sure, with enough work you can random or UUID your way out of this, but the core problem is that consuming work deletes it from the database. If a worker dies while it's processing an item, there's no way for another worker to retry. Once the worker consumes a job, it's no longer in the queue and that job dies with it.
t1 t2 t3──┬─────────────────────┬─────────────────────┬──────────────────────────▶│ │ │└─ worker pops └─ row gone from └─ worker dies('q',1) FoundationDB mid-PutObject┌──────────────────────────────────────────────────────────────────┐│ nothing in the database remembers that ('q',1) existed. ││ the replication job for uploads/report.pdf is simply gone. │└──────────────────────────────────────────────────────────────────┘// a user in Frankfurt cannot see an object written in Chicago, and// there is no record left anywhere that tells you why.
We really don’t want to just lose queue items because this could mean that
running a PutObject to the Tigris region in Chicago makes the object not show
up as visible to a user that just happens to be in Europe and is hitting the
Frankfurt region. It sure would be convenient if someone had thought of this
problem in detail and published a detailed description of how they solved it.
Foreshadowing is a literary device in which the author creates dramatic tension from which to signal to the reader that something is about to happen.
We implemented an Apple paper that describes how to implement durable message queues on top of FoundationDB
Turns out Apple has thought about this and implemented it for CloudKit with a system they call QuiCK, or a Queueing System in CloudKit. Our industry has silly names for things. QuiCK is a robust queuing system that uses FoundationDB’s Record Layer to implement a message queue based on time. To understand why this is such a galaxy-brained genius move, let’s take a sidestep into how time works in distributed systems.
Much ado about time
FoundationDB is an ordered key-value store, and the ordering is a huge bit of how it’s (ab)used in practice. One of the nice things about time is that generally it’s an ordered phenomenon. These two match.
In most temporal reference frames, events happen sequentially:
time│t1 ├──▶ person picks up apple│t2 ├──▶ person eats apple▼// one clock, one observer. the order you saw is the order it// happened. distributed systems do not get this for free.
Now, that “generally” in the previous paragraph is a bit of a misnomer in distributed systems. It helps to think about every part of a distributed system having its own independent observation of time and that changes a lot about how event ordering can be strange in practice.
Imagine that you have two reference frames: one is closer to the person interacting with that apple and the other is farther away from it and gets news about the apple from the initial reference frame tweeting about it over UDP for some reason.
RF1 · next to the apple RF2 · reads the tweets┌──────────────────────────┐ ┌──────────────────────────┐│ t1 picks up apple │ ── tweet ──▶ │ t3 reads "eats apple" ││ t2 eats apple │ ── tweet ──▶ │ t4 reads "picks up" │└──────────────────────────┘ └──────────────────────────┘// the first tweet was slow. RF2 believes the apple was eaten and// then picked up. attach the observed time, or live with this.
Oh no! The first tweet was slow and the clocks are slightly out of sync, so the second reference frame saw the tweets out of order! The obvious answer here is to attach the observed time to each tweet (and maybe get better time synchronization in your clusters). This makes everything in the distributed system have at least some understanding of when things happen and in what order they should have been observed in.
Much a-queue about time
As such, the message queue Apple built on top of FoundationDB uses time as a key part of job identity. Jobs are identified by their task type, item space, execution / vesting time, priority, and a unique ID.
("queue", version, taskType, itemSpace, vestingMs, priority, id)│└──▶ QueueItem┌─────────────┬──────────────────────────────────────────────────────────┐│ vestingMs │ when the job may run. this is the sort key. │├─────────────┼──────────────────────────────────────────────────────────┤│ taskType │ which queue. gc, lifecycle, replication. ││ itemSpace │ the tenant or bucket the job belongs to ││ priority │ ordering inside one vesting millisecond ││ id │ random suffix, so two producers never collide
