At LaunchDarkly, we face a unique challenge when it comes to adapting to the load of our customers. Because we record our customersβ web apps, we need to be able to handle bursts of traffic as they come from their users.
When we first started, our data ingested was simple: we would stream data from our recording client to our backend and perform updates as it came in. Our data handlers would perform metadata updates and write session recordings to PostgreSQL via SQL queries, and they would be consumed and removed from the database tables for permanent storage.
The initial approach worked fine for a while. But as our customer base grew, we ran into key issues with this implementation. We had to run some database migrations that ran an ALTER command with an aggressive table lock. Immediately, we saw a wave of blocked commands, all waiting for the migration to complete. For the ~30 minutes that this took, we were dead in the water with incoming session requests and could not store any of our customersβ recordings. All of the backend traffic would time out since we didnβt have a place to put the data in the meantime.
A wave of blocked database commands stacking up during an aggressive table lock migration.
Possible Solutions
Since we needed a way to buffer data, we considered various techniques that could accomplish the task. One idea was to abstract each backend payload into a βmessageβ to be put into a buffer and consumed by a worker. We had a few considerations that were driven by details about our data:- Our task data can range from ~10KB to ~100MB
- Tasks must be processed in order and serially for a given recording.
- Tasks across different recordings can be processed in parallel.
- We canβt lose any messages for a recording as that would invalidate the entire recording.
Apache Cassandra/[**Mongo
DB**](https://www.mongodb.com/ βhttps://www.mongodb.com/β)**/**[**DynamoDB**](https://aws.amazon.com/dynamodb/ βhttps://aws.amazon.com/dynamodb/β)**/ Other NoSQL Databases**- Has row size limits that vary but generally do not support our ~100MB messages.
- Would require much more application-level logic to write and consume data.
Redis
- Data may expire so messages may be lost.
- Message ordering is harder to maintain without performance constraints.
[**Rabbit
MQ**](https://www.rabbitmq.com/ βhttps://www.rabbitmq.com/β)- Fully ordered so scalability without application-level re-architecture may be a challenge.
- Brokers are replicated but do not scale well horizontally.
Apache Kafka
- Messages can be as large as ~500MB.
- Messages are ordered within a partition.
- One consumer per partition allows for consistent ordering with linear parallel scaling.
- Messages only expire after a time-based or space-based retention policy.
Building a Scalable Task Queue
As we evaluated our options, we settled on a producer-consumer message strategy. Our architecture would have our backend HTTP request handlers produce βtaskβ messages that would block until the data was sent to our chosen broker. The messages would arrive at the broker quickly and reside until a worker machine could consume them and process their data. This effectively decouples our incoming data stream from the processing, allowing us to buffer data for as long as we want (space permitting based on the configuration of our broker).Architecture diagram showing the public API handlers transmitting data to Apache Kafka, which is then ingested and processed by a set of workers.
Diagram showing messages routed to partitions based on the session ID key, with each partition having its own dedicated consumer.