<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Netflix TechBlog - Medium]]></title>
        <description><![CDATA[Learn about Netflix’s world class engineering efforts, company culture, product developments and more. - Medium]]></description>
        <link>https://netflixtechblog.com?source=rss----2615bd06b42e---4</link>
        <image>
            <url>https://cdn-images-1.medium.com/proxy/1*TGH72Nnw24QL3iV9IOm4VA.png</url>
            <title>Netflix TechBlog - Medium</title>
            <link>https://netflixtechblog.com?source=rss----2615bd06b42e---4</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Tue, 17 Dec 2024 02:49:10 GMT</lastBuildDate>
        <atom:link href="https://netflixtechblog.com/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Netflix’s Distributed Counter Abstraction]]></title>
            <link>https://netflixtechblog.com/netflixs-distributed-counter-abstraction-8d0c45eb66b2?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/8d0c45eb66b2</guid>
            <category><![CDATA[counter]]></category>
            <category><![CDATA[software-architecture]]></category>
            <category><![CDATA[system-design-interview]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <category><![CDATA[scalability]]></category>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Tue, 12 Nov 2024 20:45:23 GMT</pubDate>
            <atom:updated>2024-11-21T22:00:12.371Z</atom:updated>
            <content:encoded><![CDATA[<p>By: <a href="https://www.linkedin.com/in/rajiv-shringi/">Rajiv Shringi</a>, <a href="https://www.linkedin.com/in/oleksii-tkachuk-98b47375/">Oleksii Tkachuk</a>, <a href="https://www.linkedin.com/in/kartik894/">Kartik Sathyanarayanan</a></p><h3>Introduction</h3><p>In our previous blog post, we introduced <a href="https://netflixtechblog.com/introducing-netflix-timeseries-data-abstraction-layer-31552f6326f8">Netflix’s TimeSeries Abstraction</a>, a distributed service designed to store and query large volumes of temporal event data with low millisecond latencies. Today, we’re excited to present the <strong>Distributed Counter Abstraction</strong>. This counting service, built on top of the TimeSeries Abstraction, enables distributed counting at scale while maintaining similar low latency performance. As with all our abstractions, we use our <a href="https://netflixtechblog.medium.com/data-gateway-a-platform-for-growing-and-protecting-the-data-tier-f1ed8db8f5c6">Data Gateway Control Plane</a> to shard, configure, and deploy this service globally.</p><p>Distributed counting is a challenging problem in computer science. In this blog post, we’ll explore the diverse counting requirements at Netflix, the challenges of achieving accurate counts in near real-time, and the rationale behind our chosen approach, including the necessary trade-offs.</p><p><strong>Note</strong>: <em>When it comes to distributed counters, terms such as ‘accurate’ or ‘precise’ should be taken with a grain of salt. In this context, they refer to a count very close to accurate, presented with minimal delays.</em></p><h3>Use Cases and Requirements</h3><p>At Netflix, our counting use cases include tracking millions of user interactions, monitoring how often specific features or experiences are shown to users, and counting multiple facets of data during <a href="https://netflixtechblog.com/its-all-a-bout-testing-the-netflix-experimentation-platform-4e1ca458c15">A/B test experiments</a>, among others.</p><p>At Netflix, these use cases can be classified into two broad categories:</p><ol><li><strong>Best-Effort</strong>: For this category, the count doesn’t have to be very accurate or durable. However, this category requires near-immediate access to the current count at low latencies, all while keeping infrastructure costs to a minimum.</li><li><strong>Eventually Consistent</strong>: This category needs accurate and durable counts, and is willing to tolerate a slight delay in accuracy and a slightly higher infrastructure cost as a trade-off.</li></ol><p>Both categories share common requirements, such as high throughput and high availability. The table below provides a detailed overview of the diverse requirements across these two categories.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZjxKcMckMLrT_JqPUzP4MQ.png" /></figure><h3>Distributed Counter Abstraction</h3><p>To meet the outlined requirements, the Counter Abstraction was designed to be highly configurable. It allows users to choose between different counting modes, such as <strong>Best-Effort</strong> or <strong>Eventually Consistent</strong>, while considering the documented trade-offs of each option. After selecting a mode, users can interact with APIs without needing to worry about the underlying storage mechanisms and counting methods.</p><p>Let’s take a closer look at the structure and functionality of the API.</p><h3>API</h3><p>Counters are organized into separate namespaces that users set up for each of their specific use cases. Each namespace can be configured with different parameters, such as Type of Counter, Time-To-Live (TTL), and Counter Cardinality, using the service’s Control Plane.</p><p>The Counter Abstraction API resembles Java’s <a href="https://docs.oracle.com/en/java/javase/22/docs/api/java.base/java/util/concurrent/atomic/AtomicInteger.html">AtomicInteger</a> interface:</p><p><strong>AddCount/AddAndGetCount</strong>: Adjusts the count for the specified counter by the given delta value within a dataset. The delta value can be positive or negative. The <em>AddAndGetCount</em> counterpart also returns the count after performing the add operation.</p><pre>{<br>  &quot;namespace&quot;: &quot;my_dataset&quot;,<br>  &quot;counter_name&quot;: &quot;counter123&quot;,<br>  &quot;delta&quot;: 2,<br>  &quot;idempotency_token&quot;: { <br>    &quot;token&quot;: &quot;some_event_id&quot;,<br>    &quot;generation_time&quot;: &quot;2024-10-05T14:48:00Z&quot;<br>  }<br>}</pre><p>The idempotency token can be used for counter types that support them. Clients can use this token to safely retry or <a href="https://research.google/pubs/the-tail-at-scale/">hedge</a> their requests. Failures in a distributed system are a given, and having the ability to safely retry requests enhances the reliability of the service.</p><p><strong>GetCount</strong>: Retrieves the count value of the specified counter within a dataset.</p><pre>{<br>  &quot;namespace&quot;: &quot;my_dataset&quot;,<br>  &quot;counter_name&quot;: &quot;counter123&quot;<br>}</pre><p><strong>ClearCount</strong>: Effectively resets the count to 0 for the specified counter within a dataset.</p><pre>{<br>  &quot;namespace&quot;: &quot;my_dataset&quot;,<br>  &quot;counter_name&quot;: &quot;counter456&quot;,<br>  &quot;idempotency_token&quot;: {...}<br>}</pre><p>Now, let’s look at the different types of counters supported within the Abstraction.</p><h3>Types of Counters</h3><p>The service primarily supports two types of counters: <strong>Best-Effort</strong> and <strong>Eventually Consistent</strong>, along with a third experimental type: <strong>Accurate</strong>. In the following sections, we’ll describe the different approaches for these types of counters and the trade-offs associated with each.</p><h3>Best Effort Regional Counter</h3><p>This type of counter is powered by <a href="https://netflixtechblog.com/announcing-evcache-distributed-in-memory-datastore-for-cloud-c26a698c27f7">EVCache</a>, Netflix’s distributed caching solution built on the widely popular <a href="https://memcached.org/">Memcached</a>. It is suitable for use cases like A/B experiments, where many concurrent experiments are run for relatively short durations and an approximate count is sufficient. Setting aside the complexities of provisioning, resource allocation, and control plane management, the core of this solution is remarkably straightforward:</p><pre>// counter cache key<br>counterCacheKey = &lt;namespace&gt;:&lt;counter_name&gt;<br><br>// add operation<br>return delta &gt; 0<br>    ? cache.incr(counterCacheKey, delta, TTL)<br>    : cache.decr(counterCacheKey, Math.abs(delta), TTL);<br><br>// get operation<br>cache.get(counterCacheKey);<br><br>// clear counts from all replicas<br>cache.delete(counterCacheKey, ReplicaPolicy.ALL);</pre><p>EVCache delivers extremely high throughput at low millisecond latency or better within a single region, enabling a multi-tenant setup within a shared cluster, saving infrastructure costs. However, there are some trade-offs: it lacks cross-region replication for the <em>increment</em> operation and does not provide <a href="https://netflix.github.io/EVCache/features/#consistency">consistency guarantees</a>, which may be necessary for an accurate count. Additionally, idempotency is not natively supported, making it unsafe to retry or hedge requests.</p><p><strong><em>Edit</em>: A note on probabilistic data structures:</strong></p><p>Probabilistic data structures like <a href="https://en.wikipedia.org/wiki/HyperLogLog">HyperLogLog</a> (HLL) can be useful for tracking an approximate number of distinct elements, like distinct views or visits to a website, but are not ideally suited for implementing distinct increments and decrements for a given key. <a href="https://en.wikipedia.org/wiki/Count%E2%80%93min_sketch">Count-Min Sketch</a> (CMS) is an alternative that can be used to adjust the values of keys by a given amount. Data stores like <a href="https://redis.io/">Redis</a> support both <a href="https://redis.io/docs/latest/develop/data-types/probabilistic/hyperloglogs/">HLL</a> and <a href="https://redis.io/docs/latest/develop/data-types/probabilistic/count-min-sketch/">CMS</a>. However, we chose not to pursue this direction for several reasons:</p><ul><li>We chose to build on top of data stores that we already operate at scale.</li><li>Probabilistic data structures do not natively support several of our requirements, such as resetting the count for a given key or having TTLs for counts. Additional data structures, including more sketches, would be needed to support these requirements.</li><li>On the other hand, the EVCache solution is quite simple, requiring minimal lines of code and using natively supported elements. However, it comes at the trade-off of using a small amount of memory per counter key.</li></ul><h3>Eventually Consistent Global Counter</h3><p>While some users may accept the limitations of a Best-Effort counter, others opt for precise counts, durability and global availability. In the following sections, we’ll explore various strategies for achieving durable and accurate counts. Our objective is to highlight the challenges inherent in global distributed counting and explain the reasoning behind our chosen approach.</p><p><strong>Approach 1: Storing a Single Row per Counter</strong></p><p>Let’s start simple by using a single row per counter key within a table in a globally replicated datastore.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*X6k4-4N36IQ5yEPe" /></figure><p>Let’s examine some of the drawbacks of this approach:</p><ul><li><strong>Lack of Idempotency</strong>: There is no idempotency key baked into the storage data-model preventing users from safely retrying requests. Implementing idempotency would likely require using an external system for such keys, which can further degrade performance or cause race conditions.</li><li><strong>Heavy Contention</strong>: To update counts reliably, every writer must perform a Compare-And-Swap operation for a given counter using locks or transactions. Depending on the throughput and concurrency of operations, this can lead to significant contention, heavily impacting performance.</li></ul><p><strong>Secondary Keys</strong>: One way to reduce contention in this approach would be to use a secondary key, such as a <em>bucket_id</em>, which allows for distributing writes by splitting a given counter into <em>buckets</em>, while enabling reads to aggregate across buckets. The challenge lies in determining the appropriate number of buckets. A static number may still lead to contention with <em>hot keys</em>, while dynamically assigning the number of buckets per counter across millions of counters presents a more complex problem.</p><p>Let’s see if we can iterate on our solution to overcome these drawbacks.</p><p><strong>Approach 2: Per Instance Aggregation</strong></p><p>To address issues of hot keys and contention from writing to the same row in real-time, we could implement a strategy where each instance aggregates the counts in memory and then flushes them to disk at regular intervals. Introducing sufficient jitter to the flush process can further reduce contention.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*6iUKbxJ093jJTiYL" /></figure><p>However, this solution presents a new set of issues:</p><ul><li><strong>Vulnerability to Data Loss</strong>: The solution is vulnerable to data loss for all in-memory data during instance failures, restarts, or deployments.</li><li><strong>Inability to Reliably Reset Counts</strong>: Due to counting requests being distributed across multiple machines, it is challenging to establish consensus on the exact point in time when a counter reset occurred.</li><li><strong>Lack of Idempotency: </strong>Similar to the previous approach, this method does not natively guarantee idempotency. One way to achieve idempotency is by consistently routing the same set of counters to the same instance. However, this approach may introduce additional complexities, such as leader election, and potential challenges with availability and latency in the write path.</li></ul><p>That said, this approach may still be suitable in scenarios where these trade-offs are acceptable. However, let’s see if we can address some of these issues with a different event-based approach.</p><p><strong>Approach 3: Using Durable Queues</strong></p><p>In this approach, we log counter events into a durable queuing system like <a href="https://kafka.apache.org/">Apache Kafka</a> to prevent any potential data loss. By creating multiple topic partitions and hashing the counter key to a specific partition, we ensure that the same set of counters are processed by the same set of consumers. This setup simplifies facilitating idempotency checks and resetting counts. Furthermore, by leveraging additional stream processing frameworks such as <a href="https://kafka.apache.org/documentation/streams/">Kafka Streams</a> or <a href="https://flink.apache.org/">Apache Flink</a>, we can implement windowed aggregations.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*mQikuGyuzZ_lT7Y4" /></figure><p>However, this approach comes with some challenges:</p><ul><li><strong>Potential Delays</strong>: Having the same consumer process all the counts from a given partition can lead to backups and delays, resulting in stale counts.</li><li><strong>Rebalancing Partitions</strong>: This approach requires auto-scaling and rebalancing of topic partitions as the cardinality of counters and throughput increases.</li></ul><p>Furthermore, all approaches that pre-aggregate counts make it challenging to support two of our requirements for accurate counters:</p><ul><li><strong>Auditing of Counts</strong>: Auditing involves extracting data to an offline system for analysis to ensure that increments were applied correctly to reach the final value. This process can also be used to track the provenance of increments. However, auditing becomes infeasible when counts are aggregated without storing the individual increments.</li><li><strong>Potential Recounting</strong>: Similar to auditing, if adjustments to increments are necessary and recounting of events within a time window is required, pre-aggregating counts makes this infeasible.</li></ul><p>Barring those few requirements, this approach can still be effective if we determine the right way to scale our queue partitions and consumers while maintaining idempotency. However, let’s explore how we can adjust this approach to meet the auditing and recounting requirements.</p><p><strong>Approach 4: Event Log of Individual Increments</strong></p><p>In this approach, we log each individual counter increment along with its <strong>event_time</strong> and <strong>event_id</strong>. The event_id can include the source information of where the increment originated. The combination of event_time and event_id can also serve as the idempotency key for the write.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*0wKFK7xyTHnEKIhO" /></figure><p>However, <em>in its simplest form</em>, this approach has several drawbacks:</p><ul><li><strong>Read Latency</strong>: Each read request requires scanning all increments for a given counter potentially degrading performance.</li><li><strong>Duplicate Work</strong>: Multiple threads might duplicate the effort of aggregating the same set of counters during read operations, leading to wasted effort and subpar resource utilization.</li><li><strong>Wide Partitions</strong>: If using a datastore like <a href="https://cassandra.apache.org/_/index.html">Apache Cassandra</a>, storing many increments for the same counter could lead to a <a href="https://thelastpickle.com/blog/2019/01/11/wide-partitions-cassandra-3-11.html">wide partition</a>, affecting read performance.</li><li><strong>Large Data Footprint</strong>: Storing each increment individually could also result in a substantial data footprint over time. Without an efficient data retention strategy, this approach may struggle to scale effectively.</li></ul><p>The combined impact of these issues can lead to increased infrastructure costs that may be difficult to justify. However, adopting an event-driven approach seems to be a significant step forward in addressing some of the challenges we’ve encountered and meeting our requirements.</p><p>How can we improve this solution further?</p><h3>Netflix’s Approach</h3><p>We use a combination of the previous approaches, where we log each counting activity as an event, and continuously aggregate these events in the background using queues and a sliding time window. Additionally, we employ a bucketing strategy to prevent wide partitions. In the following sections, we’ll explore how this approach addresses the previously mentioned drawbacks and meets all our requirements.</p><p><strong>Note</strong>: <em>From here on, we will use the words “</em><strong><em>rollup</em></strong><em>” and “</em><strong><em>aggregate</em></strong><em>” interchangeably. They essentially mean the same thing, i.e., collecting individual counter increments/decrements and arriving at the final value.</em></p><p><strong>TimeSeries Event Store:</strong></p><p>We chose the <a href="https://netflixtechblog.com/introducing-netflix-timeseries-data-abstraction-layer-31552f6326f8">TimeSeries Data Abstraction</a> as our event store, where counter mutations are ingested as event records. Some of the benefits of storing events in TimeSeries include:</p><p><strong>High-Performance</strong>: The TimeSeries abstraction already addresses many of our requirements, including high availability and throughput, reliable and fast performance, and more.</p><p><strong>Reducing Code Complexity</strong>: We reduce a lot of code complexity in Counter Abstraction by delegating a major portion of the functionality to an existing service.</p><p>TimeSeries Abstraction uses Cassandra as the underlying event store, but it can be configured to work with any persistent store. Here is what it looks like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ge4X7ywSmtizcNE5" /></figure><p><strong>Handling Wide Partitions</strong>: The <em>time_bucket</em> and <em>event_bucket</em> columns play a crucial role in breaking up a wide partition, preventing high-throughput counter events from overwhelming a given partition. <em>For more information regarding this, refer to our previous </em><a href="https://netflixtechblog.com/introducing-netflix-timeseries-data-abstraction-layer-31552f6326f8"><em>blog</em></a>.</p><p><strong>No Over-Counting</strong>: The <em>event_time</em>, <em>event_id</em> and <em>event_item_key</em> columns form the idempotency key for the events for a given counter, enabling clients to retry safely without the risk of over-counting.</p><p><strong>Event Ordering</strong>: TimeSeries orders all events in descending order of time allowing us to leverage this property for events like count resets.</p><p><strong>Event Retention</strong>: The TimeSeries Abstraction includes retention policies to ensure that events are not stored indefinitely, saving disk space and reducing infrastructure costs. Once events have been aggregated and moved to a more cost-effective store for audits, there’s no need to retain them in the primary storage.</p><p>Now, let’s see how these events are aggregated for a given counter.</p><p><strong>Aggregating Count Events:</strong></p><p>As mentioned earlier, collecting all individual increments for every read request would be cost-prohibitive in terms of read performance. Therefore, a background aggregation process is necessary to continually converge counts and ensure optimal read performance.</p><p><em>But how can we safely aggregate count events amidst ongoing write operations?</em></p><p>This is where the concept of <em>Eventually Consistent </em>counts becomes crucial. <em>By intentionally lagging behind the current time by a safe margin</em>, we ensure that aggregation always occurs within an immutable window.</p><p>Lets see what that looks like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*EOpW-VnA_YZF7KOP" /></figure><p>Let’s break this down:</p><ul><li><strong>lastRollupTs</strong>: This represents the most recent time when the counter value was last aggregated. For a counter being operated for the first time, this timestamp defaults to a reasonable time in the past.</li><li><strong>Immutable Window and Lag</strong>: Aggregation can only occur safely within an immutable window that is no longer receiving counter events. The “acceptLimit” parameter of the TimeSeries Abstraction plays a crucial role here, as it rejects incoming events with timestamps beyond this limit. During aggregations, this window is pushed slightly further back to account for clock skews.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1008/0*DbtPCHPWoaauUkDr" /></figure><p>This does mean that the counter value will lag behind its most recent update by some margin (typically in the order of seconds). <em>This approach does leave the door open for missed events due to cross-region replication issues. See “Future Work” section at the end.</em></p><ul><li><strong>Aggregation Process</strong>: The rollup process aggregates all events in the aggregation window <em>since the last rollup </em>to arrive at the new value.</li></ul><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*oSHneX5BOi5VNGYM" /></figure><p><strong>Rollup Store:</strong></p><p>We save the results of this aggregation in a persistent store. The next aggregation will simply continue from this checkpoint.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*93S_a1YJ6zacuBnn" /></figure><p>We create one such Rollup table <em>per dataset</em> and use Cassandra as our persistent store. However, as you will soon see in the Control Plane section, the Counter service can be configured to work with any persistent store.</p><p><strong>LastWriteTs</strong>: Every time a given counter receives a write, we also log a <strong>last-write-timestamp</strong> as a columnar update in this table. This is done using Cassandra’s <a href="https://docs.datastax.com/en/cql-oss/3.x/cql/cql_reference/cqlInsert.html#cqlInsert__timestamp-value">USING TIMESTAMP</a> feature to predictably apply the Last-Write-Win (LWW) semantics. This timestamp is the same as the <em>event_time</em> for the event. In the subsequent sections, we’ll see how this timestamp is used to keep some counters in active rollup circulation until they have caught up to their latest value.</p><p><strong>Rollup Cache</strong></p><p>To optimize read performance, these values are cached in EVCache for each counter. We combine the <strong>lastRollupCount</strong> and <strong>lastRollupTs</strong> <em>into a single cached value per counter</em> to prevent potential mismatches between the count and its corresponding checkpoint timestamp.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*giCU1AtWUYMXHZcI" /></figure><p>But, how do we know which counters to trigger rollups for? Let’s explore our Write and Read path to understand this better.</p><p><strong>Add/Clear Count:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*wsxgnWH1yR0gHAEL" /></figure><p>An <em>add</em> or <em>clear</em> count request writes durably to the TimeSeries Abstraction and updates the last-write-timestamp in the Rollup store. If the durability acknowledgement fails, clients can retry their requests with the same idempotency token without the risk of overcounting.<strong> </strong>Upon durability, we send a <em>fire-and-forget </em>request to trigger the rollup for the request counter.</p><p><strong>GetCount:</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*76pQR6OISx9yuRmi" /></figure><p>We return the last rolled-up count as<em> a quick point-read operation</em>, accepting the trade-off of potentially delivering a slightly stale count. We also trigger a rollup during the read operation to advance the last-rollup-timestamp, enhancing the performance of <em>subsequent</em> aggregations. This process also <em>self-remediates </em>a stale count if any previous rollups had failed.</p><p>With this approach, the counts<em> continually converge</em> to their latest value. Now, let’s see how we scale this approach to millions of counters and thousands of concurrent operations using our Rollup Pipeline.</p><p><strong>Rollup Pipeline:</strong></p><p>Each <strong>Counter-Rollup</strong> server operates a rollup pipeline to efficiently aggregate counts across millions of counters. This is where most of the complexity in Counter Abstraction comes in. In the following sections, we will share key details on how efficient aggregations are achieved.</p><p><strong>Light-Weight Roll-Up Event: </strong>As seen in our Write and Read paths above, every operation on a counter sends a light-weight event to the Rollup server:</p><pre>rollupEvent: {<br>  &quot;namespace&quot;: &quot;my_dataset&quot;,<br>  &quot;counter&quot;: &quot;counter123&quot;<br>}</pre><p>Note that this event does not include the increment. This is only an indication to the Rollup server that this counter has been accessed and now needs to be aggregated. Knowing exactly which specific counters need to be aggregated prevents scanning the entire event dataset for the purpose of aggregations.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Yusg6kC9Jj9ayjbi" /></figure><p><strong>In-Memory Rollup Queues:</strong> A given Rollup server instance runs a set of <em>in-memory</em> queues to receive rollup events and parallelize aggregations. In the first version of this service, we settled on using in-memory queues to reduce provisioning complexity, save on infrastructure costs, and make rebalancing the number of queues fairly straightforward. However, this comes with the trade-off of potentially missing rollup events in case of an instance crash. For more details, see the “Stale Counts” section in “Future Work.”</p><p><strong>Minimize Duplicate Effort</strong>: We use a fast non-cryptographic hash like <a href="https://xxhash.com/">XXHash</a> to ensure that the same set of counters end up on the same queue. Further, we try to minimize the amount of duplicate aggregation work by having a separate rollup stack that chooses to run <em>fewer</em> <em>beefier</em> instances.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*u3p0kGfuwvK5mP_j" /></figure><p><strong>Availability and Race Conditions: </strong>Having a single Rollup server instance can minimize duplicate aggregation work but may create availability challenges for triggering rollups. <em>If</em> we choose to horizontally scale the Rollup servers, we allow threads to overwrite rollup values while avoiding any form of distributed locking mechanisms to maintain high availability and performance. This approach remains safe because aggregation occurs within an immutable window. Although the concept of <em>now()</em> may differ between threads, causing rollup values to sometimes fluctuate, the counts will eventually converge to an accurate value within each immutable aggregation window.</p><p><strong>Rebalancing Queues</strong>: If we need to scale the number of queues, a simple Control Plane configuration update followed by a re-deploy is enough to rebalance the number of queues.</p><pre>      &quot;eventual_counter_config&quot;: {             <br>          &quot;queue_config&quot;: {                    <br>            &quot;num_queues&quot; : 8,  // change to 16 and re-deploy<br>...</pre><p><strong>Handling Deployments</strong>: During deployments, these queues shut down gracefully, draining all existing events first, while the new Rollup server instance starts up with potentially new queue configurations. There may be a brief period when both the old and new Rollup servers are active, but as mentioned before, this race condition is managed since aggregations occur within immutable windows.</p><p><strong>Minimize Rollup Effort</strong>: Receiving multiple events for the same counter doesn’t mean rolling it up multiple times. We drain these rollup events into a Set, ensuring <em>a given counter is rolled up only once</em> <em>during a rollup window</em>.</p><p><strong>Efficient Aggregation: </strong>Each rollup consumer processes a batch of counters simultaneously. Within each batch, it queries the underlying TimeSeries abstraction in parallel to aggregate events within specified time boundaries. The TimeSeries abstraction optimizes these range scans to achieve low millisecond latencies.</p><p><strong>Dynamic Batching</strong>: The Rollup server dynamically adjusts the number of time partitions that need to be scanned based on cardinality of counters in order to prevent overwhelming the underlying store with many parallel read requests.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*hoPpSmQeScn87q0U" /></figure><p><strong>Adaptive Back-Pressure</strong>: Each consumer waits for one batch to complete before issuing the rollups for the next batch. It adjusts the wait time between batches based on the performance of the previous batch. This approach provides back-pressure during rollups to prevent overwhelming the underlying TimeSeries store.</p><p><strong>Handling Convergence</strong>:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*-hlw324cMUaC6pQJ" /></figure><p>In order to prevent <strong>low-cardinality</strong> counters from lagging behind too much and subsequently scanning too many time partitions, they are kept in constant rollup circulation. For <strong>high-cardinality</strong> counters, continuously circulating them would consume excessive memory in our Rollup queues. This is where the <strong>last-write-timestamp</strong> mentioned previously plays a crucial role. The Rollup server inspects this timestamp to determine if a given counter needs to be re-queued, ensuring that we continue aggregating until it has fully caught up with the writes.</p><p>Now, let’s see how we leverage this counter type to provide an up-to-date current count in near-realtime.</p><h3>Experimental: Accurate Global Counter</h3><p>We are experimenting with a slightly modified version of the Eventually Consistent counter. Again, take the term ‘Accurate’ with a grain of salt. The key difference between this type of counter and its counterpart is that the <em>delta</em>, representing the counts since the last-rolled-up timestamp, is computed in real-time.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*FVOlMO0VgrQoVBBi" /></figure><p>And then, <em>currentAccurateCount = lastRollupCount + delta</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*M3dbSof98dTfeuNe" /></figure><p>Aggregating this delta in real-time can impact the performance of this operation, depending on the number of events and partitions that need to be scanned to retrieve this delta. The same principle of rolling up in batches applies here to prevent scanning too many partitions in parallel. Conversely, if the counters in this dataset are<em> </em>accessed<em> </em>frequently, the time gap for the delta remains narrow, making this approach of fetching current counts quite effective.</p><p>Now, let’s see how all this complexity is managed by having a unified Control Plane configuration.</p><h3>Control Plane</h3><p>The <a href="https://netflixtechblog.medium.com/data-gateway-a-platform-for-growing-and-protecting-the-data-tier-f1ed8db8f5c6">Data Gateway Platform Control Plane</a> manages control settings for all abstractions and namespaces, including the Counter Abstraction. Below, is an example of a control plane configuration for a namespace that supports eventually consistent counters with low cardinality:</p><pre>&quot;persistence_configuration&quot;: [<br>  {<br>    &quot;id&quot;: &quot;CACHE&quot;,                             // Counter cache config<br>    &quot;scope&quot;: &quot;dal=counter&quot;,                                                   <br>    &quot;physical_storage&quot;: {<br>      &quot;type&quot;: &quot;EVCACHE&quot;,                       // type of cache storage<br>      &quot;cluster&quot;: &quot;evcache_dgw_counter_tier1&quot;   // Shared EVCache cluster<br>    }<br>  },<br>  {<br>    &quot;id&quot;: &quot;COUNTER_ROLLUP&quot;,<br>    &quot;scope&quot;: &quot;dal=counter&quot;,                    // Counter abstraction config<br>    &quot;physical_storage&quot;: {                     <br>      &quot;type&quot;: &quot;CASSANDRA&quot;,                     // type of Rollup store<br>      &quot;cluster&quot;: &quot;cass_dgw_counter_uc1&quot;,       // physical cluster name<br>      &quot;dataset&quot;: &quot;my_dataset_1&quot;                // namespace/dataset   <br>    },<br>    &quot;counter_cardinality&quot;: &quot;LOW&quot;,              // supported counter cardinality<br>    &quot;config&quot;: {<br>      &quot;counter_type&quot;: &quot;EVENTUAL&quot;,              // Type of counter<br>      &quot;eventual_counter_config&quot;: {             // eventual counter type<br>        &quot;internal_config&quot;: {                  <br>          &quot;queue_config&quot;: {                    // adjust w.r.t cardinality<br>            &quot;num_queues&quot; : 8,                  // Rollup queues per instance<br>            &quot;coalesce_ms&quot;: 10000,              // coalesce duration for rollups<br>            &quot;capacity_bytes&quot;: 16777216         // allocated memory per queue<br>          },<br>          &quot;rollup_batch_count&quot;: 32             // parallelization factor<br>        }<br>      }<br>    }<br>  },<br>  {<br>    &quot;id&quot;: &quot;EVENT_STORAGE&quot;,<br>    &quot;scope&quot;: &quot;dal=ts&quot;,                         // TimeSeries Event store<br>    &quot;physical_storage&quot;: {<br>      &quot;type&quot;: &quot;CASSANDRA&quot;,                     // persistent store type<br>      &quot;cluster&quot;: &quot;cass_dgw_counter_uc1&quot;,       // physical cluster name<br>      &quot;dataset&quot;: &quot;my_dataset_1&quot;,               // keyspace name<br>    },<br>    &quot;config&quot;: {                              <br>      &quot;time_partition&quot;: {                      // time-partitioning for events<br>        &quot;buckets_per_id&quot;: 4,                   // event buckets within<br>        &quot;seconds_per_bucket&quot;: &quot;600&quot;,           // smaller width for LOW card<br>        &quot;seconds_per_slice&quot;: &quot;86400&quot;,          // width of a time slice table<br>      },<br>      &quot;accept_limit&quot;: &quot;5s&quot;,                    // boundary for immutability<br>    },<br>    &quot;lifecycleConfigs&quot;: {<br>      &quot;lifecycleConfig&quot;: [<br>        {<br>          &quot;type&quot;: &quot;retention&quot;,                 // Event retention<br>          &quot;config&quot;: {<br>            &quot;close_after&quot;: &quot;518400s&quot;,<br>            &quot;delete_after&quot;: &quot;604800s&quot;          // 7 day count event retention<br>          }<br>        }<br>      ]<br>    }<br>  }<br>]</pre><p>Using such a control plane configuration, we compose multiple abstraction layers using containers deployed on the same host, with each container fetching configuration specific to its scope.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/853/0*4MdrlEjWg2MXU9S3" /></figure><h3>Provisioning</h3><p>As with the TimeSeries abstraction, our automation uses a bunch of user inputs regarding their workload and cardinalities to arrive at the right set of infrastructure and related control plane configuration. You can learn more about this process in a talk given by one of our stunning colleagues, <a href="https://www.linkedin.com/in/joseph-lynch-9976a431/">Joey Lynch</a> : <a href="https://www.youtube.com/watch?v=Lf6B1PxIvAs">How Netflix optimally provisions infrastructure in the cloud</a>.</p><h3>Performance</h3><p>At the time of writing this blog, this service was processing close to <strong>75K count requests/second</strong><em> globally</em> across the different API endpoints and datasets:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*1h_af4Kk3YrZrqlc" /></figure><p>while providing<strong> single-digit millisecond</strong> latencies for all its endpoints:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*UnI7eore6gvuqrrF" /></figure><h3>Future Work</h3><p>While our system is robust, we still have work to do in making it more reliable and enhancing its features. Some of that work includes:</p><ul><li><strong>Regional Rollups: </strong>Cross-region replication issues can result in missed events from other regions. An alternate strategy involves establishing a rollup table for each region, and then tallying them in a global rollup table. A key challenge in this design would be effectively communicating the clearing of the counter across regions.</li><li><strong>Error Detection and Stale Counts</strong>: Excessively stale counts can occur if rollup events are lost or if a rollup fails and isn’t retried. This isn’t an issue for frequently accessed counters, as they remain in rollup circulation. This issue is more pronounced for counters that aren’t accessed frequently. Typically, the initial read for such a counter will trigger a rollup,<em> self-remediating </em>the issue. However, for use cases that cannot accept potentially stale initial reads, we plan to implement improved error detection, rollup handoffs, and durable queues for resilient retries.</li></ul><h3>Conclusion</h3><p>Distributed counting remains a challenging problem in computer science. In this blog, we explored multiple approaches to implement and deploy a Counting service at scale. While there may be other methods for distributed counting, our goal has been to deliver blazing fast performance at low infrastructure costs while maintaining high availability and providing idempotency guarantees. Along the way, we make various trade-offs to meet the diverse counting requirements at Netflix. We hope you found this blog post insightful.</p><p>Stay tuned for <strong>Part 3 </strong>of Composite Abstractions at Netflix, where we’ll introduce our <strong>Graph Abstraction</strong>, a new service being built on top of the <a href="https://netflixtechblog.com/introducing-netflixs-key-value-data-abstraction-layer-1ea8a0a11b30">Key-Value Abstraction</a> <em>and</em> the <a href="https://netflixtechblog.com/introducing-netflix-timeseries-data-abstraction-layer-31552f6326f8">TimeSeries Abstraction</a> to handle high-throughput, low-latency graphs.</p><h3>Acknowledgments</h3><p>Special thanks to our stunning colleagues who contributed to the Counter Abstraction’s success: <a href="https://www.linkedin.com/in/joseph-lynch-9976a431/">Joey Lynch</a>, <a href="https://www.linkedin.com/in/vinaychella/">Vinay Chella</a>, <a href="https://www.linkedin.com/in/kaidanfullerton/">Kaidan Fullerton</a>, <a href="https://www.linkedin.com/in/tomdevoe/">Tom DeVoe</a>, <a href="https://www.linkedin.com/in/mengqingwang/">Mengqing Wang</a>, <a href="https://www.linkedin.com/in/varun-khaitan/">Varun Khaitan</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=8d0c45eb66b2" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/netflixs-distributed-counter-abstraction-8d0c45eb66b2">Netflix’s Distributed Counter Abstraction</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Investigation of a Workbench UI Latency Issue]]></title>
            <link>https://netflixtechblog.com/investigation-of-a-workbench-ui-latency-issue-faa017b4653d?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/faa017b4653d</guid>
            <category><![CDATA[debugging]]></category>
            <category><![CDATA[cpu]]></category>
            <category><![CDATA[jupyter-notebook]]></category>
            <category><![CDATA[performance]]></category>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Mon, 14 Oct 2024 20:02:47 GMT</pubDate>
            <atom:updated>2024-10-14T20:02:31.194Z</atom:updated>
            <content:encoded><![CDATA[<p>By: <a href="https://www.linkedin.com/in/hechaoli/">Hechao Li</a> and <a href="https://www.linkedin.com/in/mayworm/">Marcelo Mayworm</a></p><p>With special thanks to our stunning colleagues <a href="https://www.linkedin.com/in/amer-ather-9071181/">Amer Ather</a>, <a href="https://www.linkedin.com/in/itaydafna">Itay Dafna</a>, <a href="https://www.linkedin.com/in/lucaepozzi/">Luca Pozzi</a>, <a href="https://www.linkedin.com/in/matheusdeoleao/">Matheus Leão</a>, and <a href="https://www.linkedin.com/in/yeji682/">Ye Ji</a>.</p><h3>Overview</h3><p>At Netflix, the Analytics and Developer Experience organization, part of the Data Platform, offers a product called Workbench. Workbench is a remote development workspace based on<a href="https://netflixtechblog.com/titus-the-netflix-container-management-platform-is-now-open-source-f868c9fb5436"> Titus</a> that allows data practitioners to work with big data and machine learning use cases at scale. A common use case for Workbench is running<a href="https://jupyterlab.readthedocs.io/en/latest/"> JupyterLab</a> Notebooks.</p><p>Recently, several users reported that their JupyterLab UI becomes slow and unresponsive when running certain notebooks. This document details the intriguing process of debugging this issue, all the way from the UI down to the Linux kernel.</p><h3>Symptom</h3><p>Machine Learning engineer <a href="https://www.linkedin.com/in/lucaepozzi/">Luca Pozzi</a> reported to our Data Platform team that their <strong>JupyterLab UI on their workbench becomes slow and unresponsive when running some of their Notebooks.</strong> Restarting the <em>ipykernel</em> process, which runs the Notebook, might temporarily alleviate the problem, but the frustration persists as more notebooks are run.</p><h3>Quantify the Slowness</h3><p>While we observed the issue firsthand, the term “UI being slow” is subjective and difficult to measure. To investigate this issue, <strong>we needed a quantitative analysis of the slowness</strong>.</p><p><a href="https://www.linkedin.com/in/itaydafna">Itay Dafna</a> devised an effective and simple method to quantify the UI slowness. Specifically, we opened a terminal via JupyterLab and held down a key (e.g., “j”) for 15 seconds while running the user’s notebook. The input to stdin is sent to the backend (i.e., JupyterLab) via a WebSocket, and the output to stdout is sent back from the backend and displayed on the UI. We then exported the <em>.har </em>file recording all communications from the browser and loaded it into a Notebook for analysis.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ltV3CYtNjLCzolXD" /></figure><p>Using this approach, we observed latencies ranging from 1 to 10 seconds, averaging 7.4 seconds.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/704/0*H7KW62J0jZKPTjQH" /></figure><h3>Blame The Notebook</h3><p>Now that we have an objective metric for the slowness, let’s officially start our investigation. If you have read the symptom carefully, you must have noticed that the slowness only occurs when the user runs <strong>certain</strong> notebooks but not others.</p><p>Therefore, the first step is scrutinizing the specific Notebook experiencing the issue. Why does the UI always slow down after running this particular Notebook? Naturally, you would think that there must be something wrong with the code running in it.</p><p>Upon closely examining the user’s Notebook, we noticed a library called <em>pystan</em> , which provides Python bindings to a native C++ library called stan, looked suspicious. Specifically, <em>pystan</em> uses <em>asyncio</em>. However, <strong>because there is already an existing <em>asyncio</em> event loop running in the Notebook process and <em>asyncio</em> cannot be nested by design, in order for <em>pystan</em> to work, the authors of <em>pystan</em> </strong><a href="https://pystan.readthedocs.io/en/latest/faq.html#how-can-i-use-pystan-with-jupyter-notebook-or-jupyterlab"><strong>recommend</strong></a><strong> injecting <em>pystan</em> into the existing event loop by using a package called </strong><a href="https://pypi.org/project/nest-asyncio/"><strong><em>nest_asyncio</em></strong></a>, a library that became unmaintained because <a href="https://github.com/erdewit/ib_insync/commit/ef5ea29e44e0c40bbadbc16c2281b3ac58aa4a40">the author unfortunately passed away</a>.</p><p>Given this seemingly hacky usage, we naturally suspected that the events injected by <em>pystan</em> into the event loop were blocking the handling of the WebSocket messages used to communicate with the JupyterLab UI. This reasoning sounds very plausible. However, <strong>the user claimed that there were cases when a Notebook not using <em>pystan</em> runs, the UI also became slow</strong>.</p><p>Moreover, after several rounds of discussion with ChatGPT, we learned more about the architecture and realized that, in theory, <strong>the usage of <em>pystan</em> and <em>nest_asyncio</em> should not cause the slowness in handling the UI WebSocket</strong> for the following reasons:</p><p>Even though <em>pystan</em> uses <em>nest_asyncio</em> to inject itself into the main event loop, <strong>the Notebook runs on a child process (i.e.</strong>,<strong> the <em>ipykernel</em> process) of the <em>jupyter-lab</em> server process</strong>, which means the main event loop being injected by <em>pystan</em> is that of the <em>ipykernel</em> process, not the <em>jupyter-server</em> process. Therefore, even if <em>pystan</em> blocks the event loop, it shouldn’t impact the <em>jupyter-lab</em> main event loop that is used for UI websocket communication. See the diagram below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/738/0*DsQuZV5qnRXp5mVw" /></figure><p>In other words, <strong><em>pystan</em> events are injected to the event loop B in this diagram instead of event loop A</strong>. So, it shouldn’t block the UI WebSocket events.</p><p>You might also think that because event loop A handles both the WebSocket events from the UI and the ZeroMQ socket events from the <em>ipykernel</em> process, a high volume of ZeroMQ events generated by the notebook could block the WebSocket. However, <strong>when we captured packets on the ZeroMQ socket while reproducing the issue, we didn’t observe heavy traffic on this socket that could cause such blocking</strong>.</p><p>A stronger piece of evidence to rule out <em>pystan</em> was that we were ultimately able to reproduce the issue even without it, which I’ll dive into later.</p><h3>Blame Noisy Neighbors</h3><p>The Workbench instance runs as a <a href="https://netflixtechblog.com/titus-the-netflix-container-management-platform-is-now-open-source-f868c9fb5436">Titus container</a>. To efficiently utilize our compute resources, <strong>Titus employs a CPU oversubscription feature</strong>, meaning the combined virtual CPUs allocated to containers exceed the number of available physical CPUs on a Titus agent. <strong>If a container is unfortunate enough to be scheduled alongside other “noisy” containers — those that consume a lot of CPU resources — it could suffer from CPU deficiency.</strong></p><p>However, after examining the CPU utilization of neighboring containers on the same Titus agent as the Workbench instance, as well as the overall CPU utilization of the Titus agent, we quickly ruled out this hypothesis. Using the top command on the Workbench, we observed that when running the Notebook, <strong>the Workbench instance uses only 4 out of the 64 CPUs allocated to it</strong>. Simply put, <strong>this workload is not CPU-bound.</strong></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/892/0*YXsntKLiontnkNhf" /></figure><h3>Blame The Network</h3><p>The next theory was that the network between the web browser UI (on the laptop) and the JupyterLab server was slow. To investigate, we <strong>captured all the packets between the laptop and the server</strong> while running the Notebook and continuously pressing ‘j’ in the terminal.</p><p>When the UI experienced delays, we observed a 5-second pause in packet transmission from server port 8888 to the laptop. Meanwhile,<strong> traffic from other ports, such as port 22 for SSH, remained unaffected</strong>. This led us to conclude that the pause was caused by the application running on port 8888 (i.e., the JupyterLab process) rather than the network.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*c660xBwF4XuCA8KN" /></figure><h3>The Minimal Reproduction</h3><p>As previously mentioned, another strong piece of evidence proving the innocence of pystan was that we could reproduce the issue without it. By gradually stripping down the “bad” Notebook, we eventually arrived at a minimal snippet of code that reproduces the issue without any third-party dependencies or complex logic:</p><pre>import time<br>import os<br>from multiprocessing import Process<br><br>N = os.cpu_count()<br><br>def launch_worker(worker_id):<br>  time.sleep(60)<br><br>if __name__ == &#39;__main__&#39;:<br>  with open(&#39;/root/2GB_file&#39;, &#39;r&#39;) as file:<br>    data = file.read()<br>    processes = []<br>    for i in range(N):<br>      p = Process(target=launch_worker, args=(i,))<br>      processes.append(p)<br>      p.start()<br> <br>    for p in processes:<br>      p.join()</pre><p>The code does only two things:</p><ol><li>Read a 2GB file into memory (the Workbench instance has 480G memory in total so this memory usage is almost negligible).</li><li>Start N processes where N is the number of CPUs. The N processes do nothing but sleep.</li></ol><p>There is no doubt that this is the most silly piece of code I’ve ever written. It is neither CPU bound nor memory bound. Yet <strong>it can cause the JupyterLab UI to stall for as many as 10 seconds!</strong></p><h3>Questions</h3><p>There are a couple of interesting observations that raise several questions:</p><ul><li>We noticed that <strong>both steps are required in order to reproduce the issue</strong>. If you don’t read the 2GB file (that is not even used!), the issue is not reproducible. <strong>Why using 2GB out of 480GB memory could impact the performance?</strong></li><li><strong>When the UI delay occurs, the <em>jupyter-lab</em> process CPU utilization spikes to 100%</strong>, hinting at contention on the single-threaded event loop in this process (event loop A in the diagram before). <strong>What does the <em>jupyter-lab</em> process need the CPU for, given that it is not the process that runs the Notebook?</strong></li><li>The code runs in a Notebook, which means it runs in the <em>ipykernel</em> process, that is a child process of the <em>jupyter-lab</em> process. <strong>How can anything that happens in a child process cause the parent process to have CPU contention?</strong></li><li>The workbench has 64CPUs. But when we printed <em>os.cpu_count()</em>, the output was 96. That means <strong>the code starts more processes than the number of CPUs</strong>. <strong>Why is that?</strong></li></ul><p>Let’s answer the last question first. In fact, if you run <em>lscpu</em> and <em>nproc</em> commands inside a Titus container, you will also see different results — the former gives you 96, which is the number of physical CPUs on the Titus agent, whereas the latter gives you 64, which is the number of virtual CPUs allocated to the container. This discrepancy is due to the lack of a “CPU namespace” in the Linux kernel, causing the number of physical CPUs to be leaked to the container when calling certain functions to get the CPU count. The assumption here is that Python <strong><em>os.cpu_count()</em> uses the same function as the <em>lscpu</em> command, causing it to get the CPU count of the host instead of the container</strong>. Python 3.13 has <a href="https://docs.python.org/3.13/library/os.html#os.process_cpu_count">a new call that can be used to get the accurate CPU count</a>, but it’s not GA’ed yet.</p><p>It will be proven later that this inaccurate number of CPUs can be a contributing factor to the slowness.</p><h3>More Clues</h3><p>Next, we used <em>py-spy</em> to do a profiling of the <em>jupyter-lab</em> process. Note that we profiled the parent <em>jupyter-lab </em>process, <strong>not</strong> the <em>ipykernel</em> child process that runs the reproduction code. The profiling result is as follows:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ho2C4015Disa8aFv" /></figure><p>As one can see, <strong>a lot of CPU time (89%!!) is spent on a function called <em>__parse_smaps_rollup</em></strong>. In comparison, the terminal handler used only 0.47% CPU time. From the stack trace, we see that <strong>this function is inside the event loop A</strong>,<strong> so it can definitely cause the UI WebSocket events to be delayed</strong>.</p><p>The stack trace also shows that this function is ultimately called by a function used by a Jupyter lab extension called <em>jupyter_resource_usage</em>. <strong>We then disabled this extension and restarted the <em>jupyter-lab</em> process. As you may have guessed, we could no longer reproduce the slowness!</strong></p><p>But our puzzle is not solved yet. Why does this extension cause the UI to slow down? Let’s keep digging.</p><h3>Root Cause Analysis</h3><p>From the name of the extension and the names of the other functions it calls, we can infer that this extension is used to get resources such as CPU and memory usage information. Examining the code, we see that this function call stack is triggered when an API endpoint <em>/metrics/v1</em> is called from the UI. <strong>The UI apparently calls this function periodically</strong>, according to the network traffic tab in Chrome’s Developer Tools.</p><p>Now let’s look at the implementation starting from the call <em>get(jupter_resource_usage/api.py:42)</em> . The full code is <a href="https://github.com/jupyter-server/jupyter-resource-usage/blob/6f15ef91d5c7e50853516b90b5e53b3913d2ed34/jupyter_resource_usage/api.py#L28">here</a> and the key lines are shown below:</p><pre>cur_process = psutil.Process()<br>all_processes = [cur_process] + cur_process.children(recursive=True)<br><br>for p in all_processes:<br>  info = p.memory_full_info()</pre><p>Basically, it gets all children processes of the <em>jupyter-lab</em> process recursively, including both the <em>ipykernel</em> Notebook process and all processes created by the Notebook. Obviously, <strong>the cost of this function is linear to the number of all children processes</strong>. In the reproduction code, we create 96 processes. So here we will have at least 96 (sleep processes) + 1 (<em>ipykernel</em> process) + 1 (<em>jupyter-lab</em> process) = 98 processes when it should actually be 64 (allocated CPUs) + 1 (<em>ipykernel</em> process) + 1 <em>(jupyter-lab</em> process) = 66 processes, because the number of CPUs allocated to the container is, in fact, 64.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/971/0*sHTjycVMUk1yVAsk" /></figure><p>This is truly ironic. <strong>The more CPUs we have, the slower we are!</strong></p><p>At this point, we have answered one question: <strong>Why does starting many grandchildren processes in the child process cause the parent process to be slow? </strong>Because the parent process runs a function that’s linear to the number all children process recursively.</p><p>However, this solves only half of the puzzle. If you remember the previous analysis, <strong>starting many child processes ALONE doesn’t reproduce the issue</strong>. If we don’t read the 2GB file, even if we create 2x more processes, we can’t reproduce the slowness.</p><p>So now we must answer the next question: <strong>Why does reading a 2GB file in the child process affect the parent process performance, </strong>especially when the workbench has as much as 480GB memory in total?</p><p>To answer this question, let’s look closely at the function <em>__parse_smaps_rollup</em>. As the name implies, <a href="https://github.com/giampaolo/psutil/blob/c034e6692cf736b5e87d14418a8153bb03f6cf42/psutil/_pslinux.py#L1978">this function</a> parses the file <em>/proc/&lt;pid&gt;/smaps_rollup</em>.</p><pre>def _parse_smaps_rollup(self):<br>  uss = pss = swap = 0<br>  with open_binary(&quot;{}/{}/smaps_rollup&quot;.format(self._procfs_path, self.pid)) as f:<br>  for line in f:<br>    if line.startswith(b”Private_”):<br>    # Private_Clean, Private_Dirty, Private_Hugetlb<br>      s uss += int(line.split()[1]) * 1024<br>    elif line.startswith(b”Pss:”):<br>      pss = int(line.split()[1]) * 1024<br>    elif line.startswith(b”Swap:”):<br>      swap = int(line.split()[1]) * 1024<br>return (uss, pss, swap)</pre><p>Naturally, you might think that when memory usage increases, this file becomes larger in size, causing the function to take longer to parse. Unfortunately, this is not the answer because:</p><ul><li>First, <a href="https://www.kernel.org/doc/Documentation/ABI/testing/procfs-smaps_rollup"><strong>the number of lines in this file is constant</strong></a><strong> for all processes</strong>.</li><li>Second, <strong>this is a special file in the /proc filesystem, which should be seen as a kernel interface</strong> instead of a regular file on disk. In other words, <strong>I/O operations of this file are handled by the kernel rather than disk</strong>.</li></ul><p>This file was introduced in <a href="https://github.com/torvalds/linux/commit/493b0e9d945fa9dfe96be93ae41b4ca4b6fdb317#diff-cb79e2d6ea6f9627ff68d1342a219f800e04ff6c6fa7b90c7e66bb391b2dd3ee">this commit</a> in 2017, with the purpose of improving the performance of user programs that determine aggregate memory statistics. Let’s first focus on <a href="https://elixir.bootlin.com/linux/v6.5.13/source/fs/proc/task_mmu.c#L1025">the handler of <em>open</em> syscall</a> on this <em>/proc/&lt;pid&gt;/smaps_rollup</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/995/0*vGOD79Tleii7X22B" /></figure><p>Following through the <em>single_open</em> <a href="https://elixir.bootlin.com/linux/v6.5.13/source/fs/seq_file.c#L582">function</a>, we will find that it uses the function <em>show_smaps_rollup</em> for the show operation, which can translate to the <em>read</em> system call on the file. Next, we look at the <em>show_smaps_rollup</em> <a href="https://elixir.bootlin.com/linux/v6.5.13/source/fs/proc/task_mmu.c#L916">implementation</a>. You will notice <strong>a do-while loop that is linear to the virtual memory area</strong>.</p><pre>static int show_smaps_rollup(struct seq_file *m, void *v) {<br>  …<br>  vma_start = vma-&gt;vm_start;<br>  do {<br>    smap_gather_stats(vma, &amp;mss, 0);<br>    last_vma_end = vma-&gt;vm_end;<br>    …<br>  } for_each_vma(vmi, vma);<br>  …<br>}</pre><p>This perfectly <strong>explains why the function gets slower when a 2GB file is read into memory</strong>. <strong>Because the handler of reading the <em>smaps_rollup</em> file now takes longer to run the while loop</strong>. Basically, even though <strong><em>smaps_rollup</em></strong> already improved the performance of getting memory information compared to the old method of parsing the <em>/proc/&lt;pid&gt;/smaps</em> file, <strong>it is still linear to the virtual memory used</strong>.</p><h3>More Quantitative Analysis</h3><p>Even though at this point the puzzle is solved, let’s conduct a more quantitative analysis. How much is the time difference when reading the <em>smaps_rollup</em> file with small versus large virtual memory utilization? Let’s write some simple benchmark code like below:</p><pre>import os<br><br>def read_smaps_rollup(pid):<br>  with open(&quot;/proc/{}/smaps_rollup&quot;.format(pid), &quot;rb&quot;) as f:<br>    for line in f:<br>      pass<br><br>if __name__ == “__main__”:<br>  pid = os.getpid()<br>  <br>  read_smaps_rollup(pid)<br><br>  with open(“/root/2G_file”, “rb”) as f:<br>    data = f.read()<br><br>  read_smaps_rollup(pid)</pre><p>This program performs the following steps:</p><ol><li>Reads the <em>smaps_rollup</em> file of the current process.</li><li>Reads a 2GB file into memory.</li><li>Repeats step 1.</li></ol><p>We then use <em>strace</em> to find the accurate time of reading the <em>smaps_rollup</em> file.</p><pre>$ sudo strace -T -e trace=openat,read python3 benchmark.py 2&gt;&amp;1 | grep “smaps_rollup” -A 1<br><br>openat(AT_FDCWD, “/proc/3107492/smaps_rollup”, O_RDONLY|O_CLOEXEC) = 3 &lt;0.000023&gt;<br>read(3, “560b42ed4000–7ffdadcef000 — -p 0”…, 1024) = 670 &lt;0.000259&gt;<br>...<br>openat(AT_FDCWD, “/proc/3107492/smaps_rollup”, O_RDONLY|O_CLOEXEC) = 3 &lt;0.000029&gt;<br>read(3, “560b42ed4000–7ffdadcef000 — -p 0”…, 1024) = 670 &lt;0.027698&gt;</pre><p>As you can see, both times, the read <em>syscall</em> returned 670, meaning the file size remained the same at 670 bytes. However, <strong>the time it took the second time (i.e.</strong>,<strong> 0.027698 seconds) is 100x the time it took the first time (i.e.</strong>,<strong> 0.000259 seconds)</strong>! This means that if there are 98 processes, the time spent on reading this file alone will be 98 * 0.027698 = 2.7 seconds! Such a delay can significantly affect the UI experience.</p><h3>Solution</h3><p>This extension is used to display the CPU and memory usage of the notebook process on the bar at the bottom of the Notebook:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/524/0*bNYMYTc5QQAxLyya" /></figure><p>We confirmed with the user that disabling the <em>jupyter-resource-usage</em> extension meets their requirements for UI responsiveness, and that this extension is not critical to their use case. Therefore, we provided a way for them to disable the extension.</p><h3>Summary</h3><p>This was such a challenging issue that required debugging from the UI all the way down to the Linux kernel. It is fascinating that the problem is linear to both the number of CPUs and the virtual memory size — two dimensions that are generally viewed separately.</p><p>Overall, we hope you enjoyed the irony of:</p><ol><li>The extension used to monitor CPU usage causing CPU contention.</li><li>An interesting case where the more CPUs you have, the slower you get!</li></ol><p>If you’re excited by tackling such technical challenges and have the opportunity to solve complex technical challenges and drive innovation, consider joining our <a href="https://explore.jobs.netflix.net/careers?query=Data%20Platform&amp;pid=790298020581&amp;domain=netflix.com&amp;sort_by=relevance">Data Platform team</a>s. Be part of shaping the future of Data Security and Infrastructure, Data Developer Experience, Analytics Infrastructure and Enablement, and more. Explore the impact you can make with us!</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=faa017b4653d" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/investigation-of-a-workbench-ui-latency-issue-faa017b4653d">Investigation of a Workbench UI Latency Issue</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introducing Netflix’s TimeSeries Data Abstraction Layer]]></title>
            <link>https://netflixtechblog.com/introducing-netflix-timeseries-data-abstraction-layer-31552f6326f8?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/31552f6326f8</guid>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Tue, 08 Oct 2024 17:05:36 GMT</pubDate>
            <atom:updated>2024-10-13T03:41:58.855Z</atom:updated>
            <content:encoded><![CDATA[<p>By <a href="https://www.linkedin.com/in/rajiv-shringi">Rajiv Shringi</a>, <a href="https://www.linkedin.com/in/vinaychella/">Vinay Chella</a>, <a href="https://www.linkedin.com/in/kaidanfullerton/">Kaidan Fullerton</a>, <a href="https://www.linkedin.com/in/oleksii-tkachuk-98b47375/">Oleksii Tkachuk</a>, <a href="https://www.linkedin.com/in/joseph-lynch-9976a431/">Joey Lynch</a></p><h3><strong>Introduction</strong></h3><p>As Netflix continues to expand and diversify into various sectors like <strong>Video on Demand</strong> and <strong>Gaming</strong>, the ability to ingest and store vast amounts of temporal data — often reaching petabytes — with millisecond access latency has become increasingly vital. In previous blog posts, we introduced the <a href="https://netflixtechblog.com/introducing-netflixs-key-value-data-abstraction-layer-1ea8a0a11b30"><strong>Key-Value Data Abstraction Layer</strong></a> and the <a href="https://netflixtechblog.medium.com/data-gateway-a-platform-for-growing-and-protecting-the-data-tier-f1ed8db8f5c6"><strong>Data Gateway Platform</strong></a>, both of which are integral to Netflix’s data architecture. The Key-Value Abstraction offers a flexible, scalable solution for storing and accessing structured key-value data, while the Data Gateway Platform provides essential infrastructure for protecting, configuring, and deploying the data tier.</p><p>Building on these foundational abstractions, we developed the <strong>TimeSeries Abstraction</strong> — a versatile and scalable solution designed to efficiently store and query large volumes of temporal event data with low millisecond latencies, all in a cost-effective manner across various use cases.</p><p>In this post, we will delve into the architecture, design principles, and real-world applications of the <strong>TimeSeries Abstraction</strong>, demonstrating how it enhances our platform’s ability to manage temporal data at scale.</p><p><strong>Note: </strong><em>Contrary to what the name may suggest, this system is not built as a general-purpose time series database. We do not use it for metrics, histograms, timers, or any such near-real time analytics use case. Those use cases are well served by the Netflix </em><a href="https://netflixtechblog.com/introducing-atlas-netflixs-primary-telemetry-platform-bd31f4d8ed9a"><em>Atlas</em></a><em> telemetry system. Instead, we focus on addressing the challenge of storing and accessing extremely high-throughput, immutable temporal event data in a low-latency and cost-efficient manner.</em></p><h3>Challenges</h3><p>At Netflix, temporal data is continuously generated and utilized, whether from user interactions like video-play events, asset impressions, or complex micro-service network activities. Effectively managing this data at scale to extract valuable insights is crucial for ensuring optimal user experiences and system reliability.</p><p>However, storing and querying such data presents a unique set of challenges:</p><ul><li><strong>High Throughput</strong>: Managing up to 10 million writes per second while maintaining high availability.</li><li><strong>Efficient Querying in Large Datasets</strong>: Storing petabytes of data while ensuring primary key reads return results within low double-digit milliseconds, and supporting searches and aggregations across multiple secondary attributes.</li><li><strong>Global Reads and Writes</strong>: Facilitating read and write operations from anywhere in the world with adjustable consistency models.</li><li><strong>Tunable Configuration</strong>: Offering the ability to partition datasets in either a single-tenant or multi-tenant datastore, with options to adjust various dataset aspects such as retention and consistency.</li><li><strong>Handling Bursty Traffic</strong>: Managing significant traffic spikes during high-demand events, such as new content launches or regional failovers.</li><li><strong>Cost Efficiency</strong>: Reducing the cost per byte and per operation to optimize long-term retention while minimizing infrastructure expenses, which can amount to millions of dollars for Netflix.</li></ul><h3>TimeSeries Abstraction</h3><p>The TimeSeries Abstraction was developed to meet these requirements, built around the following core design principles:</p><ul><li><strong>Partitioned Data</strong>: Data is partitioned using a unique temporal partitioning strategy combined with an event bucketing approach to efficiently manage bursty workloads and streamline queries.</li><li><strong>Flexible Storage</strong>: The service is designed to integrate with various storage backends, including <a href="https://cassandra.apache.org/_/index.html">Apache Cassandra</a> and <a href="https://www.elastic.co/elasticsearch">Elasticsearch</a>, allowing Netflix to customize storage solutions based on specific use case requirements.</li><li><strong>Configurability</strong>: TimeSeries offers a range of tunable options for each dataset, providing the flexibility needed to accommodate a wide array of use cases.</li><li><strong>Scalability</strong>: The architecture supports both horizontal and vertical scaling, enabling the system to handle increasing throughput and data volumes as Netflix expands its user base and services.</li><li><strong>Sharded Infrastructure</strong>: Leveraging the <strong>Data Gateway Platform</strong>, we can deploy single-tenant and/or multi-tenant infrastructure with the necessary access and traffic isolation.</li></ul><p>Let’s dive into the various aspects of this abstraction.</p><h3>Data Model</h3><p>We follow a unique event data model that encapsulates all the data we want to capture for events, while allowing us to query them efficiently.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*jl30Jl559Fnd29in" /></figure><p>Let’s start with the smallest unit of data in the abstraction and work our way up.</p><ul><li><strong>Event Item</strong>: An event item is a key-value pair that users use to store data for a given event. For example: <em>{“device_type”: “ios”}</em>.</li><li><strong>Event</strong>: An event is a structured collection of one or more such event items. An event occurs at a specific point in time and is identified by a client-generated timestamp and an event identifier (such as a UUID). This combination of <strong>event_time</strong> and <strong>event_id</strong> also forms part of the unique idempotency key for the event, enabling users to safely retry requests.</li><li><strong>Time Series ID</strong>: A <strong>time_series_id</strong> is a collection of one or more such events over the dataset’s retention period. For instance, a <strong>device_id</strong> would store all events occurring for a given device over the retention period. All events are immutable, and the TimeSeries service only ever appends events to a given time series ID.</li><li><strong>Namespace</strong>: A namespace is a collection of time series IDs and event data, representing the complete TimeSeries dataset. Users can create one or more namespaces for each of their use cases. The abstraction applies various tunable options at the namespace level, which we will discuss further when we explore the service’s control plane.</li></ul><h3>API</h3><p>The abstraction provides the following APIs to interact with the event data.</p><p><strong>WriteEventRecordsSync</strong>: This endpoint writes a batch of events and sends back a durability acknowledgement to the client. This is used in cases where users require a guarantee of durability.</p><p><strong>WriteEventRecords</strong>: This is the fire-and-forget version of the above endpoint. It enqueues a batch of events without the durability acknowledgement. This is used in cases like logging or tracing, where users care more about throughput and can tolerate a small amount of data loss.</p><pre>{<br>  &quot;namespace&quot;: &quot;my_dataset&quot;,<br>  &quot;events&quot;: [<br>    {<br>      &quot;timeSeriesId&quot;: &quot;profile100&quot;,<br>      &quot;eventTime&quot;: &quot;2024-10-03T21:24:23.988Z&quot;,<br>      &quot;eventId&quot;: &quot;550e8400-e29b-41d4-a716-446655440000&quot;,<br>      &quot;eventItems&quot;: [<br>        {<br>          &quot;eventItemKey&quot;: &quot;deviceType&quot;,  <br>          &quot;eventItemValue&quot;: &quot;aW9z&quot;<br>        },<br>        {<br>          &quot;eventItemKey&quot;: &quot;deviceMetadata&quot;,<br>          &quot;eventItemValue&quot;: &quot;c29tZSBtZXRhZGF0YQ==&quot;<br>        }<br>      ]<br>    },<br>    {<br>      &quot;timeSeriesId&quot;: &quot;profile100&quot;,<br>      &quot;eventTime&quot;: &quot;2024-10-03T21:23:30.000Z&quot;,<br>      &quot;eventId&quot;: &quot;123e4567-e89b-12d3-a456-426614174000&quot;,<br>      &quot;eventItems&quot;: [<br>        {<br>          &quot;eventItemKey&quot;: &quot;deviceType&quot;,  <br>          &quot;eventItemValue&quot;: &quot;YW5kcm9pZA==&quot;<br>        }<br>      ]<br>    }<br>  ]<br>}</pre><p><strong>ReadEventRecords</strong>: Given a combination of a namespace, a timeSeriesId, a timeInterval, and optional eventFilters, this endpoint returns all the matching events, sorted descending by event_time, with low millisecond latency.</p><pre>{<br>  &quot;namespace&quot;: &quot;my_dataset&quot;,<br>  &quot;timeSeriesId&quot;: &quot;profile100&quot;,<br>  &quot;timeInterval&quot;: {<br>    &quot;start&quot;: &quot;2024-10-02T21:00:00.000Z&quot;,<br>    &quot;end&quot;:   &quot;2024-10-03T21:00:00.000Z&quot;<br>  },<br>  &quot;eventFilters&quot;: [<br>    {<br>      &quot;matchEventItemKey&quot;: &quot;deviceType&quot;,<br>      &quot;matchEventItemValue&quot;: &quot;aW9z&quot;<br>    }<br>  ],<br>  &quot;pageSize&quot;: 100,<br>  &quot;totalRecordLimit&quot;: 1000<br>}</pre><p><strong>SearchEventRecords</strong>: Given a search criteria and a time interval, this endpoint returns all the matching events. These use cases are fine with eventually consistent reads.</p><pre>{<br>  &quot;namespace&quot;: &quot;my_dataset&quot;,<br>  &quot;timeInterval&quot;: {<br>    &quot;start&quot;: &quot;2024-10-02T21:00:00.000Z&quot;,<br>    &quot;end&quot;: &quot;2024-10-03T21:00:00.000Z&quot;<br>  },<br>  &quot;searchQuery&quot;: {<br>    &quot;booleanQuery&quot;: {<br>      &quot;searchQuery&quot;: [<br>        {<br>          &quot;equals&quot;: {<br>            &quot;eventItemKey&quot;: &quot;deviceType&quot;,<br>            &quot;eventItemValue&quot;: &quot;aW9z&quot;<br>          }<br>        },<br>        {<br>          &quot;range&quot;: {<br>            &quot;eventItemKey&quot;: &quot;deviceRegistrationTimestamp&quot;,<br>            &quot;lowerBound&quot;: {<br>              &quot;eventItemValue&quot;: &quot;MjAyNC0xMC0wMlQwMDowMDowMC4wMDBa&quot;,<br>              &quot;inclusive&quot;: true<br>            },<br>            &quot;upperBound&quot;: {<br>              &quot;eventItemValue&quot;: &quot;MjAyNC0xMC0wM1QwMDowMDowMC4wMDBa&quot;<br>            }<br>          }<br>        }<br>      ],<br>      &quot;operator&quot;: &quot;AND&quot;<br>    }<br>  },<br>  &quot;pageSize&quot;: 100,<br>  &quot;totalRecordLimit&quot;: 1000<br>}</pre><p><strong>AggregateEventRecords</strong>: Given a search criteria and an aggregation mode (e.g. DistinctAggregation) , this endpoint performs the given aggregation within a given time interval. Similar to the Search endpoint, users can tolerate eventual consistency and a potentially higher latency (in seconds).</p><pre>{<br>  &quot;namespace&quot;: &quot;my_dataset&quot;,<br>  &quot;timeInterval&quot;: {<br>    &quot;start&quot;: &quot;2024-10-02T21:00:00.000Z&quot;,<br>    &quot;end&quot;: &quot;2024-10-03T21:00:00.000Z&quot;<br>  },<br>  &quot;searchQuery&quot;: {...some search criteria...},<br>  &quot;aggregationQuery&quot;: {<br>    &quot;distinct&quot;: {<br>      &quot;eventItemKey&quot;: &quot;deviceType&quot;,<br>      &quot;pageSize&quot;: 100<br>    }<br>  }<br>}</pre><p>In the subsequent sections, we will talk about how we interact with this data at the storage layer.</p><h3>Storage Layer</h3><p>The storage layer for TimeSeries comprises a primary data store and an optional index data store. The primary data store ensures data durability during writes and is used for primary read operations, while the index data store is utilized for search and aggregate operations. At Netflix, <strong>Apache Cassandra</strong> is the preferred choice for storing durable data in high-throughput scenarios, while <strong>Elasticsearch</strong> is the preferred data store for indexing. However, similar to our approach with the API, the storage layer is not tightly coupled to these specific data stores. Instead, we define storage API contracts that must be fulfilled, allowing us the flexibility to replace the underlying data stores as needed.</p><h3>Primary Datastore</h3><p>In this section, we will talk about how we leverage <strong>Apache Cassandra</strong> for TimeSeries use cases.</p><h4>Partitioning Scheme</h4><p>At Netflix’s scale, the continuous influx of event data can quickly overwhelm traditional databases. Temporal partitioning addresses this challenge by dividing the data into manageable chunks based on time intervals, such as hourly, daily, or monthly windows. This approach enables efficient querying of specific time ranges without the need to scan the entire dataset. It also allows Netflix to archive, compress, or delete older data efficiently, optimizing both storage and query performance. Additionally, this partitioning mitigates the performance issues typically associated with <a href="https://thelastpickle.com/blog/2019/01/11/wide-partitions-cassandra-3-11.html">wide partitions</a> in Cassandra. By employing this strategy, we can operate at much higher disk utilization, as it reduces the need to reserve large amounts of disk space for compactions, thereby saving costs.</p><p>Here is what it looks like :</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*MxuEH6_pOVDcAMie" /></figure><p><strong>Time Slice: </strong>A<strong> </strong>time slice is the unit of data retention and maps directly to a Cassandra table. We create multiple such time slices, each covering a specific interval of time. An event lands in one of these slices based on the <strong>event_time</strong>. These slices are joined with <em>no time gaps</em><strong> </strong>in between, with operations being <em>start-inclusive</em> and <em>end-exclusive</em>, ensuring that all data lands in one of the slices. By utilizing these time slices, we can efficiently implement retention by dropping entire tables, which reduces storage space and saves on costs.</p><p><strong>Why not use row-based Time-To-Live (TTL)?</strong></p><p>Using TTL on individual events would generate a significant number of <a href="https://thelastpickle.com/blog/2016/07/27/about-deletes-and-tombstones.html">tombstones</a> in Cassandra, degrading performance, especially during range scans. By employing discrete time slices and dropping them, we avoid the tombstone issue entirely. The tradeoff is that data may be retained slightly longer than necessary, as an entire table’s time range must fall outside the retention window before it can be dropped. Additionally, TTLs are difficult to adjust later, whereas TimeSeries can extend the dataset retention instantly with a single control plane operation.</p><p><strong>Time Buckets</strong>: Within a time slice, data is further partitioned into time buckets. This facilitates effective range scans by allowing us to target specific time buckets for a given query range. The tradeoff is that if a user wants to read the entire range of data over a large time period, we must scan many partitions. We mitigate potential latency by scanning these partitions in parallel and aggregating the data at the end. In most cases, the advantage of targeting smaller data subsets outweighs the read amplification from these scatter-gather operations. Typically, users read a smaller subset of data rather than the entire retention range.</p><p><strong>Event Buckets</strong>: To manage extremely high-throughput write operations, which may result in a burst of writes for a given time series within a short period, we further divide the time bucket into event buckets. This prevents overloading the same partition for a given time range and also reduces partition sizes further, albeit with a slight increase in read amplification.</p><p><strong>Note</strong>: <em>With Cassandra 4.x onwards, we notice a substantial improvement in the performance of scanning a range of data in a wide partition. See </em><strong><em>Future Enhancements</em></strong><em> at the end to see the </em><strong><em>Dynamic Event bucketing</em></strong><em> work that aims to take advantage of this.</em></p><h4>Storage Tables</h4><p>We use two kinds of tables</p><ul><li><strong>Data tables</strong>: These are the time slices that store the actual event data.</li><li><strong>Metadata table</strong>: This table stores information about how each time slice is configured <em>per namespace</em>.</li></ul><h4>Data tables</h4><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ktuEBzveeK4f1mWH" /></figure><p>The partition key enables splitting events for a <strong>time_series_id</strong> over a range of <strong>time_bucket(s)</strong> and <strong>event_bucket(s)</strong>, thus mitigating hot partitions, while the clustering key allows us to keep data sorted on disk in the order we almost always want to read it. The <strong>value_metadata</strong> column stores metadata for the <strong>event_item_value</strong> such as compression.</p><p><strong>Writing to the data table:</strong></p><p>User writes will land in a given time slice, time bucket, and event bucket as a factor of the <strong>event_time</strong> attached to the event. This factor is dictated by the control plane configuration of a given namespace.</p><p>For example:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*P4IThIE_PE9F8KYi" /></figure><p>During this process, the writer makes decisions on how to handle the data before writing, such as whether to compress it. The <strong>value_metadata</strong> column records any such post-processing actions, ensuring that the reader can accurately interpret the data.</p><p><strong>Reading from the data table:</strong></p><p>The below illustration depicts at a high-level on how we scatter-gather the reads from multiple partitions and join the result set at the end to return the final result.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*a805txbeIDqYP73d" /></figure><h4>Metadata table</h4><p>This table stores the configuration data about the time slices for a given namespace.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*asJFOjl1iwlSajJc" /></figure><p>Note the following:</p><ul><li><strong>No Time Gaps</strong>: The end_time of a given time slice overlaps with the start_time of the next time slice, ensuring all events find a home.</li><li><strong>Retention</strong>: The status indicates which tables fall inside and outside of the retention window.</li><li><strong>Flexible</strong>: This metadata can be adjusted per time slice, allowing us to tune the partition settings of future time slices based on observed data patterns in the current time slice.</li></ul><p>There is a lot more information that can be stored into the <strong>metadata</strong> column (e.g., compaction settings for the table), but we only show the partition settings here for brevity.</p><h3>Index Datastore</h3><p>To support secondary access patterns via non-primary key attributes, we index data into Elasticsearch. Users can configure a list of attributes per namespace that they wish to search and/or aggregate data on. The service extracts these fields from events as they stream in, indexing the resultant documents into Elasticsearch. Depending on the throughput, we may use Elasticsearch as a reverse index, retrieving the full data from Cassandra, or we may store the entire source data directly in Elasticsearch.</p><p><strong>Note</strong>:<em> Again, users are never directly exposed to Elasticsearch, just like they are not directly exposed to Cassandra. Instead, they interact with the Search and Aggregate API endpoints that translate a given query to that needed for the underlying datastore.</em></p><p>In the next section, we will talk about how we configure these data stores for different datasets.</p><h3>Control Plane</h3><p>The data plane is responsible for executing the read and write operations, while the control plane configures every aspect of a namespace’s behavior. The data plane communicates with the TimeSeries control stack, which manages this configuration information. In turn, the TimeSeries control stack interacts with a sharded <strong>Data Gateway Platform Control Plane</strong> that oversees control configurations for all abstractions and namespaces.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*aB6OKXoG-mT65Vh1" /></figure><p>Separating the responsibilities of the data plane and control plane helps maintain the high availability of our data plane, as the control plane takes on tasks that may require some form of schema consensus from the underlying data stores.</p><h3>Namespace Configuration</h3><p>The below configuration snippet demonstrates the immense flexibility of the service and how we can tune several things per namespace using our control plane.</p><pre>&quot;persistence_configuration&quot;: [<br>  {<br>    &quot;id&quot;: &quot;PRIMARY_STORAGE&quot;,<br>    &quot;physical_storage&quot;: {<br>      &quot;type&quot;: &quot;CASSANDRA&quot;,                  // type of primary storage<br>      &quot;cluster&quot;: &quot;cass_dgw_ts_tracing&quot;,     // physical cluster name<br>      &quot;dataset&quot;: &quot;tracing_default&quot;          // maps to the keyspace<br>    },<br>    &quot;config&quot;: {<br>      &quot;timePartition&quot;: {<br>        &quot;secondsPerTimeSlice&quot;: &quot;129600&quot;,    // width of a time slice<br>        &quot;secondPerTimeBucket&quot;: &quot;3600&quot;,      // width of a time bucket<br>        &quot;eventBuckets&quot;: 4                   // how many event buckets within<br>      },<br>      &quot;queueBuffering&quot;: {<br>        &quot;coalesce&quot;: &quot;1s&quot;,                   // how long to coalesce writes<br>        &quot;bufferCapacity&quot;: 4194304           // queue capacity in bytes<br>      },<br>      &quot;consistencyScope&quot;: &quot;LOCAL&quot;,          // single-region/multi-region<br>      &quot;consistencyTarget&quot;: &quot;EVENTUAL&quot;,      // read/write consistency<br>      &quot;acceptLimit&quot;: &quot;129600s&quot;              // how far back writes are allowed<br>    },<br>    &quot;lifecycleConfigs&quot;: {<br>      &quot;lifecycleConfig&quot;: [                  // Primary store data retention<br>        {<br>          &quot;type&quot;: &quot;retention&quot;,<br>          &quot;config&quot;: {<br>            &quot;close_after&quot;: &quot;1296000s&quot;,      // close for reads/writes<br>            &quot;delete_after&quot;: &quot;1382400s&quot;      // drop time slice<br>          }<br>        }<br>      ]<br>    }<br>  },<br>  {<br>    &quot;id&quot;: &quot;INDEX_STORAGE&quot;,<br>    &quot;physicalStorage&quot;: {<br>      &quot;type&quot;: &quot;ELASTICSEARCH&quot;,              // type of index storage<br>      &quot;cluster&quot;: &quot;es_dgw_ts_tracing&quot;,       // ES cluster name<br>      &quot;dataset&quot;: &quot;tracing_default_useast1&quot;  // base index name<br>    },<br>    &quot;config&quot;: {<br>      &quot;timePartition&quot;: {<br>        &quot;secondsPerSlice&quot;: &quot;129600&quot;         // width of the index slice<br>      },<br>      &quot;consistencyScope&quot;: &quot;LOCAL&quot;,<br>      &quot;consistencyTarget&quot;: &quot;EVENTUAL&quot;,      // how should we read/write data<br>      &quot;acceptLimit&quot;: &quot;129600s&quot;,             // how far back writes are allowed<br>      &quot;indexConfig&quot;: {<br>        &quot;fieldMapping&quot;: {                   // fields to extract to index<br>          &quot;tags.nf.app&quot;: &quot;KEYWORD&quot;,<br>          &quot;tags.duration&quot;: &quot;INTEGER&quot;,<br>          &quot;tags.enabled&quot;: &quot;BOOLEAN&quot;<br>        },<br>        &quot;refreshInterval&quot;: &quot;60s&quot;            // Index related settings<br>      }<br>    },<br>    &quot;lifecycleConfigs&quot;: {<br>      &quot;lifecycleConfig&quot;: [<br>        {<br>          &quot;type&quot;: &quot;retention&quot;,              // Index retention settings<br>          &quot;config&quot;: {<br>            &quot;close_after&quot;: &quot;1296000s&quot;,<br>            &quot;delete_after&quot;: &quot;1382400s&quot;<br>          }<br>        }<br>      ]<br>    }<br>  }<br>]</pre><h3>Provisioning Infrastructure</h3><p>With so many different parameters, we need automated provisioning workflows to deduce the best settings for a given workload. When users want to create their namespaces, they specify a list of <em>workload</em> <em>desires</em>, which the automation translates into concrete infrastructure and related control plane configuration. We highly encourage you to watch this <a href="https://www.youtube.com/watch?v=2aBVKXi8LKk">ApacheCon talk</a>, by one of our stunning colleagues <strong>Joey Lynch,</strong> on how we achieve this. We may go into detail on this subject in one of our future blog posts.</p><p>Once the system provisions the initial infrastructure, it then scales in response to the user workload. The next section describes how this is achieved.</p><h3>Scalability</h3><p>Our users may operate with limited information at the time of provisioning their namespaces, resulting in best-effort provisioning estimates. Further, evolving use-cases may introduce new throughput requirements over time. Here’s how we manage this:</p><ul><li><strong>Horizontal scaling</strong>: TimeSeries server instances can auto-scale up and down as per attached scaling policies to meet the traffic demand. The storage server capacity can be recomputed to accommodate changing requirements using our <a href="https://github.com/Netflix-Skunkworks/service-capacity-modeling/tree/main/service_capacity_modeling">capacity planner</a>.</li><li><strong>Vertical scaling</strong>: We may also choose to vertically scale our TimeSeries server instances or our storage instances to get greater CPU, RAM and/or attached storage capacity.</li><li><strong>Scaling disk</strong>: We may attach <a href="https://aws.amazon.com/ebs/">EBS</a> to store data if the capacity planner prefers infrastructure that offers larger storage at a lower cost rather than SSDs optimized for latency. In such cases, we deploy jobs to scale the EBS volume when the disk storage reaches a certain percentage threshold.</li><li><strong>Re-partitioning data</strong>: Inaccurate workload estimates can lead to over or under-partitioning of our datasets. TimeSeries control-plane can adjust the partitioning configuration for upcoming time slices, once we realize the nature of data in the wild (via partition histograms). In the future we plan to support re-partitioning of older data and dynamic partitioning of current data.</li></ul><h3>Design Principles</h3><p>So far, we have seen how TimeSeries stores, configures and interacts with event datasets. Let’s see how we apply different techniques to improve the performance of our operations and provide better guarantees.</p><h4>Event Idempotency</h4><p>We prefer to bake in idempotency in all mutation endpoints, so that users can retry or hedge their requests safely. <a href="https://research.google/pubs/the-tail-at-scale/">Hedging</a> is when the client sends an identical competing request to the server, if the original request does not come back with a response in an expected amount of time. The client then responds with whichever request completes first. This is done to keep the tail latencies for an application relatively low. This can only be done safely if the mutations are idempotent. For TimeSeries, the combination of <strong>event_time</strong>, <strong>event_id</strong> and <strong>event_item_key</strong> form the idempotency key for a given <strong>time_series_id</strong> event.</p><h4>SLO-based Hedging</h4><p>We assign Service Level Objectives (SLO) targets for different endpoints within TimeSeries, as an indication of what we think the performance of those endpoints should be <em>for a given namespace</em>. We can then hedge a request if the response does not come back in that configured amount of time.</p><pre>&quot;slos&quot;: {<br>  &quot;read&quot;: {               // SLOs per endpoint<br>    &quot;latency&quot;: {<br>      &quot;target&quot;: &quot;0.5s&quot;,   // hedge around this number<br>      &quot;max&quot;: &quot;1s&quot;         // time-out around this number<br>    }<br>  },<br>  &quot;write&quot;: {<br>    &quot;latency&quot;: {<br>      &quot;target&quot;: &quot;0.01s&quot;,<br>      &quot;max&quot;: &quot;0.05s&quot;<br>    }<br>  }<br>}</pre><h4>Partial Return</h4><p>Sometimes, a client may be sensitive to latency and willing to accept a partial result set. A real-world example of this is real-time frequency capping. Precision is not critical in this case, but if the response is delayed, it becomes practically useless to the upstream client. Therefore, the client prefers to work with whatever data has been collected so far rather than timing out while waiting for all the data. The TimeSeries client supports partial returns around SLOs for this purpose. Importantly, we still maintain the latest order of events in this partial fetch.</p><h4>Adaptive Pagination</h4><p>All reads start with a default fanout factor, scanning 8 partition buckets in parallel. However, if the service layer determines that the time_series dataset is dense — i.e., most reads are satisfied by reading the first few partition buckets — then it dynamically adjusts the fanout factor of future reads in order to reduce the read amplification on the underlying datastore. Conversely, if the dataset is sparse, we may want to increase this limit with a reasonable upper bound.</p><h4>Limited Write Window</h4><p>In most cases, the active range for writing data is smaller than the range for reading data — i.e., we want a range of time to become immutable as soon as possible so that we can apply optimizations on top of it. We control this by having a configurable “<strong>acceptLimit</strong>” parameter that prevents users from writing events older than this time limit. For example, an accept limit of 4 hours means that users cannot write events older than <em>now() — 4 hours</em>. We sometimes raise this limit for backfilling historical data, but it is tuned back down for regular write operations. Once a range of data becomes immutable, we can safely do things like caching, compressing, and compacting it for reads.</p><h4>Buffering Writes</h4><p>We frequently leverage this service for handling bursty workloads. Rather than overwhelming the underlying datastore with this load all at once, we aim to distribute it more evenly by allowing events to coalesce over short durations (typically seconds). These events accumulate in in-memory queues running on each instance. Dedicated consumers then steadily drain these queues, grouping the events by their partition key, and batching the writes to the underlying datastore.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*pMVe_h3daBDLWdis" /></figure><p>The queues are tailored to each datastore since their operational characteristics depend on the specific datastore being written to. For instance, the batch size for writing to Cassandra is significantly smaller than that for indexing into Elasticsearch, leading to different drain rates and batch sizes for the associated consumers.</p><p>While using in-memory queues does increase JVM garbage collection, we have experienced substantial improvements by transitioning to JDK 21 with ZGC. To illustrate the impact, ZGC has reduced our tail latencies by an impressive 86%:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*hj98LMk1UddaaDs-" /></figure><p>Because we use in-memory queues, we are prone to losing events in case of an instance crash. As such, these queues are only used for use cases that can tolerate some amount of data loss .e.g. tracing/logging. For use cases that need guaranteed durability and/or read-after-write consistency, these queues are effectively disabled and writes are flushed to the data store almost immediately.</p><h4>Dynamic Compaction</h4><p>Once a time slice exits the active write window, we can leverage the immutability of the data to optimize it for read performance. This process may involve re-compacting immutable data using optimal compaction strategies, dynamically shrinking and/or splitting shards to optimize system resources, and other similar techniques to ensure fast and reliable performance.</p><p>The following section provides a glimpse into the real-world performance of some of our TimeSeries datasets.</p><h3>Real-world Performance</h3><p>The service can write data in the order of low single digit milliseconds</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*VWrQj2ya5PQWusBq" /></figure><p>while consistently maintaining stable point-read latencies:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*23F_CzqsjMoI8GHB" /></figure><p>At the time of writing this blog, the service was processing close to <em>15 million events/second</em> across all the different datasets at peak globally.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1022/0*dZFDUVX35Cj1MPOj" /></figure><h3>Time Series Usage @ Netflix</h3><p>The TimeSeries Abstraction plays a vital role across key services at Netflix. Here are some impactful use cases:</p><ul><li><strong>Tracing and Insights: </strong>Logs traces across all apps and micro-services within Netflix, to understand service-to-service communication, aid in debugging of issues, and answer support requests.</li><li><strong>User Interaction Tracking</strong>: Tracks millions of user interactions — such as video playbacks, searches, and content engagement — providing insights that enhance Netflix’s recommendation algorithms in real-time and improve the overall user experience.</li><li><strong>Feature Rollout and Performance Analysis</strong>: Tracks the rollout and performance of new product features, enabling Netflix engineers to measure how users engage with features, which powers data-driven decisions about future improvements.</li><li><strong>Asset Impression Tracking and Optimization</strong>: Tracks asset impressions ensuring content and assets are delivered efficiently while providing real-time feedback for optimizations.</li><li><strong>Billing and Subscription Management:</strong> Stores historical data related to billing and subscription management, ensuring accuracy in transaction records and supporting customer service inquiries.</li></ul><p>and more…</p><h3>Future Enhancements</h3><p>As the use cases evolve, and the need to make the abstraction even more cost effective grows, we aim to make many improvements to the service in the upcoming months. Some of them are:</p><ul><li><strong>Tiered Storage for Cost Efficiency: </strong>Support moving older, lesser-accessed data into cheaper object storage that has higher time to first byte, potentially saving Netflix millions of dollars.</li><li><strong>Dynamic Event Bucketing: </strong>Support real-time partitioning of keys into optimally-sized partitions as events stream in, rather than having a <em>somewhat</em> static configuration at the time of provisioning a namespace. This strategy has a huge advantage of <em>not</em> partitioning time_series_ids that don’t need it, thus saving the overall cost of read amplification. Also, with Cassandra 4.x, we have noted major improvements in reading a subset of data in a wide partition that could lead us to be less aggressive with partitioning the entire dataset ahead of time.</li><li><strong>Caching: </strong>Take advantage of immutability of data and cache it intelligently for discrete time ranges.</li><li><strong>Count and other Aggregations: </strong>Some users are only interested in counting events in a given time interval rather than fetching all the event data for it.</li></ul><h3>Conclusion</h3><p>The TimeSeries Abstraction is a vital component of Netflix’s online data infrastructure, playing a crucial role in supporting both real-time and long-term decision-making. Whether it’s monitoring system performance during high-traffic events or optimizing user engagement through behavior analytics, TimeSeries Abstraction ensures that Netflix operates seamlessly and efficiently on a global scale.</p><p>As Netflix continues to innovate and expand into new verticals, the TimeSeries Abstraction will remain a cornerstone of our platform, helping us push the boundaries of what’s possible in streaming and beyond.</p><p>Stay tuned for Part 2, where we’ll introduce our <strong>Distributed Counter Abstraction</strong>, a key element of <strong>Netflix’s Composite Abstractions</strong>, built on top of the TimeSeries Abstraction.</p><h3>Acknowledgments</h3><p>Special thanks to our stunning colleagues who contributed to TimeSeries Abstraction’s success: <a href="https://www.linkedin.com/in/tomdevoe/">Tom DeVoe</a> <a href="https://www.linkedin.com/in/mengqingwang/">Mengqing Wang</a>, <a href="https://www.linkedin.com/in/kartik894/">Kartik Sathyanarayanan</a>, <a href="https://www.linkedin.com/in/jordan-west-8aa1731a3/">Jordan West</a>, <a href="https://www.linkedin.com/in/matt-lehman-39549719b/">Matt Lehman</a>, <a href="https://www.linkedin.com/in/cheng-wang-10323417/">Cheng Wang</a>, <a href="https://www.linkedin.com/in/clohfink/">Chris Lohfink</a> .</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=31552f6326f8" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/introducing-netflix-timeseries-data-abstraction-layer-31552f6326f8">Introducing Netflix’s TimeSeries Data Abstraction Layer</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Introducing Netflix’s Key-Value Data Abstraction Layer]]></title>
            <link>https://netflixtechblog.com/introducing-netflixs-key-value-data-abstraction-layer-1ea8a0a11b30?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/1ea8a0a11b30</guid>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Wed, 18 Sep 2024 22:49:04 GMT</pubDate>
            <atom:updated>2024-09-19T01:52:03.581Z</atom:updated>
            <content:encoded><![CDATA[<p><a href="https://www.linkedin.com/in/vidhya-arvind-11908723">Vidhya Arvind</a>, <a href="https://www.linkedin.com/in/rummadis/">Rajasekhar Ummadisetty</a>, <a href="https://jolynch.github.io/">Joey Lynch</a>, <a href="https://www.linkedin.com/in/vinaychella">Vinay Chella</a></p><h3>Introduction</h3><p>At Netflix our ability to deliver seamless, high-quality, streaming experiences to millions of users hinges on robust, <em>global</em> backend infrastructure. Central to this infrastructure is our use of multiple online distributed databases such as <a href="https://cassandra.apache.org/">Apache Cassandra</a>, a NoSQL database known for its high availability and scalability. Cassandra serves as the backbone for a diverse array of use cases within Netflix, ranging from user sign-ups and storing viewing histories to supporting real-time analytics and live streaming.</p><p>Over time as new key-value databases were introduced and service owners launched new use cases, we encountered numerous challenges with datastore misuse. Firstly, developers struggled to reason about consistency, durability and performance in this complex global deployment across multiple stores. Second, developers had to constantly re-learn new data modeling practices and common yet critical data access patterns. These include challenges with tail latency and idempotency, managing “wide” partitions with many rows, handling single large “fat” columns, and slow response pagination. Additionally, the tight coupling with multiple native database APIs — APIs that continually evolve and sometimes introduce backward-incompatible changes — resulted in org-wide engineering efforts to maintain and optimize our microservice’s data access.</p><p>To overcome these challenges, we developed a holistic approach that builds upon our <a href="https://netflixtechblog.medium.com/data-gateway-a-platform-for-growing-and-protecting-the-data-tier-f1ed8db8f5c6">Data Gateway Platform</a>. This approach led to the creation of several foundational abstraction services, the most mature of which is our Key-Value (KV) Data Abstraction Layer (DAL). This abstraction simplifies data access, enhances the reliability of our infrastructure, and enables us to support the broad spectrum of use cases that Netflix demands with minimal developer effort.</p><p>In this post, we dive deep into how Netflix’s KV abstraction works, the architectural principles guiding its design, the challenges we faced in scaling diverse use cases, and the technical innovations that have allowed us to achieve the performance and reliability required by Netflix’s global operations.</p><h3><strong>The Key-Value Service</strong></h3><p>The KV data abstraction service was introduced to solve the persistent challenges we faced with data access patterns in our distributed databases. Our goal was to build a versatile and efficient data storage solution that could handle a wide variety of use cases, ranging from the simplest hashmaps to more complex data structures, all while ensuring high availability, tunable consistency, and low latency.</p><h4>Data Model</h4><p>At its core, the KV abstraction is built around a <strong><em>two-level map</em> </strong>architecture. The first level is a hashed string <strong>ID</strong> (the primary key), and the second level is a <strong><em>sorted map of a key-value pair of bytes</em></strong>. This model supports both simple and complex data models, balancing flexibility and efficiency.</p><pre>HashMap&lt;String, SortedMap&lt;Bytes, Bytes&gt;&gt;</pre><p>For complex data models such as structured Records or time-ordered Events, this two-level approach handles hierarchical structures effectively, allowing related data to be retrieved together. For simpler use cases, it also represents flat key-value Maps (e.g. id → {&quot;&quot; → value}) or named Sets (e.g.id → {key → &quot;&quot;}). This adaptability allows the KV abstraction to be used in hundreds of diverse use cases, making it a versatile solution for managing both simple and complex data models in large-scale infrastructures like Netflix.</p><p>The KV data can be visualized at a high level, as shown in the diagram below, where three records are shown.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/755/0*9Ny8Uc-diSDnVGnk" /></figure><pre>message Item (   <br>  Bytes    key,<br>  Bytes    value,<br>  Metadata metadata,<br>  Integer  chunk<br>)</pre><h4>Database Agnostic Abstraction</h4><p>The KV abstraction is designed to hide the implementation details of the underlying database, offering a consistent interface to application developers regardless of the optimal storage system for that use case. While Cassandra is one example, the abstraction works with multiple data stores like <a href="https://github.com/Netflix/EVCache">EVCache</a>, <a href="https://aws.amazon.com/dynamodb/">DynamoDB</a>, <a href="https://rocksdb.org/">RocksDB</a>, etc…</p><p>For example, when implemented with Cassandra, the abstraction leverages Cassandra’s partitioning and clustering capabilities. The record <strong><em>ID</em></strong> acts as the partition key, and the item <strong><em>key</em></strong> as the clustering column:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/738/1*tMhXVTWqtHt24l1oflpAJQ.png" /></figure><p>The corresponding Data Definition Language (DDL) for this structure in Cassandra is:</p><pre>CREATE TABLE IF NOT EXISTS &lt;ns&gt;.&lt;table&gt; (<br>  id             text,<br>  key            blob,<br>  value          blob,<br>  value_metadata blob,<br><br>PRIMARY KEY (id, key))<br>WITH CLUSTERING ORDER BY (key &lt;ASC|DESC&gt;)</pre><h4>Namespace: Logical and Physical Configuration</h4><p>A <strong>namespace</strong> defines where and how data is stored, providing logical and physical separation while abstracting the underlying storage systems. It also serves as central configuration of access patterns such as consistency or latency targets. Each namespace may use different backends: Cassandra, EVCache, or combinations of multiple. This flexibility allows our Data Platform to route different use cases to the most suitable storage system based on performance, durability, and consistency needs. Developers just provide their data problem rather than a database solution!</p><p>In this example configuration, the ngsegment namespace is backed by both a Cassandra cluster and an EVCache caching layer, allowing for highly durable persistent storage <em>and</em> lower-latency point reads.</p><pre>&quot;persistence_configuration&quot;:[                                                   <br>  {                                                                           <br>    &quot;id&quot;:&quot;PRIMARY_STORAGE&quot;,                                                 <br>    &quot;physical_storage&quot;: {                                                    <br>      &quot;type&quot;:&quot;CASSANDRA&quot;,                                                 <br>      &quot;cluster&quot;:&quot;cassandra_kv_ngsegment&quot;,                                <br>      &quot;dataset&quot;:&quot;ngsegment&quot;,                                             <br>      &quot;table&quot;:&quot;ngsegment&quot;,                                               <br>      &quot;regions&quot;: [&quot;us-east-1&quot;],<br>      &quot;config&quot;: {<br>        &quot;consistency_scope&quot;: &quot;LOCAL&quot;,<br>        &quot;consistency_target&quot;: &quot;READ_YOUR_WRITES&quot;<br>      }                                            <br>    }                                                                       <br>  },                                                                          <br>  {                                                                           <br>    &quot;id&quot;:&quot;CACHE&quot;,                                                           <br>    &quot;physical_storage&quot;: {                                                    <br>      &quot;type&quot;:&quot;CACHE&quot;,                                                     <br>      &quot;cluster&quot;:&quot;evcache_kv_ngsegment&quot;                                   <br>     },                                                                      <br>     &quot;config&quot;: {                                                              <br>       &quot;default_cache_ttl&quot;: 180s                                             <br>     }                                                                       <br>  }                                                                           <br>] <br> </pre><h3><strong>Key APIs of the KV Abstraction</strong></h3><p>To support diverse use-cases, the KV abstraction provides four basic CRUD APIs:</p><h4>PutItems <strong>— Write one or more Items to a Record</strong></h4><p>The PutItems API is an upsert operation, it can insert new data or update existing data in the two-level map structure.</p><pre>message PutItemRequest (<br>  IdempotencyToken idempotency_token,<br>  string           namespace, <br>  string           id, <br>  List&lt;Item&gt;       items<br>)</pre><p>As you can see, the request includes the namespace, Record ID, one or more items, and an <strong>idempotency token</strong> to ensure retries of the same write are safe. Chunked data can be written by staging chunks and then committing them with appropriate metadata (e.g. number of chunks).</p><h4>GetItems <strong>— Read one or more Items from a Record</strong></h4><p>The GetItemsAPI provides a structured and adaptive way to fetch data using ID, predicates, and selection mechanisms. This approach balances the need to retrieve large volumes of data while meeting stringent Service Level Objectives (SLOs) for performance and reliability.</p><pre>message GetItemsRequest (<br>  String              namespace,<br>  String              id,<br>  Predicate           predicate,<br>  Selection           selection,<br>  Map&lt;String, Struct&gt; signals<br>)</pre><p>The GetItemsRequest includes several key parameters:</p><ul><li><strong>Namespace</strong>: Specifies the logical dataset or table</li><li><strong>Id</strong>: Identifies the entry in the top-level HashMap</li><li><strong>Predicate</strong>: Filters the matching items and can retrieve all items (match_all), specific items (match_keys), or a range (match_range)</li><li><strong>Selection</strong>: Narrows returned responses for example page_size_bytes for pagination, item_limit for limiting the total number of items across pages and include/exclude to include or exclude large values from responses</li><li><strong>Signals:</strong> Provides in-band signaling to indicate client capabilities, such as supporting client compression or chunking.</li></ul><p>The GetItemResponse message contains the matching data:</p><pre>message GetItemResponse (<br>  List&lt;Item&gt;       items,<br>  Optional&lt;String&gt; next_page_token<br>)</pre><ul><li><strong>Items</strong>: A list of retrieved items based on the Predicate and Selection defined in the request.</li><li><strong>Next Page Token</strong>: An optional token indicating the position for subsequent reads if needed, essential for handling large data sets across multiple requests. Pagination is a critical component for efficiently managing data retrieval, especially when dealing with large datasets that could exceed typical response size limits.</li></ul><h4><strong>DeleteItems — Delete one or more Items from a Record</strong></h4><p>The DeleteItems API provides flexible options for removing data, including record-level, item-level, and range deletes — all while supporting idempotency.</p><pre>message DeleteItemsRequest (<br>  IdempotencyToken idempotency_token,<br>  String           namespace,<br>  String           id,<br>  Predicate        predicate<br>)<br></pre><p>Just like in the GetItems API, the Predicate allows one or more Items to be addressed at once:</p><ul><li><strong>Record-Level Deletes (match_all)</strong>: Removes the entire record in constant latency regardless of the number of items in the record.</li><li><strong>Item-Range Deletes (match_range)</strong>: This deletes a range of items within a Record. Useful for keeping “n-newest” or prefix path deletion.</li><li><strong>Item-Level Deletes (match_keys)</strong>: Deletes one or more individual items.</li></ul><p>Some storage engines (any store which defers true deletion) such as Cassandra struggle with high volumes of deletes due to tombstone and compaction overhead. Key-Value optimizes both record and range deletes to generate a single tombstone for the operation — you can learn more about tombstones in <a href="https://thelastpickle.com/blog/2016/07/27/about-deletes-and-tombstones.html">About Deletes and Tombstones</a>.</p><p>Item-level deletes create many tombstones but KV hides that storage engine complexity via <strong>TTL-based deletes with jitter</strong>. Instead of immediate deletion, item metadata is updated as expired with randomly jittered TTL applied to stagger deletions. This technique maintains read pagination protections. While this doesn’t completely solve the problem it reduces load spikes and helps maintain consistent performance while compaction catches up. These strategies help maintain system performance, reduce read overhead, and meet SLOs by minimizing the impact of deletes.</p><h4>Complex Mutate and Scan APIs</h4><p>Beyond simple CRUD on single Records, KV also supports complex multi-item and multi-record mutations and scans via MutateItems and ScanItems APIs. PutItems also supports atomic writes of large blob data within a single Item via a chunked protocol. These complex APIs require careful consideration to ensure predictable linear low-latency and we will share details on their implementation in a future post.</p><h3>Design Philosophies for reliable and predictable performance</h3><h4>Idempotency to fight tail latencies</h4><p>To ensure data integrity the PutItems and DeleteItems APIs use <strong>idempotency tokens</strong>, which uniquely identify each mutative operation and guarantee that operations are logically executed in order, even when hedged or retried for latency reasons. This is especially crucial in last-write-wins databases like Cassandra, where ensuring the correct order and de-duplication of requests is vital.</p><p>In the Key-Value abstraction, idempotency tokens contain a generation timestamp and random nonce token. Either or both may be required by backing storage engines to de-duplicate mutations.</p><pre>message IdempotencyToken (<br>  Timestamp generation_time,<br>  String    token<br>)</pre><p>At Netflix, <strong>client-generated monotonic tokens</strong> are preferred due to their reliability, especially in environments where network delays could impact server-side token generation. This combines a client provided monotonic generation_time timestamp with a 128 bit random UUID token. Although clock-based token generation can suffer from clock skew, our tests on EC2 Nitro instances show drift is minimal (under 1 millisecond). In some cases that require stronger ordering, regionally unique tokens can be generated using tools like Zookeeper, or globally unique tokens such as a transaction IDs can be used.</p><p>The following graphs illustrate the observed <a href="https://docs.google.com/document/d/1XLBjQ9scZCy-xIo51Rs--CSdFV781fnp5hXdXTBAk1k/edit">clock skew</a> on our Cassandra fleet, suggesting the safety of this technique on modern cloud VMs with direct access to high-quality clocks. To further maintain safety, KV servers reject writes bearing tokens with large drift both preventing silent write discard (write has timestamp far in past) and immutable doomstones (write has a timestamp far in future) in storage engines vulnerable to those.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/832/0*gTmQpPIyZcKDb4Fb" /></figure><h4>Handling Large Data through Chunking</h4><p>Key-Value is also designed to efficiently handle large blobs, a common challenge for traditional key-value stores. Databases often face limitations on the amount of data that can be stored per key or partition. To address these constraints, KV uses transparent <strong>chunking</strong> to manage large data efficiently.</p><p>For items smaller than 1 MiB, data is stored directly in the main backing storage (e.g. Cassandra), ensuring fast and efficient access. However, for larger items, only the <strong>id</strong>, <strong>key</strong>, and <strong>metadata</strong> are stored in the primary storage, while the actual data is split into smaller chunks and stored separately in chunk storage. This chunk storage can also be Cassandra but with a different partitioning scheme optimized for handling large values. The idempotency token ties all these writes together into one atomic operation.</p><p>By splitting large items into chunks, we ensure that latency scales linearly with the size of the data, making the system both predictable and efficient. A future blog post will describe the <strong>chunking architecture</strong> in more detail, including its intricacies and optimization strategies.</p><h4>Client-Side Compression</h4><p>The KV abstraction leverages client-side payload compression to optimize performance, especially for large data transfers. While many databases offer server-side compression, handling compression on the client side reduces expensive server CPU usage, network bandwidth, and disk I/O. In one of our deployments, which helps power Netflix’s search, enabling client-side compression reduced payload sizes by 75%, significantly improving cost efficiency.</p><h4>Smarter Pagination</h4><p>We chose payload size in bytes as the limit per response page rather than the number of items because it allows us to provide predictable operation SLOs. For instance, we can provide a single-digit millisecond SLO on a 2 MiB page read. Conversely, using the number of items per page as the limit would result in unpredictable latencies due to significant variations in item size. A request for 10 items per page could result in vastly different latencies if each item was 1 KiB versus 1 MiB.</p><p>Using bytes as a limit poses challenges as few backing stores support byte-based pagination; most data stores use the number of results e.g. DynamoDB and Cassandra limit by number of items or rows. To address this, we use a static limit for the initial queries to the backing store, query with this limit, and process the results. If more data is needed to meet the byte limit, additional queries are executed until the limit is met, the excess result is discarded and a page token is generated.</p><p>This static limit can lead to inefficiencies, one large item in the result may cause us to discard many results, while small items may require multiple iterations to fill a page, resulting in read amplification. To mitigate these issues, we implemented <em>adaptive</em> pagination which dynamically tunes the limits based on observed data.</p><h4>Adaptive Pagination</h4><p>When an initial request is made, a query is executed in the storage engine, and the results are retrieved. As the consumer processes these results, the system tracks the number of items consumed and the total size used. This data helps calculate an approximate item size, which is stored in the page token. For subsequent page requests, this stored information allows the server to apply the appropriate limits to the underlying storage, reducing unnecessary work and minimizing read amplification.</p><p>While this method is effective for follow-up page requests, what happens with the initial request? In addition to storing item size information in the page token, the server also estimates the average item size for a given namespace and caches it locally. This cached estimate helps the server set a more optimal limit on the backing store for the initial request, improving efficiency. The server continuously adjusts this limit based on recent query patterns or other factors to keep it accurate. For subsequent pages, the server uses both the cached data and the information in the page token to fine-tune the limits.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/702/0*yg8xyQEoEmvKYoOV" /></figure><p>In addition to adaptive pagination, a mechanism is in place to send a response early if the server detects that processing the request is at risk of exceeding the request’s latency SLO.</p><p>For example, let us assume a client submits a GetItems request with a per-page limit of 2 MiB and a maximum end-to-end latency limit of 500ms. While processing this request, the server retrieves data from the backing store. This particular record has thousands of small items so it would normally take longer than the 500ms SLO to gather the full page of data. If this happens, the client would receive an SLO violation error, causing the request to fail even though there is nothing exceptional. To prevent this, the server tracks the elapsed time while fetching data. If it determines that continuing to retrieve more data might breach the SLO, the server will stop processing further results and return a response with a pagination token.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/764/0*hEkIfkUJ4KDnbbGx" /></figure><p>This approach ensures that requests are processed within the SLO, even if the full page size isn’t met, giving clients predictable progress. Furthermore, if the client is a gRPC server with proper deadlines, the client is smart enough not to issue further requests, reducing useless work.</p><p>If you want to know more, the <a href="https://www.infoq.com/articles/netflix-highly-reliable-stateful-systems/">How Netflix Ensures Highly-Reliable Online Stateful Systems</a> article talks in further detail about these and many other techniques.</p><h4>Signaling</h4><p>KV uses in-band messaging we call <em>signaling</em> that allows the dynamic configuration of the client and enables it to communicate its capabilities to the server. This ensures that configuration settings and tuning parameters can be exchanged seamlessly between the client and server. Without signaling, the client would need static configuration — requiring a redeployment for each change — or, with dynamic configuration, would require coordination with the client team.</p><p>For server-side signals, when the client is initialized, it sends a handshake to the server. The server responds back with signals, such as target or max latency SLOs, allowing the client to dynamically adjust timeouts and hedging policies. Handshakes are then made periodically in the background to keep the configuration current. For client-communicated signals, the client, along with each request, communicates its capabilities, such as whether it can handle compression, chunking, and other features.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/859/0*sVOLoSeIKpzDMQ5N" /></figure><h3>KV Usage @ Netflix</h3><p>The KV abstraction powers several key Netflix use cases, including:</p><ul><li><strong>Streaming Metadata</strong>: High-throughput, low-latency access to streaming metadata, ensuring personalized content delivery in real-time.</li><li><strong>User Profiles</strong>: Efficient storage and retrieval of user preferences and history, enabling seamless, personalized experiences across devices.</li><li><strong>Messaging</strong>: Storage and retrieval of <a href="https://netflixtechblog.com/pushy-to-the-limit-evolving-netflixs-websocket-proxy-for-the-future-b468bc0ff658">push registry</a> for messaging needs, enabling the millions of requests to flow through.</li><li><strong>Real-Time Analytics</strong>: This persists large-scale impression and provides insights into user behavior and system performance, <a href="https://netflixtechblog.com/bulldozer-batch-data-moving-from-data-warehouse-to-online-key-value-stores-41bac13863f8">moving data from offline to online</a> and vice versa.</li></ul><h3>Future Enhancements</h3><p>Looking forward, we plan to enhance the KV abstraction with:</p><ul><li><strong>Lifecycle Management</strong>: Fine-grained control over data retention and deletion.</li><li><strong>Summarization</strong>: Techniques to improve retrieval efficiency by summarizing records with many items into fewer backing rows.</li><li><strong>New Storage Engines</strong>: Integration with more storage systems to support new use cases.</li><li><strong>Dictionary Compression</strong>: Further reducing data size while maintaining performance.</li></ul><h3>Conclusion</h3><p>The Key-Value service at Netflix is a flexible, cost-effective solution that supports a wide range of data patterns and use cases, from low to high traffic scenarios, including critical Netflix streaming use-cases. The simple yet robust design allows it to handle diverse data models like HashMaps, Sets, Event storage, Lists, and Graphs. It abstracts the complexity of the underlying databases from our developers, which enables our application engineers to focus on solving business problems instead of becoming experts in every storage engine and their distributed <a href="https://jepsen.io/consistency">consistency models</a>. As Netflix continues to innovate in online datastores, the KV abstraction remains a central component in managing data efficiently and reliably at scale, ensuring a solid foundation for future growth.</p><p><strong><em>Acknowledgments:</em></strong><em> Special thanks to our stunning colleagues who contributed to Key Value’s success: </em><a href="https://www.linkedin.com/in/william-schor/"><em>William Schor</em></a><em>, </em><a href="https://www.linkedin.com/in/mengqingwang/"><em>Mengqing Wang</em></a><em>, </em><a href="https://www.linkedin.com/in/cthumuluru/"><em>Chandrasekhar Thumuluru</em></a><em>, </em><a href="https://www.linkedin.com/in/rajiv-shringi/"><em>Rajiv Shringi</em></a><em>, </em><a href="https://www.linkedin.com/in/john-l-693b7915a/"><em>John Lu</em></a><em>, </em><a href="https://www.linkedin.com/in/georgecampbell/"><em>George Cambell</em></a><em>, </em><a href="https://www.linkedin.com/in/akhaku/"><em>Ammar Khaku</em></a><em>, </em><a href="https://www.linkedin.com/in/jordan-west-8aa1731a3/"><em>Jordan West</em></a><em>, </em><a href="https://www.linkedin.com/in/clohfink/"><em>Chris Lohfink</em></a><em>, </em><a href="https://www.linkedin.com/in/matt-lehman-39549719b/"><em>Matt Lehman</em></a><em>, and the whole online datastores team (ODS, f.k.a CDE).</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=1ea8a0a11b30" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/introducing-netflixs-key-value-data-abstraction-layer-1ea8a0a11b30">Introducing Netflix’s Key-Value Data Abstraction Layer</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Pushy to the Limit: Evolving Netflix’s WebSocket proxy for the future]]></title>
            <link>https://netflixtechblog.com/pushy-to-the-limit-evolving-netflixs-websocket-proxy-for-the-future-b468bc0ff658?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/b468bc0ff658</guid>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Tue, 10 Sep 2024 19:15:34 GMT</pubDate>
            <atom:updated>2024-09-10T19:14:47.154Z</atom:updated>
            <content:encoded><![CDATA[<p><em>By </em><a href="https://www.linkedin.com/in/kyagna/"><em>Karthik Yagna</em></a><em>, </em><a href="https://www.linkedin.com/in/baskar-o-n-46477b3/"><em>Baskar Odayarkoil</em></a><em>, and </em><a href="https://www.linkedin.com/in/alexander-ellis/"><em>Alex Ellis</em></a></p><p>Pushy is Netflix’s WebSocket server that maintains persistent WebSocket connections with devices running the Netflix application. This allows data to be sent to the device from backend services on demand, without the need for continually polling requests from the device. Over the last few years, Pushy has seen tremendous growth, evolving from its role as a best-effort message delivery service to be an integral part of the Netflix ecosystem. This post describes how we’ve grown and scaled Pushy to meet its new and future needs, as it handles hundreds of millions of concurrent WebSocket connections, delivers hundreds of thousands of messages per second, and maintains a steady 99.999% message delivery reliability rate.</p><h3>History &amp; motivation</h3><p>There were two main motivating use cases that drove Pushy’s initial development and usage. The first was voice control, where you can play a title or search using your virtual assistant with a voice command like “Show me Stranger Things on Netflix.” (See <a href="https://help.netflix.com/en/node/111997"><em>How to use voice controls with Netflix</em></a> if you want to do this yourself!).</p><p>If we consider the Alexa use case, we can see how this partnership with Amazon enabled this to work. Once they receive the voice command, we allow them to make an authenticated call through <a href="https://netflixtechblog.com/open-sourcing-zuul-2-82ea476cb2b3">apiproxy</a>, our streaming edge proxy, to our internal voice service. This call includes metadata, such as the user’s information and details about the command, such as the specific show to play. The voice service then constructs a message for the device and places it on the message queue, which is then processed and sent to Pushy to deliver to the device. Finally, the device receives the message, and the action, such as “Show me Stranger Things on Netflix”, is performed. This initial functionality was built out for FireTVs and was expanded from there.</p><figure><img alt="Sample system diagram for an Alexa voice command, with the voice command entering Netflix’s cloud infrastructure via apiproxy and existing via a server-side message through Pushy to the device." src="https://cdn-images-1.medium.com/max/1024/0*WQ1W30ChfWrEmmR5" /><figcaption><em>Sample system diagram for an Alexa voice command. Where aws ends and the internet begins is an exercise left to the reader.</em></figcaption></figure><p>The other main use case was RENO, the Rapid Event Notification System mentioned above. Before the integration with Pushy, the TV UI would continuously poll a backend service to see if there were any row updates to get the latest information. These requests would happen every few seconds, which ended up creating extraneous requests to the backend and were costly for devices, which are frequently resource constrained. The integration with WebSockets and Pushy alleviated both of these points, allowing the origin service to send row updates as they were ready, resulting in lower request rates and cost savings.</p><p>For more background on Pushy, you can see <a href="https://www.youtube.com/watch?v=6w6E_B55p0E">this InfoQ talk by Susheel Aroskar</a>. Since that presentation, Pushy has grown in both size and scope, and this article will be discussing the investments we’ve made to evolve Pushy for the next generation of features.</p><h3>Client Reach</h3><p>This integration was initially rolled out for Fire TVs, PS4s, Samsung TVs, and LG TVs, leading to a reach of about 30 million candidate devices. With these clear benefits, we continued to build out this functionality for more devices, enabling the same efficiency wins. As of today, we’ve expanded our list of candidate devices even further to nearly a billion devices, including mobile devices running the Netflix app and the website experience. We’ve even extended support to older devices that lack modern capabilities, like support for TLS and HTTPS requests. For those, we’ve enabled secure communication from client to Pushy via an encryption/decryption layer on each, allowing for confidential messages to flow between the device and server.</p><h3>Scaling to handle that growth (and more)</h3><h4>Growth</h4><p>With that extended reach, Pushy has gotten busier. Over the last five years, Pushy has gone from tens of millions of concurrent connections to hundreds of millions of concurrent connections, and it regularly reaches 300,000 messages sent per second. To support this growth, we’ve revisited Pushy’s past assumptions and design decisions with an eye towards both Pushy’s future role and future stability. Pushy had been relatively hands-free operationally over the last few years, and as we updated Pushy to fit its evolving role, our goal was also to get it into a stable state for the next few years. This is particularly important as we build out new functionality that relies on Pushy; a strong, stable infrastructure foundation allows our partners to continue to build on top of Pushy with confidence.</p><p>Throughout this evolution, we’ve been able to maintain high availability and a consistent message delivery rate, with Pushy successfully maintaining 99.999% reliability for message delivery over the last few months. When our partners want to deliver a message to a device, it’s our job to make sure they can do so.</p><p>Here are a few of the ways we’ve evolved Pushy to handle its growing scale.</p><figure><img alt="A few of the related services in Pushy’s immediate ecosystem and the changes we’ve made for them." src="https://cdn-images-1.medium.com/max/1024/0*6yETYqbh6V9LhZcI" /><figcaption>A few of the related services in Pushy’s immediate ecosystem and the changes we’ve made for them.</figcaption></figure><h4>Message processor</h4><p>One aspect that we invested in was the evolution of the asynchronous message processor. The previous version of the message processor was a Mantis stream-processing job that processed messages from the message queue. It was very efficient, but it had a set job size, requiring manual intervention if we wanted to horizontally scale it, and it required manual intervention when rolling out a new version.</p><p>It served Pushy’s needs well for many years. As the scale of the messages being processed increased and we were making more code changes in the message processor, we found ourselves looking for something more flexible. In particular, we were looking for some of the features we enjoy with our other services: automatic horizontal scaling, canaries, automated red/black rollouts, and more observability. With this in mind, we rewrote the message processor as a standalone Spring Boot service using Netflix paved-path components. Its job is the same, but it does so with easy rollouts, canary configuration that lets us roll changes safely, and autoscaling policies we’ve defined to let it handle varying volumes.</p><p>Rewriting always comes with a risk, and it’s never the first solution we reach for, particularly when working with a system that’s in place and working well. In this case, we found that the burden from maintaining and improving the custom stream processing job was increasing, and we made the judgment call to do the rewrite. Part of the reason we did so was the clear role that the message processor played — we weren’t rewriting a huge monolithic service, but instead a well-scoped component that had explicit goals, well-defined success criteria, and a clear path towards improvement. Since the rewrite was completed in mid-2023, the message processor component has been completely zero touch, happily automated and running reliably on its own.</p><h4>Push Registry</h4><p>For most of its life, Pushy has used <a href="https://netflixtechblog.com/introducing-dynomite-making-non-distributed-databases-distributed-c7bce3d89404">Dynomite</a> for keeping track of device connection metadata in its Push Registry. Dynomite is a Netflix open source wrapper around Redis that provides a few additional features like auto-sharding and cross-region replication, and it provided Pushy with low latency and easy record expiry, both of which are critical for Pushy’s workload.</p><p>As Pushy’s portfolio grew, we experienced some pain points with Dynomite. Dynomite had great performance, but it required manual scaling as the system grew. The folks on the Cloud Data Engineering (CDE) team, the ones building the paved path for internal data at Netflix, graciously helped us scale it up and make adjustments, but it ended up being an involved process as we kept growing.</p><p>These pain points coincided with the introduction of KeyValue, which was a new offering from the CDE team that is roughly “HashMap as a service” for Netflix developers. KeyValue is an abstraction over the storage engine itself, which allows us to choose the best storage engine that meets our SLO needs. In our case, we value low latency — the faster we can read from KeyValue, the faster these messages can get delivered. With CDE’s help, we migrated our Push Registry to use KV instead, and we have been extremely satisfied with the result. After tuning our store for Pushy’s needs, it has been on autopilot since, appropriately scaling and serving our requests with very low latency.</p><h4>Scaling Pushy horizontally and vertically</h4><p>Most of the other services our team runs, like apiproxy, the streaming edge proxy, are CPU bound, and we have autoscaling policies that scale them horizontally when we see an increase in CPU usage. This maps well to their workload — more HTTP requests means more CPU used, and we can scale up and down accordingly.</p><p>Pushy has slightly different performance characteristics, with each node maintaining many connections and delivering messages on demand. In Pushy’s case, CPU usage is consistently low, since most of the connections are parked and waiting for an occasional message. Instead of relying on CPU, we scale Pushy on the number of connections, with exponential scaling to scale faster after higher thresholds are reached. We load balance the initial HTTP requests to establish the connections and rely on a reconnect protocol where devices will reconnect every 30 minutes or so, with some staggering, that gives us a steady stream of reconnecting devices to balance connections across all available instances.</p><p>For a few years, our scaling policy had been that we would add new instances when the average number of connections reached 60,000 connections per instance. For a couple hundred million devices, this meant that we were regularly running thousands of Pushy instances. We can horizontally scale Pushy to our heart’s content, but we would be less content with our bill and would have to shard Pushy further to get around NLB connection limits. This evolution effort aligned well with an internal focus on cost efficiency, and we used this as an opportunity to revisit these earlier assumptions with an eye towards efficiency.</p><p>Both of these would be helped by increasing the number of connections that each Pushy node could handle, reducing the total number of Pushy instances and running more efficiently with the right balance between instance type, instance cost, and maximum concurrent connections. It would also allow us to have more breathing room with the NLB limits, reducing the toil of additional sharding as we continue to grow. That being said, increasing the number of connections per node is not without its own drawbacks. When a Pushy instance goes down, the devices that were connected to it will immediately try to reconnect. By increasing the number of connections per instance, it means that we would be increasing the number of devices that would be immediately trying to reconnect. We could have a million connections per instance, but a down node would lead to a thundering herd of a million devices reconnecting at the same time.</p><p>This delicate balance led to us doing a deep evaluation of many instance types and performance tuning options. Striking that balance, we ended up with instances that handle an average of 200,000 connections per node, with breathing room to go up to 400,000 connections if we had to. This makes for a nice balance between CPU usage, memory usage, and the thundering herd when a device connects. We’ve also enhanced our autoscaling policies to scale exponentially; the farther we are past our target average connection count, the more instances we’ll add. These improvements have enabled Pushy to be almost entirely hands off operationally, giving us plenty of flexibility as more devices come online in different patterns.</p><h4>Reliability &amp; building a stable foundation</h4><p>Alongside these efforts to scale Pushy for the future, we also took a close look at our reliability after finding some connectivity edge cases during recent feature development. We found a few areas for improvement around the connection between Pushy and the device, with failures due to Pushy attempting to send messages on a connection that had failed without notifying Pushy. Ideally something like a silent failure wouldn’t happen, but we frequently see odd client behavior, particularly on older devices.</p><p>In collaboration with the client teams, we were able to make some improvements. On the client side, better connection handling and improvements around the reconnect flow meant that they were more likely to reconnect appropriately. In Pushy, we added additional heartbeats, idle connection cleanup, and better connection tracking, which meant that we were keeping around fewer and fewer stale connections.</p><p>While these improvements were mostly around those edge cases for the feature development, they had the side benefit of bumping our message delivery rates up even further. We already had a good message delivery rate, but this additional bump has enabled Pushy to regularly average 5 9s of message delivery reliability.</p><figure><img alt="Push message delivery success rate over a recent 2-week period, staying consistently over 5 9s of reliability." src="https://cdn-images-1.medium.com/max/1024/0*SFyzjaMH524tYkkQ" /><figcaption><em>Push message delivery success rate over a recent 2-week period.</em></figcaption></figure><h3>Recent developments</h3><p>With this stable foundation and all of these connections, what can we now do with them? This question has been the driving force behind nearly all of the recent features built on top of Pushy, and it’s an exciting question to ask, particularly as an infrastructure team.</p><h4>Shift towards direct push</h4><p>The first change from Pushy’s traditional role is what we call direct push; instead of a backend service dropping the message on the asynchronous message queue, it can instead leverage the Push library to skip the asynchronous queue entirely. When called to deliver a message in the direct path, the Push library will look up the Pushy connected to the target device in the Push Registry, then send the message directly to that Pushy. Pushy will respond with a status code reflecting whether it was able to successfully deliver the message or it encountered an error, and the Push library will bubble that up to the calling code in the service.</p><figure><img alt="The system diagram for the direct and indirect push paths. The direct push path goes directly from a backend service to Pushy, while the indirect path goes to a decoupled message queue, which is then handled by a message processor and sent on to Pushy." src="https://cdn-images-1.medium.com/max/1024/0*PJHwCgmRYIYMVPcl" /><figcaption>The system diagram for the direct and indirect push paths.</figcaption></figure><p>Susheel, the original author of Pushy, added this functionality as an optional path, but for years, nearly all backend services relied on the indirect path with its “best-effort” being good enough for their use cases. In recent years, we’ve seen usage of this direct path really take off as the needs of backend services have grown. In particular, rather than being just best effort, these direct messages allow the calling service to have immediate feedback about the delivery, letting them retry if a device they’re targeting has gone offline.</p><p>These days, messages sent via direct push make up the majority of messages sent through Pushy. For example, for a recent 24 hour period, direct messages averaged around 160,000 messages per second and indirect averaged at around 50,000 messages per second..</p><figure><img alt="Graph of direct vs indirect messages per second, showing around 150,000 direct messages per second and around 50,000 indirect messages per second." src="https://cdn-images-1.medium.com/max/653/0*oCI-seLx9OMSYZQk" /><figcaption>Graph of direct vs indirect messages per second.</figcaption></figure><h4>Device to device messaging</h4><p>As we’ve thought through this evolving use case, our concept of a message sender has also evolved. What if we wanted to move past Pushy’s pattern of delivering server-side messages? What if we wanted to have a device send a message to a backend service, or maybe even to another device? Our messages had traditionally been unidirectional as we send messages from the server to the device, but we now leverage these bidirectional connections and direct device messaging to enable what we call device to device messaging. This device to device messaging supported early phone-to-TV communication in support of games like Triviaverse, and it’s the messaging foundation for our <a href="https://help.netflix.com/en/node/132821">Companion Mode</a> as TVs and phones communicate back and forth.</p><figure><img alt="A screenshot of one of the authors playing Triviaquest with a mobile device as the controller." src="https://cdn-images-1.medium.com/max/596/1*rA3HZj7YEo5Sp4Xjp5c6EA.png" /><figcaption>A screenshot of one of the authors playing Triviaquest with a mobile device as the controller.</figcaption></figure><p>This requires higher level knowledge of the system, where we need to know not just information about a single device, but more broader information, like what devices are connected for an account that the phone can pair with. This also enables things like subscribing to device events to know when another device comes online and when they’re available to pair or send a message to. This has been built out with an additional service that receives device connection information from Pushy. These events, sent over a Kafka topic, let the service keep track of the device list for a given account. Devices can subscribe to these events, allowing them to receive a message from the service when another device for the same account comes online.</p><figure><img alt="Pushy and its relationship with the Device List Service for discovering other devices. Pushy reaches out to the Device List Service, and when it receives the device list in response, propagates that back to the requesting device." src="https://cdn-images-1.medium.com/max/1024/0*PhEf0jXvhXbx6kwN" /><figcaption>Pushy and its relationship with the Device List Service for discovering other devices.</figcaption></figure><p>This device list enables the discoverability aspect of these device to device messages. Once the devices have this knowledge of the other devices connected for the same account, they’re able to choose a target device from this list that they can then send messages to.</p><p>Once a device has that list, it can send a message to Pushy over its WebSocket connection with that device as the target in what we call a <em>device to device message</em> (1 in the diagram below). Pushy looks up the target device’s metadata in the Push registry (2) and sends the message to the second Pushy that the target device is connected to (3), as if it was the backend service in the direct push pattern above. That Pushy delivers the message to the target device (4), and the original Pushy will receive a status code in response, which it can pass back to the source device (5).</p><figure><img alt="A basic order of events for a device to device message." src="https://cdn-images-1.medium.com/max/1005/0*dEQ1TpVfTQNs3eg4" /><figcaption>A basic order of events for a device to device message.</figcaption></figure><h4>The messaging protocol</h4><p>We’ve defined a basic JSON-based message protocol for device to device messaging that lets these messages be passed from the source device to the target device. As a networking team, we naturally lean towards abstracting the communication layer with encapsulation wherever possible. This generalized message means that device teams are able to define their own protocols on top of these messages — Pushy would just be the transport layer, happily forwarding messages back and forth.</p><figure><img alt="A simple block diagram showing the client app protocol on top of the device to device protocol, which itself is on top of the WebSocket &amp; Pushy protocol." src="https://cdn-images-1.medium.com/max/354/1*4-ijw8c0BTX9r20jVIgKNA.png" /><figcaption>The client app protocol, built on top of the device to device protocol, built on top of Pushy.</figcaption></figure><p>This generalization paid off in terms of investment and operational support. We built the majority of this functionality in October 2022, and we’ve only needed small tweaks since then. We needed nearly no modifications as client teams built out the functionality on top of this layer, defining the higher level application-specific protocols that powered the features they were building. We really do enjoy working with our partner teams, but if we’re able to give them the freedom to build on top of our infrastructure layer without us getting involved, then we’re able to increase their velocity, make their lives easier, and play our infrastructure roles as message platform providers.</p><p>With early features in experimentation, Pushy sees an average of 1000 device to device messages per second, a number that will only continue to grow.</p><figure><img alt="Graph of device to device messages per second, showing an average of 1000 messages per second." src="https://cdn-images-1.medium.com/max/742/0*6gn9UvREat4OqRoU" /><figcaption>Graph of device to device messages per second.</figcaption></figure><h4>The Netty-gritty details</h4><p>In Pushy, we handle incoming WebSocket messages in our PushClientProtocolHandler (<a href="https://github.com/Netflix/zuul/blob/99ef8841c8b7b82536d5fb193fd751c675c9ad0d/zuul-core/src/main/java/com/netflix/zuul/netty/server/push/PushClientProtocolHandler.java">code pointer to class in Zuul that we extend</a>), which extends Netty’s ChannelInboundHandlerAdapter and is added to the Netty pipeline for each client connection. We listen for incoming WebSocket messages from the connected device in its channelRead method and parse the incoming message. If it’s a device to device message, we pass the message, the ChannelHandlerContext, and the PushUserAuth information about the connection’s identity to our DeviceToDeviceManager.</p><figure><img alt="A rough overview of the internal organization for these components, with the code classes described above. Inside Pushy, a Push Client Protocol handler inside a Netty Channel calls out to the Device to Device manager, which itself calls out to the Push Message Sender class that forwards the message on to the other Pushy." src="https://cdn-images-1.medium.com/max/842/0*cp-lfclw0ayykX2H" /><figcaption>A rough overview of the internal organization for these components.</figcaption></figure><p>The DeviceToDeviceManager is responsible for validating the message, doing some bookkeeping, and kicking off an async call that validates that the device is an authorized target, looks up the Pushy for the target device in the local cache (or makes a call to the data store if it’s not found), and forwards on the message. We run this asynchronously to avoid any event loop blocking due to these calls. The DeviceToDeviceManager is also responsible for observability, with metrics around cache hits, calls to the data store, message delivery rates, and latency percentile measurements. We’ve relied heavily on these metrics for alerts and optimizations — Pushy really is a metrics service that occasionally will deliver a message or two!</p><h4>Security</h4><p>As the edge of the Netflix cloud, security considerations are always top of mind. With every connection over HTTPS, we’ve limited these messages to just authenticated WebSocket connections, added rate limiting, and added authorization checks to ensure that a device is able to target another device — you may have the best intentions in mind, but I’d strongly prefer it if you weren’t able to send arbitrary data to my personal TV from yours (and vice versa, I’m sure!).</p><h4>Latency and other considerations</h4><p>One main consideration with the products built on top of this is latency, particularly when this feature is used for anything interactive within the Netflix app.</p><p>We’ve added caching to Pushy to reduce the number of lookups in the hotpath for things that are unlikely to change frequently, like a device’s allowed list of targets and the Pushy instance the target device is connected to. We have to do some lookups on the initial messages to know where to send them, but it enables us to send subsequent messages faster without any KeyValue lookups. For these requests where caching removed KeyValue from the hot path, we were able to greatly speed things up. From the incoming message arriving at Pushy to the response being sent back to the device, we reduced median latency to less than a millisecond, with the 99th percentile of latency at less than 4ms.</p><p>Our KeyValue latency is usually very low, but we have seen brief periods of elevated read latencies due to underlying issues in our KeyValue datastore. Overall latencies increased for other parts of Pushy, like client registration, but we saw very little increase in device to device latency with this caching in place.</p><h3>Cultural aspects that enable this work</h3><p>Pushy’s scale and system design considerations make the work technically interesting, but we also deliberately focus on non-technical aspects that have helped to drive Pushy’s growth. We focus on iterative development that solves the hardest problem first, with projects frequently starting with quick hacks or prototypes to prove out a feature. As we do this initial version, we do our best to keep an eye towards the future, allowing us to move quickly from supporting a single, focused use case to a broad, generalized solution. For example, for our cross-device messaging, we were able to solve hard problems in the early work for <em>Triviaverse</em> that we later leveraged for the generic device to device solution.</p><p>As one can immediately see in the system diagrams above, Pushy does not exist in a vacuum, with projects frequently involving at least half a dozen teams. Trust, experience, communication, and strong relationships all enable this to work. Our team wouldn’t exist without our platform users, and we certainly wouldn’t be here writing this post without all of the work our product and client teams do. This has also emphasized the importance of building and sharing — if we’re able to get a prototype together with a device team, we’re able to then show it off to seed ideas from other teams. It’s one thing to mention that you can send these messages, but it’s another to show off the TV responding to the first click of the phone controller button!</p><h3>The future of Pushy</h3><p>If there’s anything certain in this world, it’s that Pushy will continue to grow and evolve. We have many new features in the works, like WebSocket message proxying, WebSocket message tracing, a global broadcast mechanism, and subscription functionality in support of Games and Live. With all of this investment, Pushy is a stable, reinforced foundation, ready for this next generation of features.</p><p>We’ll be writing about those new features as well — stay tuned for future posts.</p><p><em>Special thanks to our stunning colleagues </em><a href="https://www.linkedin.com/in/jeremy-kelly-526a30180/"><em>Jeremy Kelly</em></a><em> and </em><a href="https://www.linkedin.com/in/justin-guerra-3282262b/"><em>Justin Guerra</em></a><em> who have both been invaluable to Pushy’s growth and the WebSocket ecosystem at large. We would also like to thank our larger teams and our numerous partners for their great work; it truly takes a village!</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b468bc0ff658" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/pushy-to-the-limit-evolving-netflixs-websocket-proxy-for-the-future-b468bc0ff658">Pushy to the Limit: Evolving Netflix’s WebSocket proxy for the future</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Noisy Neighbor Detection with eBPF]]></title>
            <link>https://netflixtechblog.com/noisy-neighbor-detection-with-ebpf-64b1f4b3bbdd?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/64b1f4b3bbdd</guid>
            <category><![CDATA[observability]]></category>
            <category><![CDATA[linux]]></category>
            <category><![CDATA[ebpf]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[containers]]></category>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Tue, 10 Sep 2024 18:00:21 GMT</pubDate>
            <atom:updated>2024-09-10T23:58:25.805Z</atom:updated>
            <content:encoded><![CDATA[<p><em>By </em><a href="https://www.linkedin.com/in/josefernandezmn/"><em>Jose Fernandez</em></a><em>, </em><a href="https://www.linkedin.com/in/sebastien-dabdoub-2a5a0958/"><em>Sebastien Dabdoub</em></a><em>, </em><a href="https://www.linkedin.com/in/jason-koch-5692172/"><em>Jason Koch</em></a><em>, </em><a href="https://www.linkedin.com/in/artemtkachuk/"><em>Artem Tkachuk</em></a></p><p>The Compute and Performance Engineering teams at Netflix regularly investigate performance issues in our multi-tenant environment. The first step is determining whether the problem originates from the application or the underlying infrastructure. One issue that often complicates this process is the &quot;noisy neighbor&quot; problem. On <a href="https://netflixtechblog.com/titus-the-netflix-container-management-platform-is-now-open-source-f868c9fb5436">Titus</a>, our multi-tenant compute platform, a &quot;noisy neighbor&quot; refers to a container or system service that heavily utilizes the server&#39;s resources, causing performance degradation in adjacent containers. We usually focus on CPU utilization because it is our workloads’ most frequent source of noisy neighbor issues.</p><p>Detecting the effects of noisy neighbors is complex. Traditional performance analysis tools such as <a href="https://www.brendangregg.com/perf.html">perf</a> can introduce significant overhead, risking further performance degradation. Additionally, these tools are typically deployed after the fact, which is too late for effective investigation.<em> </em>Another challenge is that debugging noisy neighbor issues requires significant low-level expertise and specialized tooling<em>. </em>In this blog post, we&#39;ll reveal how we leveraged <a href="https://ebpf.io/">eBPF</a> to achieve continuous, low-overhead instrumentation of the Linux scheduler, enabling effective self-serve monitoring of noisy neighbor issues. You’ll learn how Linux kernel instrumentation can improve your infrastructure observability with deeper insights and enhanced monitoring.</p><h3>Continuous Instrumentation of the Linux Scheduler</h3><p>To ensure the reliability of our workloads that depend on low latency responses, we instrumented the <a href="https://en.wikipedia.org/wiki/Run_queue">run queue</a> latency for each container, which measures the time processes spend in the scheduling queue before being dispatched to the CPU. Extended waiting in this queue can be a telltale of performance issues, especially when containers are not utilizing their total CPU allocation. Continuous instrumentation is critical to catching such matters as they emerge, and eBPF, with its hooks into the Linux scheduler with minimal overhead, enabled us to monitor run queue latency efficiently.</p><p>To emit a run queue latency metric, we leveraged three eBPF hooks: sched_wakeup<strong>, </strong>sched_wakeup_new<strong>,</strong> and sched_switch.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6bapyclfXZPsUIaXFM-xaQ.png" /><figcaption>Diagram of how run queue latency is measured and instrumented</figcaption></figure><p>The sched_wakeup<strong> </strong>and sched_wakeup_new hooks are invoked when a process changes state from &#39;sleeping&#39; to &#39;runnable.&#39; They let us identify when a process is ready to run and is waiting for CPU time. During this event, we generate a timestamp and store it in an eBPF hash map using the process ID as the key.</p><pre>struct {<br>    __uint(type, BPF_MAP_TYPE_HASH);<br>    __uint(max_entries, MAX_TASK_ENTRIES);<br>    __uint(key_size, sizeof(u32));<br>    __uint(value_size, sizeof(u64));<br>} runq_enqueued SEC(&quot;.maps&quot;);<br><br>SEC(&quot;tp_btf/sched_wakeup&quot;)<br>int tp_sched_wakeup(u64 *ctx)<br>{<br>    struct task_struct *task = (void *)ctx[0];<br>    u32 pid = task-&gt;pid;<br>    u64 ts = bpf_ktime_get_ns();<br><br>    bpf_map_update_elem(&amp;runq_enqueued, &amp;pid, &amp;ts, BPF_NOEXIST);<br>    return 0;<br>}</pre><p>Conversely, the sched_switch hook is triggered when the CPU switches between processes. This hook provides pointers to the process currently utilizing the CPU and the process about to take over. We use the upcoming task&#39;s process ID (PID) to fetch the timestamp from the eBPF map. This timestamp represents when the process entered the queue, which we had previously stored. We then calculate the run queue latency by simply subtracting the timestamps.</p><pre>SEC(&quot;tp_btf/sched_switch&quot;)<br>int tp_sched_switch(u64 *ctx)<br>{<br>    struct task_struct *prev = (struct task_struct *)ctx[1];<br>    struct task_struct *next = (struct task_struct *)ctx[2];<br>    u32 prev_pid = prev-&gt;pid;<br>    u32 next_pid = next-&gt;pid;<br> <br>    // fetch timestamp of when the next task was enqueued<br>    u64 *tsp = bpf_map_lookup_elem(&amp;runq_enqueued, &amp;next_pid);<br>    if (tsp == NULL) {<br>        return 0; // missed enqueue<br>    }<br><br>    // calculate runq latency before deleting the stored timestamp<br>    u64 now = bpf_ktime_get_ns();<br>    u64 runq_lat = now - *tsp;<br><br>    // delete pid from enqueued map<br>    bpf_map_delete_elem(&amp;runq_enqueued, &amp;next_pid);<br>    ....</pre><p>One of the advantages of eBPF is its ability to provide pointers to the actual kernel data structures representing processes or threads, also known as tasks in kernel terminology. This feature enables access to a wealth of information stored about a process. We required the process&#39;s cgroup ID to associate it with a container for our specific use case. However, the cgroup information in the process struct is safeguarded by an<a href="https://elixir.bootlin.com/linux/v6.6.16/source/include/linux/sched.h#L1225"> RCU (Read Copy Update) lock</a>.</p><p>To safely access this RCU-protected information, we can leverage <a href="https://docs.kernel.org/bpf/kfuncs.html">kfuncs</a> in eBPF. kfuncs are kernel functions that can be called from eBPF programs. There are kfuncs available to lock and unlock RCU read-side critical sections. These functions ensure that our eBPF program remains safe and efficient while retrieving the cgroup ID from the task struct.</p><pre>void bpf_rcu_read_lock(void) __ksym;<br>void bpf_rcu_read_unlock(void) __ksym;<br><br>u64 get_task_cgroup_id(struct task_struct *task)<br>{<br>    struct css_set *cgroups;<br>    u64 cgroup_id;<br>    bpf_rcu_read_lock();<br>    cgroups = task-&gt;cgroups;<br>    cgroup_id = cgroups-&gt;dfl_cgrp-&gt;kn-&gt;id;<br>    bpf_rcu_read_unlock();<br>    return cgroup_id;<br>}</pre><p>Once the data is ready, we must package it and send it to userspace. For this purpose, we chose the eBPF <a href="https://nakryiko.com/posts/bpf-ringbuf/">ring buffer</a>. It is efficient, high-performing, and user-friendly. It can handle variable-length data records and allows data reading without necessitating extra memory copying or syscalls. However, the sheer number of data points was causing the userspace program to use too much CPU, so we implemented a rate limiter in eBPF to sample the data.</p><pre>struct {<br>    __uint(type, BPF_MAP_TYPE_RINGBUF);<br>    __uint(max_entries, RINGBUF_SIZE_BYTES);<br>} events SEC(&quot;.maps&quot;);<br><br>struct {<br>    __uint(type, BPF_MAP_TYPE_PERCPU_HASH);<br>    __uint(max_entries, MAX_TASK_ENTRIES);<br>    __uint(key_size, sizeof(u64));<br>    __uint(value_size, sizeof(u64));<br>} cgroup_id_to_last_event_ts SEC(&quot;.maps&quot;);<br><br>struct runq_event {<br>    u64 prev_cgroup_id;<br>    u64 cgroup_id;<br>    u64 runq_lat;<br>    u64 ts;<br>};<br><br>SEC(&quot;tp_btf/sched_switch&quot;)<br>int tp_sched_switch(u64 *ctx)<br>{<br>    // ....<br>    // The previous code<br>    // ....<br> <br>    u64 prev_cgroup_id = get_task_cgroup_id(prev);<br>    u64 cgroup_id = get_task_cgroup_id(next);<br> <br>    // per-cgroup-id-per-CPU rate-limiting <br>    // to balance observability with performance overhead<br>    u64 *last_ts = <br>        bpf_map_lookup_elem(&amp;cgroup_id_to_last_event_ts, &amp;cgroup_id);<br>    u64 last_ts_val = last_ts == NULL ? 0 : *last_ts;<br><br>    // check the rate limit for the cgroup_id in consideration<br>    // before doing more work<br>    if (now - last_ts_val &lt; RATE_LIMIT_NS) {<br>        // Rate limit exceeded, drop the event<br>        return 0;<br>    }<br><br>    struct runq_event *event;<br>    event = bpf_ringbuf_reserve(&amp;events, sizeof(*event), 0);<br>  <br>    if (event) {<br>        event-&gt;prev_cgroup_id = prev_cgroup_id;<br>        event-&gt;cgroup_id = cgroup_id;<br>        event-&gt;runq_lat = runq_lat;<br>        event-&gt;ts = now;<br>        bpf_ringbuf_submit(event, 0);<br>        // Update the last event timestamp for the current cgroup_id<br>        bpf_map_update_elem(&amp;cgroup_id_to_last_event_ts, &amp;cgroup_id,<br>            &amp;now, BPF_ANY);<br><br>    }<br><br>    return 0;<br>}</pre><p>Our userspace application, developed in Go, processes events from the ring buffer to emit metrics to our metrics backend, <a href="https://netflixtechblog.com/introducing-atlas-netflixs-primary-telemetry-platform-bd31f4d8ed9a">Atlas</a>. Each event includes a run queue latency sample with a cgroup ID, which we associate with containers running on the host. We categorize it as a system service if no such association is found. When a cgroup ID is associated with a container, we emit a percentile timer Atlas metric (runq.latency) for that container. We also increment a counter metric (sched.switch.out) to monitor preemptions occurring for the container&#39;s processes. Access to the prev_cgroup_id of the preempted process allows us to tag the metric with the cause of the preemption, whether it&#39;s due to a process within the same container (or cgroup), a process in another container, or a system service.</p><p>It&#39;s important to highlight that both the runq.latency metric and the sched.switch.out metrics are needed to determine if a container is affected by noisy neighbors, which is the goal we aim to achieve — relying solely on the runq.latency metric can lead to misconceptions. For example, if a container is at or over its cgroup CPU limit, the scheduler will throttle it, resulting in an apparent spike in run queue latency due to delays in the queue. If we were only to consider this metric, we might incorrectly attribute the performance degradation to noisy neighbors when it&#39;s actually because the container is hitting its CPU quota. However, simultaneous spikes in both metrics, mainly when the cause is a different container or system process, clearly indicate a noisy neighbor issue.</p><h3>A Noisy Neighbor Story</h3><p>Below is the runq.latency metric for a server running a single container with ample CPU capacity. The 99th percentile averages 83.4µs (microseconds), serving as our baseline. Although there are some spikes reaching 400µs, the latency remains within acceptable parameters.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*_DcYxRgeDwX5i07IrdTZyA.png" /><figcaption>container1’s 99th percentile runq.latency averages 83µs (microseconds), with spikes up to 400µs, without adjacent containers. This serves as our baseline for a container not contending for CPU on a host.</figcaption></figure><p>At 10:35, launching container2, which fully utilized all CPUs on the host, caused a significant 131-millisecond spike (131,000 microseconds) in container1&#39;s P99 run queue latency. This spike would be noticeable in the userspace application if it were serving HTTP traffic. If userspace app owners reported an unexplained latency spike, we could quickly identify the noisy neighbor issue through run queue latency metrics.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*DJrwEbrWPOxVMS0JP7uE9A.png" /><figcaption>Launching container2 at 10:35, which maxes out all CPUs on the host, <strong>caused a 131-millisecond spike in container1’s P99 run queue latency</strong> due to increased preemptions by system processes. This indicates a noisy neighbor issue, where system services compete for CPU time with containers.</figcaption></figure><p>The sched.switch.out metric indicates that the spike was due to increased preemptions by system processes, highlighting a noisy neighbor issue where system services compete with containers for CPU time. Our metrics show that the noisy neighbors were actually system processes, likely triggered by container2 consuming all available CPU capacity.</p><h3>Optimizing eBPF Code</h3><p>We developed an open-source eBPF process monitoring tool called <a href="https://netflixtechblog.com/announcing-bpftop-streamlining-ebpf-performance-optimization-6a727c1ae2e5">bpftop</a> to measure the overhead of eBPF code in this kernel hot path. Our profiling with bpftop shows that the instrumentation adds less than 600 nanoseconds to each sched_* hook. We conducted a performance analysis on a Java service running in a container, and the instrumentation did not introduce significant overhead. The performance variance with the run queue profiling code active versus inactive was not measurable in milliseconds.</p><p>During our research on how eBPF statistics are measured in the kernel, we identified an opportunity to improve the calculation. We submitted this <a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=ce09cbdd988887662546a1175bcfdfc6c8fdd150">patch</a>, which was included in the Linux kernel 6.10 release.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*YD6hkXce9a70AgvSHstgWA.gif" /></figure><p>Through trial and error and using bpftop, we identified several optimizations that helped maintain low overhead for our eBPF code:</p><ul><li>We found that BPF_MAP_TYPE_HASH was the most performant for storing enqueued timestamps. Using BPF_MAP_TYPE_TASK_STORAGE resulted in nearly a twofold performance decline. BPF_MAP_TYPE_PERCPU_HASH was slightly less performant than BPF_MAP_TYPE_HASH, which was unexpected and requires further investigation.</li><li>BPF_MAP_TYPE_LRU_HASH maps are 40–50 nanoseconds slower per operation than regular hash maps. Due to space concerns from PID churn, we initially used them for enqueued timestamps. Ultimately, we settled on BPF_MAP_TYPE_HASH with an increased size to mitigate this risk.</li><li>The BPF_CORE_READ helper adds 20–30 nanoseconds per invocation. In the case of raw tracepoints, specifically those that are &quot;BTF-enabled&quot; (tp_btf/*), it is safe and more efficient to access the task struct members directly. Andrii Nakryiko recommends this approach in this <a href="https://nakryiko.com/posts/bpf-core-reference-guide/#btf-enabled-bpf-program-types-with-direct-memory-reads">blog post</a>.</li><li>The sched_switch, sched_wakeup, and sched_wakeup_new are all triggered for kernel tasks, which are identifiable by their PID of 0. We found monitoring these tasks unnecessary, so we implemented several early exit conditions and conditional logic to prevent executing costly operations, such as accessing BPF maps, when dealing with a kernel task. Notably, kernel tasks operate through the scheduler queue like any regular process.</li></ul><h3>Conclusion</h3><p>Our findings highlight the value of low-overhead continuous instrumentation of the Linux kernel with eBPF. We have integrated these metrics into customer dashboards, enabling actionable insights and guiding multitenancy performance discussions. We can also now use these metrics to refine CPU isolation strategies to minimize the impact of noisy neighbors. Additionally, thanks to these metrics, we&#39;ve gained deeper insights into the Linux scheduler.</p><p>This work has also deepened our understanding of eBPF technology and underscored the importance of tools like bpftop for optimizing eBPF code. As eBPF adoption increases, we foresee more infrastructure observability and business logic shifting to it. One promising project in this space is <a href="https://github.com/sched-ext/scx">sched_ext</a>, which has the potential to revolutionize how scheduling decisions are made and tailored to specific workload needs.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=64b1f4b3bbdd" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/noisy-neighbor-detection-with-ebpf-64b1f4b3bbdd">Noisy Neighbor Detection with eBPF</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Recommending for Long-Term Member Satisfaction at Netflix]]></title>
            <link>https://netflixtechblog.com/recommending-for-long-term-member-satisfaction-at-netflix-ac15cada49ef?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/ac15cada49ef</guid>
            <category><![CDATA[reward-engineering]]></category>
            <category><![CDATA[contextual-bandit]]></category>
            <category><![CDATA[recommendation-system]]></category>
            <category><![CDATA[machine-learning]]></category>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Thu, 29 Aug 2024 01:01:40 GMT</pubDate>
            <atom:updated>2024-08-29T00:59:13.618Z</atom:updated>
            <content:encoded><![CDATA[<p>By <a href="https://www.linkedin.com/in/jiangwei-pan-66a62a13/">Jiangwei Pan</a>, <a href="https://www.linkedin.com/in/thegarytang/">Gary Tang</a>, <a href="https://www.linkedin.com/in/henry-kang-wang-06701716/">Henry Wang</a>, and <a href="https://www.linkedin.com/in/jbasilico/">Justin Basilico</a></p><h3>Introduction</h3><p>Our mission at Netflix is to entertain the world. Our personalization algorithms play a crucial role in delivering on this mission for all members by recommending the right shows, movies, and games at the right time. This goal extends beyond immediate engagement; we aim to create an experience that brings lasting enjoyment to our members. Traditional recommender systems often optimize for short-term metrics like clicks or engagement, which may not fully capture long-term satisfaction. We strive to recommend content that not only engages members in the moment but also enhances their long-term satisfaction, which increases the value they get from Netflix, and thus they’ll be more likely to continue to be a member.</p><h3>Recommendations as Contextual Bandit</h3><p>One simple way we can view recommendations is as a contextual bandit problem. When a member visits, that becomes a context for our system and it selects an action of what recommendations to show, and then the member provides various types of feedback. These feedback signals can be immediate (skips, plays, thumbs up/down, or adding items to their playlist) or delayed (completing a show or renewing their subscription). We can define reward functions to reflect the quality of the recommendations from these feedback signals and then train a contextual bandit policy on historical data to maximize the expected reward.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Y8QDcyallv_mh7ylPzXqkA.png" /></figure><h3>Improving Recommendations: Models and Objectives</h3><p>There are many ways that a recommendation model can be improved. They may come from more informative input features, more data, different architectures, more parameters, and so forth. In this post, we focus on a less-discussed aspect about improving the recommender objective by defining a reward function that tries to better reflect long-term member satisfaction.</p><h3>Retention as Reward?</h3><p>Member retention might seem like an obvious reward for optimizing long-term satisfaction because members should stay if they’re satisfied, however it has several drawbacks:</p><ul><li><strong>Noisy</strong>: Retention can be influenced by numerous external factors, such as seasonal trends, marketing campaigns, or personal circumstances unrelated to the service.</li><li><strong>Low Sensitivity</strong>: Retention is only sensitive for members on the verge of canceling their subscription, not capturing the full spectrum of member satisfaction.</li><li><strong>Hard to Attribute</strong>: Members might cancel only after a series of bad recommendations.</li><li><strong>Slow to Measure</strong>: We only get one signal per account per month.</li></ul><p>Due to these challenges, optimizing for retention alone is impractical.</p><h3>Proxy Rewards</h3><p>Instead, we can train our bandit policy to optimize a proxy reward function that is highly aligned with long-term member satisfaction while being sensitive to individual recommendations. The proxy reward <em>r(user, item)</em> is a function of user interaction with the recommended item. For example, if we recommend “One Piece” and a member plays then subsequently completes and gives it a thumbs-up, a simple proxy reward might be defined as <em>r(user, item) = f(play, complete, thumb)</em>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*xfSMqEoF0I2_qOPu" /></figure><h4>Click-through rate (CTR)</h4><p>Click-through rate (CTR), or in our case play-through rate, can be viewed as a simple proxy reward where <em>r(user, item) </em>= 1 if the user clicks a recommendation and 0 otherwise. CTR is a common feedback signal that generally reflects user preference expectations. It is a simple yet strong baseline for many recommendation applications. In some cases, such as ads personalization where the click is the target action, CTR may even be a reasonable reward for production models. However, in most cases, over-optimizing CTR can lead to promoting clickbaity items, which may harm long-term satisfaction.</p><h4>Beyond CTR</h4><p>To align the proxy reward function more closely with long-term satisfaction, we need to look beyond simple interactions, consider all types of user actions, and understand their true implications on user satisfaction.</p><p>We give a few examples in the Netflix context:</p><ul><li><strong>Fast season completion </strong>✅: Completing a season of a recommended TV show in one day is a strong sign of enjoyment and long-term satisfaction.</li><li><strong>Thumbs-down after completion </strong>❌: Completing a TV show in several weeks followed by a thumbs-down indicates low satisfaction despite significant time spent.</li><li><strong>Playing a movie for just 10 minutes </strong>❓: In this case, the user’s satisfaction is ambiguous. The brief engagement might indicate that the user decided to abandon the movie, or it could simply mean the user was interrupted and plans to finish the movie later, perhaps the next day.</li><li><strong>Discovering new genres </strong>✅ ✅: Watching more Korean or game shows after “Squid Game” suggests the user is discovering something new. This discovery was likely even more valuable since it led to a variety of engagements in a new area for a member.</li></ul><h3>Reward Engineering</h3><p>Reward engineering is the iterative process of refining the proxy reward function to align with long-term member satisfaction. It is similar to feature engineering, except that it can be derived from data that isn’t available at serving time. Reward engineering involves four stages: hypothesis formation, defining a new proxy reward, training a new bandit policy, and A/B testing. Below is a simple example.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*YRi8chIaj_OlV-Fd" /></figure><h3>Challenge: Delayed Feedback</h3><p>User feedback used in the proxy reward function is often delayed or missing. For example, a member may decide to play a recommended show for just a few minutes on the first day and take several weeks to fully complete the show. This completion feedback is therefore delayed. Additionally, some user feedback may never occur; while we may wish otherwise, not all members provide a thumbs-up or thumbs-down after completing a show, leaving us uncertain about their level of enjoyment.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*cfammHCaAxkEjJhL" /></figure><p>We could try and wait to give a longer window to observe feedback, but how long should we wait for delayed feedback before computing the proxy rewards? If we wait too long (e.g., weeks), we miss the opportunity to update the bandit policy with the latest data. In a highly dynamic environment like Netflix, a stale bandit policy can degrade the user experience and be particularly bad at recommending newer items.</p><h4>Solution: predict missing feedback</h4><p>We aim to update the bandit policy shortly after making a recommendation while also defining the proxy reward function based on all user feedback, including delayed feedback. Since delayed feedback has not been observed at the time of policy training, we can predict it. This prediction occurs for each training example with delayed feedback, using already observed feedback and other relevant information up to the training time as input features. Thus, the prediction also gets better as time progresses.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*-dmyaQqosWyMq-UU" /></figure><p>The proxy reward is then calculated for each training example using both observed and predicted feedback. These training examples are used to update the bandit policy.</p><p>But aren’t we still only relying on observed feedback in the proxy reward function? Yes, because delayed feedback is predicted based on observed feedback. However, it is simpler to reason about rewards using all feedback directly. For instance, the delayed thumbs-up prediction model may be a complex neural network that takes into account all observed feedback (e.g., short-term play patterns). It’s more straightforward to define the proxy reward as a simple function of the thumbs-up feedback rather than a complex function of short-term interaction patterns. It can also be used to adjust for potential biases in how feedback is provided.</p><p>The reward engineering diagram is updated with an optional delayed feedback prediction step.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Rnu7B69daM-JY13CdtM6IQ.png" /></figure><h4>Two types of ML models</h4><p>It’s worth noting that this approach employs two types of ML models:</p><ul><li><strong>Delayed Feedback Prediction Models</strong>: These models predict <em>p(final feedback | observed feedbacks)</em>. The predictions are used to define and compute proxy rewards for bandit policy training examples. As a result, these models are used offline during the bandit policy training.</li><li><strong>Bandit Policy Models</strong>: These models are used in the bandit policy <em>π(item | user; r)</em> to generate recommendations online and in real-time.</li></ul><h3>Challenge: Online-Offline Metric Disparity</h3><p>Improved input features or neural network architectures often lead to better offline model metrics (e.g., AUC for classification models). However, when these improved models are subjected to A/B testing, we often observe flat or even negative online metrics, which can quantify long-term member satisfaction.</p><p>This online-offline metric disparity usually occurs when the proxy reward used in the recommendation policy is not fully aligned with long-term member satisfaction. In such cases, a model may achieve higher proxy rewards (offline metrics) but result in worse long-term member satisfaction (online metrics).</p><p>Nevertheless, the model improvement is genuine. One approach to resolve this is to further refine the proxy reward definition to align better with the improved model. When this tuning results in positive online metrics, the model improvement can be effectively productized. See [1] for more discussions on this challenge.</p><h3>Summary and Open Questions</h3><p>In this post, we provided an overview of our reward engineering efforts to align Netflix recommendations with long-term member satisfaction. While retention remains our north star, it is not easy to optimize directly. Therefore, our efforts focus on defining a proxy reward that is aligned with long-term satisfaction and sensitive to individual recommendations. Finally, we discussed the unique challenge of delayed user feedback at Netflix and proposed an approach that has proven effective for us. Refer to [2] for an earlier overview of the reward innovation efforts at Netflix.</p><p>As we continue to improve our recommendations, several open questions remain:</p><ul><li>Can we learn a good proxy reward function automatically by correlating behavior with retention?</li><li>How long should we wait for delayed feedback before using its predicted value in policy training?</li><li>How can we leverage Reinforcement Learning to further align the policy with long-term satisfaction?</li></ul><h3>References</h3><p>[1] <a href="https://ojs.aaai.org/aimagazine/index.php/aimagazine/article/view/18140">Deep learning for recommender systems: A Netflix case study</a>. AI Magazine 2021. Harald Steck, Linas Baltrunas, Ehtsham Elahi, Dawen Liang, Yves Raimond, Justin Basilico.</p><p>[2] <a href="https://web.archive.org/web/20231011142826id_/https://dl.acm.org/doi/pdf/10.1145/3604915.3608873">Reward innovation for long-term member satisfaction</a>. RecSys 2023. Gary Tang, Jiangwei Pan, Henry Wang, Justin Basilico.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ac15cada49ef" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/recommending-for-long-term-member-satisfaction-at-netflix-ac15cada49ef">Recommending for Long-Term Member Satisfaction at Netflix</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Improve Your Next Experiment by Learning Better Proxy Metrics From Past Experiments]]></title>
            <link>https://netflixtechblog.com/improve-your-next-experiment-by-learning-better-proxy-metrics-from-past-experiments-64c786c2a3ac?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/64c786c2a3ac</guid>
            <category><![CDATA[data-science]]></category>
            <category><![CDATA[experimentation]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[a-b-testing]]></category>
            <category><![CDATA[statistics]]></category>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Mon, 26 Aug 2024 15:46:24 GMT</pubDate>
            <atom:updated>2024-08-26T15:44:02.705Z</atom:updated>
            <content:encoded><![CDATA[<p><em>By </em><a href="https://www.linkedin.com/in/aurelien-bibaut/"><em>Aurélien Bibaut</em></a><em>, </em><a href="https://www.linkedin.com/in/winston-chou-6491b0168/"><em>Winston Chou</em></a><em>, </em><a href="https://www.linkedin.com/in/simon-ejdemyr-22b920123/"><em>Simon Ejdemyr</em></a><em>, and </em><a href="https://www.linkedin.com/in/kallus/"><em>Nathan Kallus</em></a></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*m_lKjIe460GlWr5JseoQzw.jpeg" /></figure><p>We are excited to share <a href="https://arxiv.org/pdf/2402.17637">our work</a> on how to learn good proxy metrics from historical experiments at <a href="https://kdd2024.kdd.org/">KDD 2024</a>. This work addresses a fundamental question for technology companies and academic researchers alike: how do we establish that a treatment that improves short-term (statistically sensitive) outcomes also improves long-term (statistically insensitive) outcomes? Or, faced with multiple short-term outcomes, how do we optimally trade them off for long-term benefit?</p><p>For example, in an A/B test, you may observe that a product change improves the click-through rate. However, the test does not provide enough signal to measure a change in long-term retention, leaving you in the dark as to whether this treatment makes users more satisfied with your service. The click-through rate is a <em>proxy metric</em> (<em>S</em>, for surrogate, in our paper) while retention is a downstream <em>business outcome </em>or <em>north star metric </em>(<em>Y</em>). We may even have several proxy metrics, such as other types of clicks or the length of engagement after click. Taken together, these form a <em>vector</em> of proxy metrics.</p><p>The goal of our work is to understand the true relationship between the proxy metric(s) and the north star metric — so that we can assess a proxy’s ability to stand in for the north star metric, learn how to combine multiple metrics into a single best one, and better explore and compare different proxies.</p><p>Several intuitive approaches to understanding this relationship have surprising pitfalls:</p><ul><li><strong>Looking only at user-level correlations between the proxy <em>S </em>and north star <em>Y</em>.</strong> Continuing the example from above, you may find that users with a higher click-through rate also tend to have a higher retention. But this does not mean that a <em>product change </em>that improves the click-through rate will also improve retention (in fact, promoting clickbait may have the opposite effect). This is because, as any introductory causal inference class will tell you, there are many confounders between <em>S </em>and <em>Y</em> — many of which you can never reliably observe and control for.</li><li><strong>Looking naively at treatment effect correlations between <em>S </em>and <em>Y.</em></strong> Suppose you are lucky enough to have many historical A/B tests. Further imagine the ordinary least squares (OLS) regression line through a scatter plot of <em>Y </em>on <em>S</em> in which each point represents the (<em>S</em>,<em>Y</em>)-treatment effect from a previous test. Even if you find that this line has a positive slope, you unfortunately <em>cannot</em> conclude that product changes that improve <em>S </em>will also improve <em>Y</em>. The reason for this is correlated measurement error — if <em>S</em> and <em>Y</em> are positively correlated in the population, then treatment arms that happen to have more users with high <em>S</em> will also have more users with high <em>Y</em>.</li></ul><p>Between these naive approaches, we find that the second one is the easier trap to fall into. This is because the dangers of the first approach are well-known, whereas covariances between <em>estimated</em> treatment effects can appear misleadingly causal. In reality, these covariances can be severely biased compared to what we actually care about: covariances between <em>true</em> treatment effects. In the extreme — such as when the negative effects of clickbait are substantial but clickiness and retention are highly correlated at the user level — the true relationship between <em>S </em>and <em>Y </em>can be negative even if the OLS slope is positive. Only more data per experiment could diminish this bias — using more experiments as data points will only yield more precise estimates of the badly biased slope. At first glance, this would appear to imperil any hope of using existing experiments to detect the relationship.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*o0Br8UYxvXPga-Sh" /><figcaption><em>This figure shows a hypothetical treatment effect covariance matrix between S and Y (white line; negative correlation), a unit-level sampling covariance matrix creating correlated measurement errors between these metrics (black line; positive correlation), and the covariance matrix of estimated treatment effects which is a weighted combination of the first two (orange line; no correlation).</em></figcaption></figure><p>To overcome this bias, we propose better ways to leverage historical experiments, inspired by techniques from the literature on weak instrumental variables. More specifically, we show that three estimators are consistent for the true proxy/north-star relationship under different constraints (the <a href="https://arxiv.org/pdf/2402.17637">paper</a> provides more details and should be helpful for practitioners interested in choosing the best estimator for their setting):</p><ul><li>A <strong>Total Covariance (TC) </strong>estimator allows us to estimate the OLS slope from a scatter plot of <em>true </em>treatment effects by subtracting the scaled measurement error covariance from the covariance of estimated treatment effects. Under the assumption that the correlated measurement error is the same across experiments (homogeneous covariances), the bias of this estimator is inversely proportional to the total number of units across all experiments, as opposed to the number of members per experiment.</li><li><strong>Jackknife Instrumental Variables Estimation (JIVE)</strong> converges to the same OLS slope as the TC estimator but does not require the assumption of homogeneous covariances. JIVE eliminates correlated measurement error by removing each observation’s data from the computation of its instrumented surrogate values.</li><li>A <strong>Limited Information Maximum Likelihood (LIML) </strong>estimator is statistically efficient as long as there are no direct effects between the treatment and <em>Y</em> (that is, <em>S</em> fully mediates all treatment effects on <em>Y</em>). We find that LIML is highly sensitive to this assumption and recommend TC or JIVE for most applications.</li></ul><p>Our methods yield linear structural models of treatment effects that are easy to interpret. As such, they are well-suited to the decentralized and rapidly-evolving practice of experimentation at Netflix, which runs <a href="https://netflixtechblog.com/experimentation-is-a-major-focus-of-data-science-across-netflix-f67923f8e985">thousands of experiments per year</a> on many diverse parts of the business. Each area of experimentation is staffed by independent Data Science and Engineering teams. While every team ultimately cares about the same north star metrics (e.g., long-term revenue), it is highly impractical for most teams to measure these in short-term A/B tests. Therefore, each has also developed proxies that are more sensitive and directly relevant to their work (e.g., user engagement or latency). To complicate matters more, teams are constantly innovating on these secondary metrics to find the right balance of sensitivity and long-term impact.</p><p>In this decentralized environment, linear models of treatment effects are a highly useful tool for coordinating efforts around proxy metrics and aligning them towards the north star:</p><ol><li><strong>Managing metric tradeoffs.</strong> Because experiments in one area can affect metrics in another area, there is a need to measure all secondary metrics in all tests, but also to understand the relative impact of these metrics on the north star. This is so we can inform decision-making when one metric trades off against another metric.</li><li><strong>Informing metrics innovation.</strong> To minimize wasted effort on metric development, it is also important to understand how metrics correlate with the north star “net of” existing metrics.</li><li><strong>Enabling teams to work independently.</strong> Lastly, teams need simple tools in order to iterate on their own metrics. Teams may come up with dozens of variations of secondary metrics, and slow, complicated tools for evaluating these variations are unlikely to be adopted. Conversely, our models are easy and fast to fit, and are actively used to develop proxy metrics at Netflix.</li></ol><p>We are thrilled about the research and implementation of these methods at Netflix — while also continuing to strive for <strong><em>great and always better</em></strong>, per our <a href="https://jobs.netflix.com/culture">culture</a>. For example, we still have some way to go to develop a more flexible data architecture to streamline the application of these methods within Netflix. Interested in helping us? See our <a href="https://jobs.netflix.com/">open job postings</a>!</p><p><em>For feedback on this blog post and for supporting and making this work better, we thank Apoorva Lal, Martin Tingley, Patric Glynn, Richard McDowell, Travis Brooks, and Ayal Chen-Zion.</em></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=64c786c2a3ac" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/improve-your-next-experiment-by-learning-better-proxy-metrics-from-past-experiments-64c786c2a3ac">Improve Your Next Experiment by Learning Better Proxy Metrics From Past Experiments</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Investigation of a Cross-regional Network Performance Issue]]></title>
            <link>https://netflixtechblog.com/investigation-of-a-cross-regional-network-performance-issue-422d6218fdf1?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/422d6218fdf1</guid>
            <category><![CDATA[tcp]]></category>
            <category><![CDATA[debugging]]></category>
            <category><![CDATA[kernel]]></category>
            <category><![CDATA[network]]></category>
            <category><![CDATA[linux]]></category>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Mon, 05 Aug 2024 22:18:00 GMT</pubDate>
            <atom:updated>2024-04-24T20:40:41.603Z</atom:updated>
            <content:encoded><![CDATA[<p><a href="https://www.linkedin.com/in/hechaoli/">Hechao Li</a>, <a href="https://www.linkedin.com/in/rogercruz/">Roger Cruz</a></p><h3>Cloud Networking Topology</h3><p>Netflix operates a highly efficient cloud computing infrastructure that supports a wide array of applications essential for our SVOD (Subscription Video on Demand), live streaming and gaming services. Utilizing Amazon AWS, our infrastructure is hosted across multiple geographic regions worldwide. This global distribution allows our applications to deliver content more effectively by serving traffic closer to our customers. Like any distributed system, our applications occasionally require data synchronization between regions to maintain seamless service delivery.</p><p>The following diagram shows a simplified cloud network topology for cross-region traffic.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1022/0*RpHklRseVBeBJG6u" /></figure><h3>The Problem At First Glance</h3><p>Our Cloud Network Engineering on-call team received a request to address a network issue affecting an application with cross-region traffic. Initially, it appeared that the application was experiencing timeouts, likely due to suboptimal network performance. As we all know, the longer the network path, the more devices the packets traverse, increasing the likelihood of issues. For this incident, <strong>the client application is located in an internal subnet in the US region while the server application is located in an external subnet in a European region</strong>. Therefore, it is natural to blame the network since packets need to travel long distances through the internet.</p><p>As network engineers, our initial reaction when the network is blamed is typically, “No, it can’t be the network,” and our task is to prove it. Given that there were no recent changes to the network infrastructure and no reported AWS issues impacting other applications, the on-call engineer suspected a noisy neighbor issue and sought assistance from the Host Network Engineering team.</p><h3>Blame the Neighbors</h3><p>In this context, a noisy neighbor issue occurs when a container shares a host with other network-intensive containers. <strong>These noisy neighbors consume excessive network resources, causing other containers on the same host to suffer from degraded network performance. </strong>Despite each container having bandwidth limitations, oversubscription can still lead to such issues.</p><p>Upon investigating other containers on the same host — most of which were part of the same application — we quickly eliminated the possibility of noisy neighbors. <strong>The network throughput for both the problematic container and all others was significantly below the set bandwidth limits.</strong> We attempted to resolve the issue by removing these bandwidth limits, allowing the application to utilize as much bandwidth as necessary. However, the problem persisted.</p><h3>Blame the Network</h3><p>We observed some <strong>TCP packets in the network marked with the RST flag</strong>, a flag indicating that a connection should be immediately terminated. Although the frequency of these packets was not alarmingly high, the presence of any RST packets still raised suspicion on the network. To determine whether this was indeed a network-induced issue, we conducted a tcpdump on the client. In the packet capture file, we spotted one TCP stream that was closed after exactly 30 seconds.</p><p>SYN at 18:47:06</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*ZLnTrJNuCBe4tUry" /></figure><p>After the 3-way handshake (SYN,SYN-ACK,ACK), the traffic started flowing normally. Nothing strange until FIN at 18:47:36 (30 seconds later)</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*0-aCcRviD0JHcngn" /></figure><p>The packet capture results clearly indicated that <strong>it was the client application that initiated the connection termination by sending a FIN packet</strong>. Following this, the server continued to send data; however, since the client had already decided to close the connection, it responded with RST packets to all subsequent data from the server.</p><p>To ensure that the client wasn’t closing the connection due to packet loss, we also conducted a packet capture on the server side to verify that all packets sent by the server were received. This task was complicated by the fact that the packets passed through a NAT gateway (NGW), which meant that on the server side, the client’s IP and port appeared as those of the NGW, differing from those seen on the client side. Consequently, to accurately match TCP streams, <strong>we needed to identify the TCP stream on the client side, locate the raw TCP sequence number, and then use this number as a filter on the server side to find the corresponding TCP stream.</strong></p><p>With packet capture results from both the client and server sides, we confirmed that <strong>all packets sent by the server were correctly received before the client sent a FIN</strong>.</p><p>Now, from the network point of view, the story is clear. The client initiated the connection requesting data from the server. The server kept sending data to the client with no problem. However, at a certain point, <strong>despite the server still having data to send, the client chose to terminate the reception of data</strong>. This led us to suspect that the issue might be related to the client application itself.</p><h3>Blame the Application</h3><p>In order to fully understand the problem, we now need to understand how the application works. As shown in the diagram below, the application runs in the us-east-1 region. <strong>It reads data from cross-region servers and writes the data to consumers within the same region.</strong> The client runs as containers, whereas the servers are EC2 instances.</p><p><strong>Notably, the cross-region read was problematic </strong>while the write path was smooth. Most importantly, there is a 30-second application-level timeout for reading the data. The application (client) errors out if it fails to read an initial batch of data from the servers within 30 seconds. When we increased this timeout to 60 seconds, everything worked as expected. <strong>This explains why the client initiated a FIN — because it lost patience waiting for the server to transfer data</strong>.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/752/0*zNmSGl1_5vtOHETn" /></figure><p>Could it be that the server was updated to send data more slowly? Could it be that the client application was updated to receive data more slowly? Could it be that the data volume became too large to be completely sent out within 30 seconds? Sadly, <strong>we received negative answers for all 3 questions from the application owner.</strong> The server had been operating without changes for over a year, there were no significant updates in the latest rollout of the client, and the data volume had remained consistent.</p><h3>Blame the Kernel</h3><p>If both the network and the application weren’t changed recently, then what changed? In fact, we discovered that the issue coincided with a recent <strong>Linux kernel upgrade from version 6.5.13 to 6.6.10</strong>. To test this hypothesis, we rolled back the kernel upgrade and it did restore normal operation to the application.</p><p>Honestly speaking, at that time I didn’t believe it was a kernel bug because I assumed the TCP implementation in the kernel should be solid and stable (Spoiler alert: How wrong was I!). But we were also out of ideas from other angles.</p><p>There were about 14k commits between the good and bad kernel versions. Engineers on the team methodically and diligently bisected between the two versions. When the bisecting was narrowed to a couple of commits, <strong>a change with “tcp” in its commit message caught our attention. The final bisecting confirmed that </strong><a href="https://lore.kernel.org/netdev/20230717152917.751987-1-edumazet@google.com/T/"><strong>this commit</strong></a><strong> was our culprit</strong>.</p><p>Interestingly, while reviewing the email history related to this commit, we found that <a href="https://github.com/eventlet/eventlet/issues/821">another user had reported a Python test failure following the same kernel upgrade</a>. Although their solution was not directly applicable to our situation, it suggested that <strong>a simpler test might also reproduce our problem</strong>. Using <em>strace</em>, we observed that the application configured the following socket options when communicating with the server:</p><pre>[pid 1699] setsockopt(917, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0<br>[pid 1699] setsockopt(917, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0<br>[pid 1699] setsockopt(917, SOL_SOCKET, SO_SNDBUF, [131072], 4) = 0<br>[pid 1699] setsockopt(917, SOL_SOCKET, SO_RCVBUF, [65536], 4) = 0<br>[pid 1699] setsockopt(917, SOL_TCP, TCP_NODELAY, [1], 4) = 0</pre><p>We then developed a minimal client-server C application that transfers a file from the server to the client, with the client configuring the same set of socket options. During testing, we used a 10M file, which represents the volume of data typically transferred within 30 seconds before the client issues a FIN. <strong>On the old kernel, this cross-region transfer completed in 22 seconds, whereas on the new kernel, it took 39 seconds to finish.</strong></p><h3>The Root Cause</h3><p>With the help of the minimal reproduction setup, we were ultimately able to pinpoint the root cause of the problem. In order to understand the root cause, it’s essential to have a grasp of the TCP receive window.</p><h4>TCP Receive Window</h4><p>Simply put, <strong>the TCP receive window is how the receiver tells the sender “This is how many bytes you can send me without me ACKing any of them”</strong>. Assuming the sender is the server and the receiver is the client, then we have:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*98gJP81W46nhdonq" /></figure><h4>The Window Size</h4><p>Now that we know the TCP receive window size could affect the throughput, the question is, how is the window size calculated? As an application writer, you can’t decide the window size, however, you can decide how much memory you want to use for buffering received data. This is configured using <strong><em>SO_RCVBUF</em> socket option</strong> we saw in the <em>strace</em> result above. However, note that the value of this option means how much <strong>application data</strong> can be queued in the receive buffer. In <a href="https://man7.org/linux/man-pages/man7/socket.7.html">man 7 socket</a>, there is</p><blockquote>SO_RCVBUF</blockquote><blockquote>Sets or gets the maximum socket receive buffer in bytes.<br> The kernel doubles this value (to allow space for<br> bookkeeping overhead) when it is set using setsockopt(2),<br> and this doubled value is returned by getsockopt(2). The<br> default value is set by the<br> /proc/sys/net/core/rmem_default file, and the maximum<br> allowed value is set by the /proc/sys/net/core/rmem_max<br> file. The minimum (doubled) value for this option is 256.</blockquote><p>This means, when the user gives a value X, then <a href="https://elixir.bootlin.com/linux/v6.9-rc1/source/net/core/sock.c#L976">the kernel stores 2X in the variable sk-&gt;sk_rcvbuf</a>. In other words, <strong>the kernel assumes that the bookkeeping overhead is as much as the actual data (i.e. 50% of the sk_rcvbuf)</strong>.</p><h4>sysctl_tcp_adv_win_scale</h4><p>However, the assumption above may not be true because the actual overhead really depends on a lot of factors such as Maximum Transmission Unit (MTU). Therefore, <strong>the kernel provided this <em>sysctl_tcp_adv_win_scale</em> which you can use to tell the kernel what the actual overhead is</strong>. (I believe 99% of people also don’t know how to set this parameter correctly and I’m definitely one of them. You’re the kernel, if you don’t know the overhead, how can you expect me to know?).</p><p>According to <a href="https://docs.kernel.org/networking/ip-sysctl.html">the <em>sysctl</em> doc</a>,</p><blockquote><em>tcp_adv_win_scale — INTEGER</em></blockquote><blockquote><em>Obsolete since linux-6.6 Count buffering overhead as bytes/2^tcp_adv_win_scale (if tcp_adv_win_scale &gt; 0) or bytes-bytes/2^(-tcp_adv_win_scale), if it is &lt;= 0.</em></blockquote><blockquote><em>Possible values are [-31, 31], inclusive.</em></blockquote><blockquote><em>Default: 1</em></blockquote><p>For 99% of people, we’re just using the default value 1, which in turn means the overhead is calculated by <em>rcvbuf/2^tcp_adv_win_scale = 1/2 * rcvbuf</em>. This matches the assumption when setting the <em>SO_RCVBUF</em> value.</p><p>Let’s recap. Assume you set <em>SO_RCVBUF</em> to 65536, which is the value set by the application as shown in the <em>setsockopt</em> syscall. Then we have:</p><ul><li>SO_RCVBUF = 65536</li><li>rcvbuf = 2 * 65536 = 131072</li><li>overhead = rcvbuf / 2 = 131072 / 2 = 65536</li><li>receive window size = rcvbuf — overhead = 131072–65536 = 65536</li></ul><p>(Note, this calculation is simplified. The real calculation is more complex.)</p><p>In short, the receive window size before the kernel upgrade was 65536. With this window size, the application was able to transfer 10M data within 30 seconds.</p><h4>The Change</h4><p><a href="https://lore.kernel.org/netdev/20230717152917.751987-1-edumazet@google.com/T/">This commit</a> obsoleted <em>sysctl_tcp_adv_win_scale</em> and introduced a <em>scaling_ratio</em> that can more accurately calculate the overhead or window size, which is the right thing to do. With the change, the window size is now <em>rcvbuf * scaling_ratio</em>.</p><p>So how is <em>scaling_ratio</em> calculated? It is calculated using <strong><em>skb-&gt;len/skb-&gt;truesize</em></strong> where <em>skb-&gt;len</em> is the length of the tcp data length in an <em>skb</em> and <em>truesize</em> is the total size of the <em>skb</em>. <strong>This is surely a more accurate ratio based on real data rather than a hardcoded 50%.</strong> Now, here is the next question: during the TCP handshake <strong>before any data is transferred, how do we decide the initial <em>scaling_ratio</em>? </strong>The answer is, a magic and conservative ratio was chosen with the value being roughly 0.25.</p><p>Now we have:</p><ul><li>SO_RCVBUF = 65536</li><li>rcvbuf = 2 * 65536 = 131072</li><li>receive window size = rcvbuf * 0.25 = 131072 * 0.25 = 32768</li></ul><p>In short, <strong>the receive window size halved after the kernel upgrade. Hence the throughput was cut in half</strong>,<strong> causing the data transfer time to double.</strong></p><p>Naturally, you may ask, I understand that the initial window size is small, but <strong>why doesn’t the window grow when we have a more accurate ratio of the payload later</strong> (i.e. <em>skb-&gt;len/skb-&gt;truesize</em>)? With some debugging, we eventually found out that the <em>scaling_ratio</em> does <a href="https://elixir.bootlin.com/linux/v6.7.9/source/net/ipv4/tcp_input.c#L248">get updated to a more accurate <em>skb-&gt;len/skb-&gt;truesize</em></a>, which in our case is around 0.66. However, another variable, <em>window_clamp</em>, is not updated accordingly. <em>window_clamp</em> is the <a href="https://elixir.bootlin.com/linux/v6.7.9/source/include/linux/tcp.h#L256">maximum receive window allowed to be advertised</a>, which is also initialized to <em>0.25 * rcvbuf </em>using the initial <em>scaling_ratio</em>. As a result, <strong>the receive window size is capped at this value and can’t grow bigger</strong>.</p><h3>The Fix</h3><p>In theory, the fix is to update <em>window_clamp</em> along with <em>scaling_ratio</em>. However, in order to have a simple fix that doesn’t introduce other unexpected behaviors, <a href="https://git.kernel.org/pub/scm/linux/kernel/git/netdev/net-next.git/commit/?id=697a6c8cec03">our final fix was to increase the initial <em>scaling_ratio</em> from 25% to 50%</a>. This will make the receive window size backward compatible with the original default <em>sysctl_tcp_adv_win_scale</em>.</p><p>Meanwhile, notice that the problem is not only caused by the changed kernel behavior but also by the fact that the application sets <em>SO_RCVBUF</em> and has a 30-second application-level timeout. In fact, the application is Kafka Connect and both settings are the default configurations (<a href="https://kafka.apache.org/documentation/#connectconfigs_receive.buffer.bytes"><em>receive.buffer.bytes=64k</em></a> and <a href="https://kafka.apache.org/documentation/#consumerconfigs_request.timeout.ms"><em>request.timeout.ms=30s</em></a>). We also<a href="https://issues.apache.org/jira/browse/KAFKA-16496"> created a kafka ticket to change receive.buffer.bytes to -1</a> to allow Linux to auto tune the receive window.</p><h3>Conclusion</h3><p>This was a very interesting debugging exercise that covered many layers of Netflix’s stack and infrastructure. While it technically wasn’t the “network” to blame, this time it turned out the culprit was the software components that make up the network (i.e. the TCP implementation in the kernel).</p><p>If tackling such technical challenges excites you, consider joining our Cloud Infrastructure Engineering teams. Explore opportunities by visiting <a href="https://jobs.netflix.com/">Netflix Jobs</a> and searching for Cloud Engineering positions.</p><h3>Acknowledgments</h3><p>Special thanks to our stunning colleagues <a href="https://www.linkedin.com/in/alok-tiagi-99205015/">Alok Tiagi</a>, <a href="https://www.linkedin.com/in/artemtkachuk/">Artem Tkachuk</a>, <a href="https://www.linkedin.com/in/jethanadams/">Ethan Adams</a>, <a href="https://www.linkedin.com/in/jorge-rodriguez-12b5595/">Jorge Rodriguez</a>, <a href="https://www.linkedin.com/in/nickmahilani/">Nick Mahilani</a>, <a href="https://tycho.pizza/">Tycho Andersen</a> and <a href="https://www.linkedin.com/in/vinay-rayini/">Vinay Rayini</a> for investigating and mitigating this issue. We would also like to thank Linux kernel network expert <a href="https://www.linkedin.com/in/eric-dumazet-ba252942/">Eric Dumazet</a> for reviewing and applying the patch.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=422d6218fdf1" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/investigation-of-a-cross-regional-network-performance-issue-422d6218fdf1">Investigation of a Cross-regional Network Performance Issue</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Java 21 Virtual Threads - Dude, Where’s My Lock?]]></title>
            <link>https://netflixtechblog.com/java-21-virtual-threads-dude-wheres-my-lock-3052540e231d?source=rss----2615bd06b42e---4</link>
            <guid isPermaLink="false">https://medium.com/p/3052540e231d</guid>
            <category><![CDATA[java]]></category>
            <category><![CDATA[performance]]></category>
            <category><![CDATA[troubleshooting]]></category>
            <category><![CDATA[concurrency]]></category>
            <category><![CDATA[distributed-systems]]></category>
            <dc:creator><![CDATA[Netflix Technology Blog]]></dc:creator>
            <pubDate>Mon, 29 Jul 2024 18:04:05 GMT</pubDate>
            <atom:updated>2024-07-29T18:03:30.765Z</atom:updated>
            <content:encoded><![CDATA[<h4>Getting real with virtual threads</h4><p>By <a href="https://www.linkedin.com/in/vfilanovsky/">Vadim Filanovsky</a>, <a href="https://www.linkedin.com/in/mike-huang-a552781/">Mike Huang</a>, <a href="https://www.linkedin.com/in/danny-thomas-a623413/">Danny Thomas</a> and <a href="https://www.linkedin.com/in/martinchalupa/">Martin Chalupa</a></p><h3>Intro</h3><p>Netflix has an extensive history of using Java as our primary programming language across our vast fleet of microservices. As we pick up newer versions of Java, our JVM Ecosystem team seeks out new language features that can improve the ergonomics and performance of our systems. In a <a href="https://netflixtechblog.com/bending-pause-times-to-your-will-with-generational-zgc-256629c9386b">recent article</a>, we detailed how our workloads benefited from switching to generational ZGC as our default garbage collector when we migrated to Java 21. Virtual threads is another feature we are excited to adopt as part of this migration.</p><p>For those new to virtual threads, <a href="https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html">they are described</a> as “lightweight threads that dramatically reduce the effort of writing, maintaining, and observing high-throughput concurrent applications.” Their power comes from their ability to be suspended and resumed automatically via continuations when blocking operations occur, thus freeing the underlying operating system threads to be reused for other operations. Leveraging virtual threads can unlock higher performance when utilized in the appropriate context.</p><p>In this article we discuss one of the peculiar cases that we encountered along our path to deploying virtual threads on Java 21.</p><h3>The problem</h3><p>Netflix engineers raised several independent reports of intermittent timeouts and hung instances to the Performance Engineering and JVM Ecosystem teams. Upon closer examination, we noticed a set of common traits and symptoms. In all cases, the apps affected ran on Java 21 with SpringBoot 3 and embedded Tomcat serving traffic on REST endpoints. The instances that experienced the issue simply stopped serving traffic even though the JVM on those instances remained up and running. One clear symptom characterizing the onset of this issue is a persistent increase in the number of sockets in closeWait state as illustrated by the graph below:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/800/1*b5oZiN2Ew96GEeZ9oIIhPA.png" /></figure><h3>Collected diagnostics</h3><p>Sockets remaining in closeWait state indicate that the remote peer closed the socket, but it was never closed on the local instance, presumably because the application failed to do so. This can often indicate that the application is hanging in an abnormal state, in which case application thread dumps may reveal additional insight.</p><p>In order to troubleshoot this issue, we first leveraged our <a href="https://netflixtechblog.com/improved-alerting-with-atlas-streaming-eval-e691c60dc61e">alerts system</a> to catch an instance in this state. Since we periodically collect and persist thread dumps for all JVM workloads, we can often retroactively piece together the behavior by examining these thread dumps from an instance. However, we were surprised to find that all our thread dumps show a perfectly idle JVM with no clear activity. Reviewing recent changes revealed that these impacted services enabled virtual threads, and we knew that virtual thread call stacks do not show up in jstack-generated thread dumps. To obtain a more complete thread dump containing the state of the virtual threads, we used the “jcmd Thread.dump_to_file” command instead. As a last-ditch effort to introspect the state of JVM, we also collected a heap dump from the instance.</p><h3>Analysis</h3><p>Thread dumps revealed thousands of “blank” virtual threads:</p><pre>#119821 &quot;&quot; virtual<br><br>#119820 &quot;&quot; virtual<br><br>#119823 &quot;&quot; virtual<br><br>#120847 &quot;&quot; virtual<br><br>#119822 &quot;&quot; virtual<br>...</pre><p>These are the VTs (virtual threads) for which a thread object is created, but has not started running, and as such, has no stack trace. In fact, there were approximately the same number of blank VTs as the number of sockets in closeWait state. To make sense of what we were seeing, we need to first understand how VTs operate.</p><p>A virtual thread is not mapped 1:1 to a dedicated OS-level thread. Rather, we can think of it as a task that is scheduled to a fork-join thread pool. When a virtual thread enters a blocking call, like waiting for a Future, it relinquishes the OS thread it occupies and simply remains in memory until it is ready to resume. In the meantime, the OS thread can be reassigned to execute other VTs in the same fork-join pool. This allows us to multiplex a lot of VTs to just a handful of underlying OS threads. In JVM terminology, the underlying OS thread is referred to as the “carrier thread” to which a virtual thread can be “mounted” while it executes and “unmounted” while it waits. A great in-depth description of virtual thread is available in <a href="https://openjdk.org/jeps/444">JEP 444</a>.</p><p>In our environment, we utilize a blocking model for Tomcat, which in effect holds a worker thread for the lifespan of a request. By enabling virtual threads, Tomcat switches to virtual execution. Each incoming request creates a new virtual thread that is simply scheduled as a task on a <a href="https://github.com/apache/tomcat/blob/10.1.24/java/org/apache/tomcat/util/threads/VirtualThreadExecutor.java">Virtual Thread Executor</a>. We can see Tomcat creates a VirtualThreadExecutor <a href="https://github.com/apache/tomcat/blob/10.1.24/java/org/apache/tomcat/util/net/AbstractEndpoint.java#L1070-L1071">here</a>.</p><p>Tying this information back to our problem, the symptoms correspond to a state when Tomcat keeps creating a new web worker VT for each incoming request, but there are no available OS threads to mount them onto.</p><h3>Why is Tomcat stuck?</h3><p>What happened to our OS threads and what are they busy with? As <a href="https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html#GUID-04C03FFC-066D-4857-85B9-E5A27A875AF9">described here</a>, a VT will be pinned to the underlying OS thread if it performs a blocking operation while inside a synchronized block or method. This is exactly what is happening here. Here is a relevant snippet from a thread dump obtained from the stuck instance:</p><pre>#119515 &quot;&quot; virtual<br>      java.base/jdk.internal.misc.Unsafe.park(Native Method)<br>      java.base/java.lang.VirtualThread.parkOnCarrierThread(VirtualThread.java:661)<br>      java.base/java.lang.VirtualThread.park(VirtualThread.java:593)<br>      java.base/java.lang.System$2.parkVirtualThread(System.java:2643)<br>      java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)<br>      java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)<br>      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)<br>      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:990)<br>      java.base/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)<br>      java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)<br>      zipkin2.reporter.internal.CountBoundedQueue.offer(CountBoundedQueue.java:54)<br>      zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.report(AsyncReporter.java:230)<br>      zipkin2.reporter.brave.AsyncZipkinSpanHandler.end(AsyncZipkinSpanHandler.java:214)<br>      brave.internal.handler.NoopAwareSpanHandler$CompositeSpanHandler.end(NoopAwareSpanHandler.java:98)<br>      brave.internal.handler.NoopAwareSpanHandler.end(NoopAwareSpanHandler.java:48)<br>      brave.internal.recorder.PendingSpans.finish(PendingSpans.java:116)<br>      brave.RealSpan.finish(RealSpan.java:134)<br>      brave.RealSpan.finish(RealSpan.java:129)<br>      io.micrometer.tracing.brave.bridge.BraveSpan.end(BraveSpan.java:117)<br>      io.micrometer.tracing.annotation.AbstractMethodInvocationProcessor.after(AbstractMethodInvocationProcessor.java:67)<br>      io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor.proceedUnderSynchronousSpan(ImperativeMethodInvocationProcessor.java:98)<br>      io.micrometer.tracing.annotation.ImperativeMethodInvocationProcessor.process(ImperativeMethodInvocationProcessor.java:73)<br>      io.micrometer.tracing.annotation.SpanAspect.newSpanMethod(SpanAspect.java:59)<br>      java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)<br>      java.base/java.lang.reflect.Method.invoke(Method.java:580)<br>      org.springframework.aop.aspectj.AbstractAspectJAdvice.invokeAdviceMethodWithGivenArgs(AbstractAspectJAdvice.java:637)<br>...</pre><p>In this stack trace, we enter the synchronization in brave.RealSpan.finish(<a href="https://github.com/openzipkin/brave/blob/6.0.3/brave/src/main/java/brave/RealSpan.java#L134">RealSpan.java:134</a>). This virtual thread is effectively pinned — it is mounted to an actual OS thread even while it waits to acquire a reentrant lock. There are 3 VTs in this exact state and another VT identified as “&lt;redacted&gt; @DefaultExecutor - 46542” that also follows the same code path. These 4 virtual threads are pinned while waiting to acquire a lock. Because the app is deployed on an instance with 4 vCPUs, <a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/lang/VirtualThread.java#L1102-L1134">the fork-join pool that underpins VT execution</a> also contains 4 OS threads. Now that we have exhausted all of them, no other virtual thread can make any progress. This explains why Tomcat stopped processing the requests and why the number of sockets in closeWait state keeps climbing. Indeed, Tomcat accepts a connection on a socket, creates a request along with a virtual thread, and passes this request/thread to the executor for processing. However, the newly created VT cannot be scheduled because all of the OS threads in the fork-join pool are pinned and never released. So these newly created VTs are stuck in the queue, while still holding the socket.</p><h3>Who has the lock?</h3><p>Now that we know VTs are waiting to acquire a lock, the next question is: Who holds the lock? Answering this question is key to understanding what triggered this condition in the first place. Usually a thread dump indicates who holds the lock with either “- locked &lt;0x…&gt; (at …)” or “Locked ownable synchronizers,” but neither of these show up in our thread dumps. As a matter of fact, no locking/parking/waiting information is included in the jcmd-generated thread dumps. This is a limitation in Java 21 and will be addressed in the future releases. Carefully combing through the thread dump reveals that there are a total of 6 threads contending for the same ReentrantLock and associated Condition. Four of these six threads are detailed in the previous section. Here is another thread:</p><pre>#119516 &quot;&quot; virtual<br>      java.base/java.lang.VirtualThread.park(VirtualThread.java:582)<br>      java.base/java.lang.System$2.parkVirtualThread(System.java:2643)<br>      java.base/jdk.internal.misc.VirtualThreads.park(VirtualThreads.java:54)<br>      java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:219)<br>      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)<br>      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:990)<br>      java.base/java.util.concurrent.locks.ReentrantLock$Sync.lock(ReentrantLock.java:153)<br>      java.base/java.util.concurrent.locks.ReentrantLock.lock(ReentrantLock.java:322)<br>      zipkin2.reporter.internal.CountBoundedQueue.offer(CountBoundedQueue.java:54)<br>      zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.report(AsyncReporter.java:230)<br>      zipkin2.reporter.brave.AsyncZipkinSpanHandler.end(AsyncZipkinSpanHandler.java:214)<br>      brave.internal.handler.NoopAwareSpanHandler$CompositeSpanHandler.end(NoopAwareSpanHandler.java:98)<br>      brave.internal.handler.NoopAwareSpanHandler.end(NoopAwareSpanHandler.java:48)<br>      brave.internal.recorder.PendingSpans.finish(PendingSpans.java:116)<br>      brave.RealScopedSpan.finish(RealScopedSpan.java:64)<br>      ...</pre><p>Note that while this thread seemingly goes through the same code path for finishing a span, it does not go through a synchronized block. Finally here is the 6th thread:</p><pre>#107 &quot;AsyncReporter &lt;redacted&gt;&quot;<br>      java.base/jdk.internal.misc.Unsafe.park(Native Method)<br>      java.base/java.util.concurrent.locks.LockSupport.park(LockSupport.java:221)<br>      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer.acquire(AbstractQueuedSynchronizer.java:754)<br>      java.base/java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:1761)<br>      zipkin2.reporter.internal.CountBoundedQueue.drainTo(CountBoundedQueue.java:81)<br>      zipkin2.reporter.internal.AsyncReporter$BoundedAsyncReporter.flush(AsyncReporter.java:241)<br>      zipkin2.reporter.internal.AsyncReporter$Flusher.run(AsyncReporter.java:352)<br>      java.base/java.lang.Thread.run(Thread.java:1583)</pre><p>This is actually a normal platform thread, not a virtual thread. Paying particular attention to the line numbers in this stack trace, it is peculiar that the thread seems to be blocked within the internal acquire() method <em>after</em> <a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java#L1761">completing the wait</a>. In other words, this calling thread owned the lock upon entering awaitNanos(). We know the lock was explicitly acquired <a href="https://github.com/openzipkin/zipkin-reporter-java/blob/3.4.0/core/src/main/java/zipkin2/reporter/internal/CountBoundedQueue.java#L76">here</a>. However, by the time the wait completed, it could not reacquire the lock. Summarizing our thread dump analysis:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/c09dd46b372e014eae5c9698b8a9e96d/href">https://medium.com/media/c09dd46b372e014eae5c9698b8a9e96d/href</a></iframe><p>There are 5 virtual threads and 1 regular thread waiting for the lock. Out of those 5 VTs, 4 of them are pinned to the OS threads in the fork-join pool. There’s still no information on who owns the lock. As there’s nothing more we can glean from the thread dump, our next logical step is to peek into the heap dump and introspect the state of the lock.</p><h3>Inspecting the lock</h3><p>Finding the lock in the heap dump was relatively straightforward. Using the excellent <a href="https://eclipse.dev/mat/">Eclipse MAT</a> tool, we examined the objects on the stack of the AsyncReporter non-virtual thread to identify the lock object. Reasoning about the current state of the lock was perhaps the trickiest part of our investigation. Most of the relevant code can be found in the <a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java">AbstractQueuedSynchronizer.java</a>. While we don’t claim to fully understand the inner workings of it, we reverse-engineered enough of it to match against what we see in the heap dump. This diagram illustrates our findings:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6AOJeVdbhmStpb9CRj30nw.png" /></figure><p>First off, the exclusiveOwnerThread field is null (2), signifying that no one owns the lock. We have an “empty” ExclusiveNode (3) at the head of the list (waiter is null and status is cleared) followed by another ExclusiveNode with waiter pointing to one of the virtual threads contending for the lock — #119516 (4). The only place we found that clears the exclusiveOwnerThread field is within the ReentrantLock.Sync.tryRelease() method (<a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/ReentrantLock.java#L178">source link</a>). There we also set state = 0 matching the state that we see in the heap dump (1).</p><p>With this in mind, we traced the <a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java#L1058-L1064">code path</a> to release() the lock. After successfully calling tryRelease(), the lock-holding thread attempts to <a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java#L641-L647">signal the next waiter</a> in the list. At this point, the lock-holding thread is still at the head of the list, even though ownership of the lock is <em>effectively released</em>. The <em>next </em>node in the list points to the thread that is <em>about to acquire the lock</em>.</p><p>To understand how this signaling works, let’s look at the <a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java#L670-L765">lock acquire path</a> in the AbstractQueuedSynchronizer.acquire() method. Grossly oversimplifying, it’s an infinite loop, where threads attempt to acquire the lock and then park if the attempt was unsuccessful:</p><pre>while(true) {<br>   if (tryAcquire()) {<br>      return; // lock acquired<br>   }<br>   park();<br>}</pre><p>When the lock-holding thread releases the lock and signals to unpark the next waiter thread, the unparked thread iterates through this loop again, giving it another opportunity to acquire the lock. Indeed, our thread dump indicates that all of our waiter threads are parked on <a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java#L754">line 754</a>. Once unparked, the thread that managed to acquire the lock should end up in <a href="https://github.com/openjdk/jdk21u/blob/jdk-21.0.3-ga/src/java.base/share/classes/java/util/concurrent/locks/AbstractQueuedSynchronizer.java#L716-L723">this code block</a>, effectively resetting the head of the list and clearing the reference to the waiter.</p><p>To restate this more concisely, the lock-owning thread is referenced by the head node of the list. Releasing the lock notifies the next node in the list while acquiring the lock resets the head of the list to the current node. This means that what we see in the heap dump reflects the state when one thread has already released the lock but the next thread has yet to acquire it. It’s a weird in-between state that should be transient, but our JVM is stuck here. We know thread #119516 was notified and is about to acquire the lock because of the ExclusiveNode state we identified at the head of the list. However, thread dumps show that thread #119516 continues to wait, just like other threads contending for the same lock. How can we reconcile what we see between the thread and heap dumps?</p><h3>The lock with no place to run</h3><p>Knowing that thread #119516 was actually notified, we went back to the thread dump to re-examine the state of the threads. Recall that we have 6 total threads waiting for the lock with 4 of the virtual threads each pinned to an OS thread. These 4 will not yield their OS thread until they acquire the lock and proceed out of the synchronized block. #107 “AsyncReporter &lt;redacted&gt;” is a regular platform thread, so nothing should prevent it from proceeding if it acquires the lock. This leaves us with the last thread: #119516. It is a VT, but it is not pinned to an OS thread. Even if it’s notified to be unparked, it cannot proceed because there are no more OS threads left in the fork-join pool to schedule it onto. That’s exactly what happens here — although #119516 is signaled to unpark itself, it cannot leave the parked state because the fork-join pool is occupied by the 4 other VTs waiting to acquire the same lock. None of those pinned VTs can proceed until they acquire the lock. It’s a variation of the <a href="https://en.wikipedia.org/wiki/Deadlock">classic deadlock problem</a>, but instead of 2 locks we have one lock and a semaphore with 4 permits as represented by the fork-join pool.</p><p>Now that we know exactly what happened, it was easy to come up with a <a href="https://gist.github.com/DanielThomas/0b099c5f208d7deed8a83bf5fc03179e">reproducible test case</a>.</p><h3>Conclusion</h3><p>Virtual threads are expected to improve performance by reducing overhead related to thread creation and context switching. Despite some sharp edges as of Java 21, virtual threads largely deliver on their promise. In our quest for more performant Java applications, we see further virtual thread adoption as a key towards unlocking that goal. We look forward to Java 23 and beyond, which brings a wealth of upgrades and hopefully addresses the integration between virtual threads and locking primitives.</p><p>This exploration highlights just one type of issue that performance engineers solve at Netflix. We hope this glimpse into our problem-solving approach proves valuable to others in their future investigations.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=3052540e231d" width="1" height="1" alt=""><hr><p><a href="https://netflixtechblog.com/java-21-virtual-threads-dude-wheres-my-lock-3052540e231d">Java 21 Virtual Threads - Dude, Where’s My Lock?</a> was originally published in <a href="https://netflixtechblog.com">Netflix TechBlog</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>