Five production write-ups on how data layers break or get cheaper: a cache key that stopped identifying its bytes, backups reused as storage, shard-safe sequences, and Spark memory leaks.
Picked from 32 new articles across 34 sources · arrow keys for other days
Today in 30 seconds
30s left
Cloudflare's MySQL pooler returned empty results for 0.001% of queries because two nodes cached differently encoded bytes under one key. story 1 ↓
Netflix now serves 15 PB of cold time-series data from Cassandra's own S3 backups, and cluster disk usage fell from 80% to 7%. story 2 ↓
PlanetScale's Neki router hands out every Postgres sequence value itself, one per row, so nextval() stays unique across shards. story 3 ↓
GitHub's new counts-only star endpoint filters by date first, so react/react's full history takes about 24 requests instead of 2,511. story 4 ↓
Netflix ran parallel experiments on separate Git branches to trace its Spark failures to hidden broadcast joins pinning 3.1 GB per executor. story 5 ↓
1/5Cloudflare· Pranoy Dutta· Oct 14
How a shared cache made Cloudflare's MySQL pooler return empty results
Cloudflare's Hyperdrive sometimes returned empty MySQL results because two of its nodes shared a cache but stored responses in different protocol formats. Storing one standard format fixed it, and connection success reached 99.99%.
Why it matters · The bug shows how a cache key can stop identifying its bytes once a proxy translates between protocol versions, and how a design assumption carried over from Postgres broke on MySQL.
Know this first
Context from the editor, not the article · 37 sec
1 · What a connection pooler does
Opening a SQL connection takes several network round trips, and each open connection costs the database memory. A pooler keeps a set of warm connections to the database and lends them to short-lived requests. Serverless platforms need this because they would otherwise open a fresh connection on every request.
2 · Hyperdrive and Workers
Cloudflare Workers are serverless functions that run in Cloudflare's data centers worldwide. Hyperdrive is Cloudflare's pooler that connects Workers to Postgres and, now, MySQL databases. It can also cache query results in Cloudflare's content delivery network (CDN) cache in each data center.
3 · Protocol capability flags
2/5Netflix Tech Blog· Netflix Technology Blog· Oct 14
Netflix moves 15 PB of cold time-series data onto Cassandra’s own S3 backups
Netflix stopped streaming old time-series data to S3 through Kafka and Flink. Cassandra now serves those reads straight from its existing disaster-recovery backups in S3, which removed a pipeline and cut local disk use by about 90%.
Why it matters · Netflix turned an existing cost, its disaster-recovery backups, into the cold storage tier. It is a concrete pattern for tiering immutable data in an LSM database without running a second copy pipeline.
Know this first
Context from the editor, not the article · 32 sec
1 · How Cassandra stores data
Writes land in an in-memory Memtable, which is flushed to immutable files on disk called SSTables. Background compaction merges SSTables. A ring of tokens assigns each partition key to nodes. A read checks the memtable and every SSTable that might hold the key.
2 · Netflix's TimeSeries Abstraction
This is Netflix's internal service for storing and querying event data ordered by time, built on Cassandra. It splits each dataset into tables called Time Slices, each covering a fixed time window. Once a window is past its write boundary, its table never changes again.
3 · Hot versus cold tiers
3/5PlanetScale· Mats Rydberg· Oct 14
How PlanetScale's sharded Postgres keeps sequence values unique across shards
PlanetScale's Neki, a sharded Postgres, never lets shards call nextval(). The central router evaluates it once per result row on the stream coming back, and breaks UPDATEs into a lock-and-fetch step followed by one update per row.
Why it matters · Any sharding layer over a SQL database has to keep functions like now() and nextval() meaning what they mean on a single node. This post shows where that gets expensive.
Know this first
Context from the editor, not the article · 35 sec
1 · Router-and-shards architecture
Neki splits Postgres tables across many shards, each a complete Postgres server holding a slice of the rows. A router takes client queries, decides which work to push down to the shards, and does the rest itself, such as merging or joining rows that come back from different shards.
2 · Postgres sequences
A sequence is a named counter. Calling nextval('seq') returns a value it has never handed out before. Values can have gaps, because a rolled-back transaction doesn't return its numbers. Sequences back serial and GENERATED AS IDENTITY columns and DEFAULT nextval(...) defaults.
3 · Volatility classes
4/5GitHub Engineering· Taylor Cuilty· Oct 14
GitHub rebuilds star history as a counts-only endpoint, filtering by date first
GitHub replaced a slow, scrapable list of stargazers with an endpoint that returns 30 weeks of daily star counts and no usernames. It filters stars by date before counting them, so each request does less database work.
Why it matters · It's a clear production example of how the order of operations in a query, not the size of the response, sets the cost. The lesson applies to any API built on aggregation.
Know this first
Context from the editor, not the article · 32 sec
1 · What star history is
A GitHub repository's star count over time, plotted as a curve. Maintainers use it to see whether a release or announcement drew new interest. Third-party sites such as star-history.com built these curves from GitHub's public API.
2 · Offset pagination
APIs often split large results into pages using SQL LIMIT (how many rows to return) and OFFSET (how many to skip). The database usually has to produce and order the rows it skips, so later pages can cost as much as reading everything up to that point.
3 · Why indexes matter here
5/5Netflix Tech Blog· Netflix Technology Blog· Oct 14
How Netflix traced Spark memory failures in its session pipeline to bloated rows
A Netflix engineer chased out-of-memory crashes in a pipeline that groups tens of billions of events into viewing sessions. Running full-scale experiments in parallel showed that bloated per-event rows, not data volume, caused them.
Why it matters · It shows that when bugs only appear at full scale, running many experiments at once beats speeding up one, and it names Spark memory traps that total-size metrics hide.
Know this first
Context from the editor, not the article · 35 sec
1 · Spark executors and shuffles
Apache Spark splits a job across many executor processes, each with a fixed memory slot. Operations that group data by key trigger a shuffle, which moves rows between executors. When a group doesn't fit in memory, Spark spills it to disk or the executor dies with an out-of-memory (OOM) error.
2 · Sessionization
Sessionization groups a user's raw events, such as taps and playback signals, into sessions. In Spark this is often done with collect_list, which gathers all of a key's rows into one array (the "event bag"). The whole array has to sit in memory at once.
When a MySQL client connects, it and the server exchange bit flags listing the optional features each supports. Both sides then use only the features they share. A proxy in the middle can negotiate different flags on each side, so it has to translate messages between the two formats.
When the Client and Endpoint run in the same data center, they share one cache, which made the bug possible.1 / 4
Cloudflare has made MySQL support in 1 generally available. Hyperdrive is its database connection pooler for 2, the company's serverless functions. Pranoy Dutta's post describes one bug from the beta: about 0.001% of cached queries came back empty when they should have returned rows.
Background
Opening a SQL connection takes at least five network round trips, which serverless code can't afford on every request. Hyperdrive keeps warm connections open on Cloudflare's network, shares them across Worker requests, and can cache query results in Cloudflare's content delivery network (CDN) cache.
The beta also turned up simpler bugs. Hyperdrive asked for MySQL's utf8 encoding, a deprecated 3-byte alias that can't store emojis, so it switched to utf8mb4. It also found that COM_RESET_CONNECTION, the command that clears session state between borrowers, resets the encoding to the server's default. Hyperdrive now sets utf8mb4 again after every reset.
Two nodes, one cache
Each Hyperdrive instance has a Client node next to the Worker, which handles caching, and an Endpoint node near the database, which holds the connection pool. Each uses its local data center's cache, so when both run in the same data center they share one. The cache key is a normalized hash of the query. This is only safe if both nodes store identical bytes for the same result.
The format mismatch
When a MySQL connection opens, the two sides agree on capability flags, which are optional protocol features. Hyperdrive always uses a fixed set toward the database and translates responses into whatever the Worker's driver supports. One flag, CLIENT_DEPRECATE_EOF, changes how a result set ends. Without it, EOF packets mark the end of the column definitions and the end of the rows. With it, the first EOF is dropped and the last one becomes an OK packet. Hyperdrive uses the flag. The popular Node.js driver mysql2 doesn't, so Hyperdrive rewrites every result for it.
The Client's caching code came from the Postgres version, and it cached exactly the bytes it sent to the Worker. In MySQL, those bytes were in the Worker's format, while the Endpoint cached Hyperdrive's format under the same key. That led to two failures:
The Endpoint caches a response; a Client reads it and forwards it untranslated.
A Client caches its translated bytes; the Endpoint serves them to another Client, which translates them a second time. The result is an early EOF (an empty result) or a hang.
Fix and result
Every node now caches one canonical form in Hyperdrive's own encoding and translates only when sending to the Worker. The empty results stopped, and connection success reached the 99.99% target. The post doesn't say whether other capability flags carry similar risk.
Cloudflare has made MySQL support in 1 generally available. Hyperdrive is its database connection pooler for 2, the company's serverless functions. It launched with Postgres only, and MySQL went into beta last year. Pranoy Dutta writes that going from beta to 3 came down to "20 bug fixes in a trenchcoat." Most of the post covers one of them: a cache bug that returned empty results for about 0.001% of queries. The cause was a design assumption that holds for Postgres's wire protocol but not for MySQL's.
Why Hyperdrive exists
Opening a SQL connection takes at least five network round trips. Each open connection also costs the database server resources. MySQL spends less per connection than Postgres because it doesn't use a whole OS process for each one, but large connection counts still cause trouble. Opening a connection per request on a serverless platform would add latency and pile up connections. Hyperdrive instead keeps warm connections open on Cloudflare's network and shares them across many Worker requests. It can also cache common query results in Cloudflare's content delivery network (CDN) cache.
Two smaller bugs first
The post describes two encoding bugs fixed during the beta.
Missing emojis. Hyperdrive asked for the utf8 character set during the connection handshake. In MySQL, utf8 is a deprecated alias for utf8mb3, which allows at most 3 bytes per character. Real UTF-8 allows up to 4, and emojis need all 4. Switching the handshake to utf8mb4 fixed it.
Encoding lost after a reset. Between checkouts, Hyperdrive sends COM_RESET_CONNECTION, a lightweight MySQL command that clears session state so nothing leaks from one borrower to the next. The command resets the character set to the server's default, not to the one negotiated at startup. On a server that defaults to latin1, a connection that started as utf8mb4 became latin1 after the first reset. Hyperdrive now sets utf8mb4 again after every reset.
The rare one
The beta customer with the most MySQL traffic saw a small, steady rate of empty responses to queries that should always return rows. At about 0.001% of requests, even a higher trace sampling rate rarely captured an end-to-end example. The team found no network explanation. The only solid clue was that the bug appeared only on Hyperdrive instances with caching turned on.
Topology: two nodes, sometimes one cache
Each Hyperdrive instance runs as two nodes:
the Client, on the same machine as the user's Worker. It handles caching and forwards cache misses onward.
the Endpoint, in the data center closest to the origin database. It holds the connection pool.
Each node uses the CDN cache of the data center it runs in. When both are in the same data center, they share a cache. That normally helps: the cache key is a normalized hash of the query's wire messages, and the value is the result bytes. A Client can then serve a result that its Endpoint cached a moment earlier. This only works if both nodes store the same bytes for the same response.
Where the bytes diverge
A MySQL connection starts with a Connection Phase, where the two sides agree on authentication and 4: bit flags for optional protocol features. Then comes the Command Phase, where queries and results go back and forth. If one side uses a feature the other didn't agree to, the protocol falls out of sync.
Hyperdrive is a server to the Worker and a client to the origin database. Toward the origin it always uses a fixed set of capabilities so it can parse the replies. The Worker's driver may support a different set, so the Hyperdrive Client translates each response into the format that driver expects.
The capability that mattered was CLIENT_DEPRECATE_EOF. Without it, a result set has an EOF packet after the column definitions and another EOF at the end of the rows. With it, the first EOF is dropped and the last one becomes an OK packet. MySQL 5.7.5 and later supports this, but only uses it if the client asks. Hyperdrive asks for it. The popular Node.js driver mysql2 doesn't. So for mysql2 users, the Client rewrites every result: it adds the metadata EOF and turns the final OK into an EOF.
The flaw came from the Postgres implementation. The Client cached exactly the bytes it wrote back to the Worker. Postgres doesn't need this kind of translation, so that was safe there. In MySQL, the Client's bytes are in the Worker's format and the Endpoint's bytes are in Hyperdrive's format, yet both sit under the same cache key.
Two ways to fail
Endpoint writes, Client reads. The Endpoint gets response R for query Q from the origin and caches it as R_hyperdrive, in Hyperdrive's format. A Client in the same data center gets Q, finds R_hyperdrive in the cache, and sends it to the Worker without translating it.
Client writes, Endpoint reads. A Client caches its translated R_worker. The Endpoint, handling Q for a different Client, finds R_worker and forwards it. That Client translates the already-translated bytes, then either sends an EOF too early, which the driver reads as an empty result, or hangs.
To make this concrete (an illustrative reading, not a trace from the article): suppose a mysql2 Worker receives R_hyperdrive. Its driver expects an EOF after the column definitions and doesn't see one. That throws off where the driver thinks the rows start and end. Both failures need the two nodes to be in the same data center, caching on, and one node to read the other's entry. That explains why the rate was so low.
The fix and the result
Both nodes now write one canonical form to the cache: result bytes in Hyperdrive's own capability encoding, no matter which node stores them. Translation to the Worker's format happens only on the way out. After the release, the empty results stopped and connection success from Workers reached the team's 99.99% target.
What's left open
The post doesn't cover how the team checked the fix beyond the success-rate number. It doesn't say whether other capability flags, such as compression, could cause similar mismatches. It also doesn't list the other ~17 fixes. The general lesson is clear, though: a cache key has to identify both the logical result and the exact encoding of the stored bytes. If any writer can produce a different encoding, store one canonical form or include the format in the key.
Remember
A cache key must identify the exact encoding of the stored bytes, not just the logical result.
Translating proxies need one canonical internal format; translate only at the edge, never before caching.
Assumptions that hold for one database's wire protocol can quietly break on another's.
Tiered storage keeps recent, frequently read data on fast, expensive local disks (hot). Older, rarely read data goes to cheap object storage such as Amazon S3 (cold). Callers keep using one query interface.
Time Slices: each table covers a time window and becomes immutable once past it.1 / 4
Netflix has dropped the Kafka/Flink pipeline that copied old time-series data into S3. Cassandra now reads that data straight from its existing disaster-recovery backups. The new cold tier holds more than 15 PB of compressed data, and disk usage on the affected clusters fell from 80% to 7%.
The setup
The TimeSeries Abstraction is Netflix's Cassandra-based service for event data ordered by time, running to several petabytes. Each dataset is split into 1, tables that each cover a window of time and become immutable once that window closes. Most queries hit recent slices, but old ones must stay readable, sometimes in under a second.
First attempt: stream a copy
Events were written to Cassandra and also streamed through Kafka (a message log) and Flink (a stream processor) into hourly 2 files on S3. Parquet is a columnar file format. A daily job, taking about 12 hours on the largest datasets, merged these into one file per day. The files used small 4–8 MB row groups, events sorted by time, and 3 cached so the planner could skip files. Reads cost about 150 ms at p50 and 500 ms at p99. But the pipeline was expensive and added on-call burden, and it paid to move data into S3 that backups were already storing there.
Second attempt: read the backups
Results and gaps
Cold reads run at thousands per second per region. p90 latency is about 30% better than the Flink design, and p99 is under about 500 ms. The post gives no dollar figures. It doesn't say how backup retention policies changed now that backups serve live reads, or whether the custom code will be released.
Netflix has replaced its first cold-storage design for time-series data, a Kafka and Flink pipeline that copied events into S3. In the new design, Cassandra reads old data directly from the disaster-recovery backups it already keeps in S3. The new tier holds more than 15 PB of compressed data. Disk usage on the affected clusters fell from 80% to 7%. p90 cold-read latency is about 30% better than the old pipeline, and p99 stays under roughly 500 ms.
The problem
The TimeSeries Abstraction is Netflix's service for storing and querying event data ordered by time. It runs on Cassandra and backs datasets of several petabytes, on storage tuned for millisecond reads. Users keep asking for longer retention, and most of that history is rarely read. It still has to be available for audits and investigations, sometimes with sub-second latency. Keeping all of it on dense hot nodes is expensive, and the density makes scaling, deployments and cluster stability harder. The goal was to move old data to object storage without changing the API.
One design choice makes this tractable. TimeSeries splits each dataset into 1, separate tables that each cover a window of time. Once a slice passes its write boundary it is immutable. A control plane creates slices and keeps their metadata in a dedicated table. The data plane uses that metadata to route reads and writes to the right tables.
Attempt one: stream a second copy to S3
The first design wrote each event to Cassandra and, at the same time, streamed it through Apache Kafka (a durable message log) to Apache Flink (a stream processor). Time-series IDs were hash-partitioned into buckets, and dedicated Flink tasks handled each bucket. The tasks batched events into 2 files (Parquet is a columnar file format split into "row groups"), grouped by event time within the hour. Those files suit streaming writes but are slow to read.
A daily compaction job fixed that. It merged the small files into one daily Parquet file per namespace and applied three read optimisations:
Small row groups of 4–8 MB. This is deliberately well below Parquet's recommended minimum, because the workload is point and range lookups rather than scans. Individual row groups are fetched with S3 range reads.
Events sorted by time within each row group, so a time-range query doesn't need a brute-force scan.
3 and file metadata cached in 4, Netflix's distributed cache, so the query planner can skip files that can't contain the key.
On the largest datasets the job takes about 12 hours, a choice that favours low compute cost over speed. When it finishes, the files are registered with the control plane and the hot slice is truncated.
On the read side, the data plane sends each query to both tiers and merges the results. Because metadata prunes so much, reading a full day for one ID takes at most three sequential S3 reads. Latency per read was about 150 ms at p50 and about 500 ms at p99, at up to 1,250 cold reads per second globally.
It worked, and it shrank both the local disks (EBS volumes) and the backup footprint. But Kafka, Flink and the compaction job cost money, add moving parts to on-call, and pay to transfer data into S3 that the backups were already putting there. So the team asked whether Cassandra could read its backups directly.
Attempt two: serve reads from the backups
Making the files read-friendly. Standard Cassandra compaction strategies leave 5 (Cassandra's immutable on-disk files) whose token ranges overlap. That applies to 6 (leveled) and 7 (time-window). A single partition key can then sit in several files, and a point read has to check each one. Netflix wrote a new strategy, . Once a slice becomes immutable, ROCS re-compacts it a single time so each SSTable covers its own non-overlapping token range. A key lookup then touches one file. Netflix says this lowered tail latency on every tier, hot included. The post shows a chart of the improvement but gives no figures in the text.
Using the backup layout. Backups are stored per node, keyed by the node's ring token. Each snapshot has a JSON manifest listing every SSTable component: the large Data.db file, plus Index, Filter (Bloom filter), Summary, Statistics and CompressionInfo. Every component except Data.db is small. So Cassandra downloads those to local disk and does query planning locally, then fetches only the needed data chunks from S3.
Hooking into the read path. Cassandra 4.1 added 9, which let a table swap in its own memtable implementation. Netflix wrote a backupmemtable, set on a table as memtable = 'backupmemtable:timestamp=...'. The timestamp is the point by which the backup is guaranteed to cover the whole table and the table is guaranteed immutable. As a rough analogy, it works like mounting a remote read-only disk while keeping its index on local storage.
When the memtable opens, a state machine pulls the metadata files from S3 once. Several safeguards protect this step:
S3 calls retry with exponential backoff.
The download runs on its own thread pool, away from normal Cassandra work.
A file whose size doesn't match the manifest is deleted and downloaded again.
A .timestamp file ties the download to one backup. If the table's backup timestamp changes, the download restarts.
Reads that arrive during setup wait at most half the read RPC timeout.
Reads run S3 fetches in parallel across requests, SSTables and chunks. A shared cache removes duplicate fetches and prefetches chunks. Three settings cut down S3 calls:
Larger compression chunks, accepting some wasted bytes on small reads.
Read repair off, since the backup data is immutable and already repaired.
10 off, so S3's slower responses don't trigger duplicate reads.
Knowing when it's safe. Before a table switches to its backup, the control plane checks two things across the whole ring using Cassandra 11 (read-only system views you can query with CQL). First, no node is still running a ROCS compaction. Second, the backup is complete on every node (num_uploaded equals num_tokens). The control plane automates the whole lifecycle. Slices can be at any stage without affecting availability, and late backups or lagging compactions only delay the transition.
Isolating load. Reading from S3 costs much more CPU than reading local disk, and a burst of cold reads could hurt hot traffic. So cold reads can optionally run on a separate Cassandra cluster whose memtables point at the hot cluster's backups. That cluster must match the original's topology but can run on smaller, cheaper, storage-light instances.
Results
The rollout covered the most storage-heavy clusters:
More than 15 PB of compressed data now sits in the cold tier.
Disk usage fell from 80% to 7%.
The tier serves thousands of cold reads per second in each region.
p90 latency is about 30% better than the Flink design, which Netflix credits to more efficient S3 access and smaller reads.
p99 stays under roughly 500 ms.
Cassandra's chunk cache absorbs bursts.
What's left open
The post gives no dollar figures for either design. Backups now carry live reads, so backup retention, deletion and corruption become availability concerns, and the post doesn't say how those policies changed. A slice can only go cold after its backup is verified, which ties tiering to the backup schedule. The design relies on Cassandra 4.1 internals and a custom memtable and compaction strategy, and the post doesn't say whether either will be released.
Remember
Immutability enabled every step: closed slices allow one-time compaction, backup reuse and dropping read repair.
Keeping SSTable index and filter files local means S3 is touched only for the actual data chunks.
A cheaper, storage-light cluster can serve cold reads off the hot cluster's backups, isolating CPU load.
Postgres labels every function immutable (same inputs give the same output forever), stable (same output for the whole statement, like now()), or volatile (may differ on every call, like nextval() or random()). The planner uses these labels to decide what it may cache, reorder or skip.
Query 3: now() is highlighted as router-only, while the column expressions can run on the shards.1 / 4
Mats Rydberg of PlanetScale explains how 1, the company's sharded PostgreSQL, makes sequences behave as they do on a single Postgres server.
Router and shards
In Neki, each shard is a full Postgres server holding a slice of the data. A router sits in front of them. It takes client queries, pushes whatever it safely can down to the shards, and does the rest itself. Rydberg says the best case is sending a whole query to a single shard. That is rarely possible, because no shard has all the rows.
Some expressions are 2 and must never run on a shard. now(), the transaction's start time, is one. If each shard evaluated it, clock drift would give rows from the same query different timestamps. So the router evaluates it once and copies that value to every row.
Volatility classes
Postgres labels every expression immutable, stable or volatile. Immutable ones like lower(name) can run anywhere. Stable ones are fixed for the duration of a statement but can depend on session settings. to_char() returns "Wednesday" or "Onsdag" depending on the lc_time setting, so Neki has to forward any SET lc_time to the shards before pushing it down. Volatile ones can return a new value on every call.
Sequences go through one authority
nextval() is volatile and has to return values that are unique across the whole cluster. If shards generated their own values they would collide, so all sequence operations are router-only. A single 3 hosts every sequence, and the router fetches values from it.
The hard part is 4, meaning the number of rows. nextval() must run exactly once per row of the 5, the intermediate result of FROM … JOIN … WHERE. In the post's example, joining two small tables gives three rows. A WHERE filter cuts that to one, and only one sequence value is used. An optimiser may reuse duplicated values like lower(name) across rows that came from the same source row. It must not reuse nextval().
When both tables are sharded on the join key, shards can do the join locally. The router can't predict how many rows will come back, so it can't fetch sequence values in bulk. It calls nextval() on each row as the results stream past.
UPDATE is harder
For UPDATE animals SET tag_id = nextval('tag_seq'), Neki first selects just the primary-key column (id, also the shard key) from every shard with FOR UPDATE to lock the rows. Then it calls nextval() for each returned row and sends a separate parameterised update to that row's shard. The post doesn't measure what this per-row pattern costs on large tables.
PlanetScale engineer Mats Rydberg has written up how 1, the company's sharded PostgreSQL, implements Postgres sequences so they behave exactly as they do on one server. Two things make this hard. Sequence functions are volatile, meaning their result can change on every call. And handing out values that are unique across many shards needs a single point of coordination.
The planner's default: push work down
In Neki every shard is a complete Postgres with its own planner. Rydberg says the ideal case is relaying the whole query to one shard, but that is usually impossible because each shard holds only its slice of the data. In the most general case a shard only has to return raw rows, and the router, the component that receives client queries, must be able to do everything else.
Take SELECT age + 1, lower(name) FROM animals. The expressions could be evaluated on the shards or on the router, and both give correct results. Choosing between them is an optimisation problem with several inputs: which side has spare resources, which evaluates faster, whether the computed value or the raw column is cheaper to send over the network, and whether a nested expression should be split between the two. The post assumes a simple heuristic for clarity: push evaluation to the shard whenever that is allowed.
Router-only expressions
Some expressions are never allowed on the shards. Neki calls these 2. now() is one. It returns the wall-clock time at the start of the transaction. If every shard evaluated it, clock drift and different request arrival times would produce different timestamps within a single query. So the router runs the rest of the query on the shards, then adds now() to each returned row. It must give every row the same value, either by evaluating once into a one-row table and joining that in, or by caching the value per statement.
That gives Neki's general planning model: find the parts of the query that can't run on shards, plan them to run in the router, and push everything else down.
Stable isn't the same as safe to push down
Sequences: one authority, every call central
nextval() is volatile. In Neki the sequence tables exist on every shard but start out empty. Any query can reference any sequence, so letting shards generate values would produce duplicates across shards. All sequence operations are therefore router-only. One designated 4 physically hosts every sequence, and the router asks it for new values and hands them out. That is how Neki keeps allocation synchronised across shards.
Cardinality: how many times to call it
A volatile expression has to be evaluated exactly once per row of the 5. That is the intermediate table built by FROM … JOIN … WHERE (plus GROUP BY, HAVING and so on), over which the SELECT list is computed.
A worked example from the post: animals has rows (0, BISMUTH) and (1, DOGE). feedings has (0, morning), (1, midday), (1, evening) and (2, morning). The inner join on id gives three rows, because DOGE appears twice. Adding WHERE feedings.time = 'morning' leaves one row, so nextval('tag_seq') runs once and uses one value. Without the WHERE clause it runs three times. An optimiser might notice that age + 1 and lower(name) are the same on both DOGE rows and compute them once. It must not do that with nextval().
Join order adds a further subtlety. Filtering before or after a join is legal and is a standard optimisation, but Neki and Postgres may choose different orders. If a volatile expression sits inside a WHERE or ON predicate, Rydberg notes that results are "not always deterministic, but they are correct".
Distributed joins
Network round trips between router and shard dominate Neki's costs. A general cross-shard join pulls both tables to the router and joins them there. When the join predicate uses each table's shard key (for example, both tables sharded on id and joined on id), matching rows always live on the same shard. Each shard can then do the join locally and return its share of the table expression.
Take SELECT age + 1, lower(name), nextval('tag_seq'), now() FROM animals JOIN feedings ON … with both tables sharded on id. The join and the two immutable columns run on the shards. The router evaluates now() once and copies it to every row. The router can't know how many rows each shard will return, so it can't fetch a block of sequence values ahead of time. It has to call nextval() on each row as the merged result streams through it.
UPDATE with nextval()
UPDATE animals SET tag_id = nextval('tag_seq') is harder. The row count is unknown, the rows are spread across shards, and the shards can't evaluate nextval() themselves. Rydberg says there is more than one possible solution. Neki currently does this:
Send each shard a SELECT for only the columns it needs, here the primary key, which is also the shard key (id), with FOR UPDATE so the rows stay locked.
As each row arrives at the router, evaluate nextval().
Send a parameterised update for that row, routed to its shard by id and filtered to the single row by primary key.
This makes one update statement per row, plus an initial locking read. The post gives no performance numbers and doesn't say how this costs out on large tables. That trade-off is the obvious open question. The post also leaves the stable-expression version (SET col = now()) as an exercise, calling it simpler because it doesn't depend on row count.
Remember
Stable functions can still differ across shards through session settings like lc_time, so settings must be forwarded before pushdown.
Optimisers may reuse values on join-duplicated rows for immutable expressions, but never for volatile ones like nextval().
Neki turns an UPDATE using nextval() into a locking SELECT plus one routed update per row.
An index on a timestamp column lets a database jump straight to rows in a date range without reading the whole table. A filter the index can serve shrinks the work. A filter applied after grouping does not.
Image: GitHub Engineering
GitHub has added an API endpoint that returns up to 30 weeks of daily star counts for a repository in a single request, with no usernames. Building the full star history for react/react (about 251,000 stars) now takes about 24 requests instead of about 2,511.
The old workaround
Star history tools such as 1 built their growth curves from GET /repos/{owner}/{repo}/stargazers. That endpoint lists the users who starred a repo, 100 per page, with optional timestamps. Tools kept the dates and ignored the names. This caused two problems:
Scraping. Spammers and phishers harvested the public stargazer lists.
Database load. A pagination bug made every page request count all of the repo's stars. In one week, nearly 37,000 of these count queries were logged as slow, averaging about 3 seconds, and more than 1,000 were killed by database safeguards.
GitHub first limited the lists to repo admins and collaborators. That cut stargazer requests by about 60% and time spent on the count query by about 80%. It also broke the star history tools. Talking with their maintainers showed they only needed counts by date.
Why fewer results wasn't enough
The obvious fix was to group stars into daily buckets in SQL and page through the buckets with LIMIT and OFFSET. But those clauses run last. The database still reads and groups every star on every request, then discards most of the buckets. For react/react, 24 such pages would examine about 6 million rows.
What shipped
The new endpoint pages by calendar week. It turns the page number into a date range on the indexed created_at column, fetches only the stars in that range, and then counts them by day. Each star is read about once across the whole history, roughly 251,000 rows for react/react. The response has the same shape as GitHub's weekly commit-activity endpoint.
Along the way, GitHub found the stargazers web page also counting stars live. Switching it to a cached count took its slow queries from hundreds per week to zero.
Limits
Each request covers a fixed span of time, not a fixed number of rows, so a week with a huge spike of stars can still be expensive. The star table stores only current stars, and un-starring deletes the record, so the curve reflects today's stargazers rather than true past counts. The article also lists calendar boundaries as a caveat but doesn't explain how they're handled.
The main lesson, in author Taylor Cuilty's words: "a smaller response does not always mean a cheaper query."
GitHub has shipped an API endpoint that returns up to 30 weeks of daily star counts for a repository in one request, with no user identities attached. It replaces a workaround that third-party tools had used for years. Those tools paged through the full list of people who starred a repo, kept the timestamps and threw away the names. Taylor Cuilty's write-up for GitHub Engineering explains why the obvious redesign, returning counts instead of people, would not have made the queries cheaper, and what did.
Two problems with one endpoint
The old endpoint, GET /repos/{owner}/{repo}/stargazers, answered "who starred this repository?" It returned up to 100 users per page. If the caller asked for a custom media type, each entry also carried the time of the star. Tools built growth curves from those timestamps. That design caused trouble in two places.
Privacy. Because the usernames were public and could be pulled in bulk, bad actors scraped stargazer lists to target spam and phishing campaigns.
Database load. The cost of building a curve grew with the repo's popularity. react/react has about 251,000 stars, so its full curve took about 2,511 requests, and tools often hit rate limits or only sampled some pages. A pagination bug made each request worse. To work out how many pages existed, every request counted all of the repo's stars, even though it returned only 100. In the week before GitHub restricted access, nearly 37,000 of these count queries ran slowly enough to be logged. They averaged almost 3 seconds each, and database safeguards killed more than 1,000 of them, which contributed to request timeouts.
The data model added a quirk. The star table stores the current set of stargazers. It is not an event log: when someone un-stars a repo, the record is deleted. Any curve rebuilt from it shows when today's stargazers starred the repo, not the true historical count.
Step one: restrict, then ask
As an immediate fix, GitHub limited stargazer and watcher lists to repository admins and collaborators. Other callers now get an empty response or a 1. After that, the database served about 60% fewer stargazer requests, and total time spent on the count query fell by about 80%.
The restriction also broke tools like 2. GitHub talked with maintainers and found that what they wanted was the curve, used as a rough signal of interest before and after a release or launch. The usernames had only looked like part of the contract because every timestamp came attached to one.
Approaches that didn't fix the cost
Timestamps without identities. The first prototype kept one record per star and dropped the username for callers without access. This was a small change and kept existing tools working. But clients would still page through hundreds of thousands of records to build a curve. It also left trend data inside a pagination model designed for listing people.
Aggregate, then paginate. The next idea was to group stars into daily or weekly buckets in SQL and page through the buckets. The trouble is the order in which a simple query does its work:
all matching stars -> bucket every row -> sort buckets -> OFFSET/LIMIT -> response
LIMIT and OFFSET run last. Each page request still reads and groups every star in the repo, then throws most of the buckets away. The JSON response gets smaller, but the database does the same amount of work. For react/react, reading the whole history in 24 pages would mean examining about 6 million rows: 24 requests × ~251,000 stars.
What shipped: filter by time first
The new endpoint paginates by calendar week. It turns page and per_page into a range on the indexed created_at column, fetches only the stars in that range, and then groups them into daily counts:
indexed date range -> group rows in that range -> daily buckets -> response
Each response covers up to 30 weeks, and higher page numbers go further back in time. A worked example using the article's numbers for react/react:
Old path: ~2,511 requests of 100 users each, each one also counting all ~251,000 stars.
Aggregate-then-paginate: 24 requests, ~6 million rows examined in total.
Shipped design: ~24 requests, ~251,000 rows examined in total, because each star falls into exactly one week range.
The number of requests now depends on how old the repo is, not on how many stars it has. The response has the same shape as the existing weekly commit-activity endpoint (/stats/commit_activity). GitHub picked weeks because maintainers usually look at growth over weeks or around a known event.
Side effects
Comparing the old and new paths turned up the same count-on-demand pattern elsewhere. The stargazers page on GitHub.com was counting stars live. GitHub switched it to the repo's 3, and that page's slow queries dropped from hundreds per week to zero. A similar fix has since gone out to other stargazer API endpoints.
Trade-offs that remain
Filtering by date limits each request to a fixed span of time, but not to a fixed number of rows. A week with an unusually large number of stars, such as a launch that goes viral, can still produce a slow, expensive query. The article calls these dense weeks a remaining scaling limit and doesn't say whether there is a cap or fallback.
Because the star table drops un-starred records, the curve still reflects only current stargazers. The article lists removed stars and calendar boundaries as parts of the API contract users should know about. It doesn't explain how partial weeks at either end are handled, or the exact name of the new endpoint and its parameters beyond page and per_page.
The argument
Cuilty draws four lessons. A smaller response doesn't mean a cheaper query, so look at the order the work happens in. Start from what users are trying to do; here that was a trend, not a list of people. Return only what that use needs, because anything extra can be misused. And state the remaining limits openly, as part of the contract. The engineering result comes from the first lesson: moving the filter ahead of the grouping step so the index does the narrowing.
Remember
LIMIT/OFFSET shrink the response but not the work: grouping and sorting run before them.
Push an index-backed filter ahead of aggregation so each request reads only the rows it returns.
Time-range pages bound duration, not row count. Viral weeks can still produce expensive queries.
Skew means a few keys hold far more data than the rest. A random 1% sample keeps the average shape of the data but usually misses the rare giant keys, and those giant keys are the ones that crash executors.
Chart: running experiment variants in parallel cuts total time from the sum of runs to the slowest run.1 / 4
A Netflix engineer, Prashanth Gedde Narayanaswamy, spent two months stabilizing member-sessionizer. The pipeline groups tens of billions of hourly client events into viewing sessions per profile. After it moved to a new logging platform, it started failing with executor out-of-memory (1) errors, heavy spill to disk and thousands of stage retries.
Only full-scale runs reproduced it
None of the failures showed up on sampled data. The cause was skew: a tab left streaming overnight can produce tens of thousands of events, while a normal session has a handful, and a 1% sample misses those outliers. Every test needed a full hour of production data and took hours to run.
So he stopped trying to make one run faster and ran many at once. 2, Netflix's pipeline deployment tool, tags each deployed workflow, its output tables and its run namespace with the Git branch name. One branch therefore became one isolated experiment. Five variants could run in parallel, so total time fell to the length of the slowest run. Shared-cluster contention sometimes limited him to two. Jobs ran inside 3 sessions (terminal sessions that survive disconnects) on a cloud dev box. That let him get 4–5 experiment cycles done during a 24-hour flight. A Markdown log recorded each run, its single change from the previous run, and its metrics, which forced him to change one variable at a time.
What the experiments found
Spark's collect_list keeps a session's whole event bag in memory, so the size of each event mattered more than total data volume. Events had grown heavier. Scala Option wrappers forced allocation of JVM objects, and unused strings such as referrer URLs added hundreds of bytes per event. Removing Option from never-null fields cut peak executor memory from 112% of the slot to 58%, with no spill. Unused fields were dropped, and per-session values were stored once per session instead of once per event.
Two results surprised him. Slimmer rows pushed a table under the broadcast threshold, and 4 pin tables in executor memory. Adaptive Query Execution, which re-plans joins at runtime, turned nine joins into broadcasts. Each was under 400 MB, but together they held about 3.1 GB in one executor. Lowering only the runtime threshold to 10 MB fixed it. A session cap built on a Window function also failed at peak, because the Window operator buffers the whole session first. The cap had to move ahead of it.
The limit
The author says plainly that putting a whole session into one row has a ceiling. As upstream events gain fields, the memory pressure will come back. Tuning bought time, and eventually the data model will have to change.
Netflix engineer Prashanth Gedde Narayanaswamy expected a two-week tuning job on member-sessionizer. It took two months. The pipeline is one of Netflix's largest: every hour it groups tens of billions of client events (taps, playbacks, engagement signals) into viewing sessions per profile. His write-up covers two things. One is how he changed his workflow so he could run many full-scale experiments at once. The other is what those experiments found, which was several Spark memory traps, the cause of each, and the fix.
Why small samples didn't work
The trouble began when the pipeline moved from a legacy logging system to a newer platform. The new job hit executor 1 (out-of-memory) errors, heavy 2 to disk and thousands of stage retries. None of this showed up on sampled data.
The cause was skew. A Netflix tab left idle and streaming overnight produces tens of thousands of telemetry events, while a normal session has a handful. A 1% sample almost never contains those extreme sessions, and they are what kill executors. So every test had to run on a full, unsampled hour of production data. Each run took hours, which allowed only three or four attempts a day.
Running experiments in parallel
The turning point, the author says, was to stop trying to make one run faster and instead run many without them interfering. The tool for this was 3, Netflix's command-line tool for building, testing and deploying data pipelines. Workflows run on 4, Netflix's workflow orchestrator, and they are templated with a sanitized branch name. If he pushes a branch such as pgn/cap-experiment, the deployed workflow, its output tables and its run namespace are all tagged with that name. Concurrent runs, whether his own or a teammate's, therefore can't collide.
That makes each branch one experiment. He made the scheduler's settings into parameters (session cap size, number of shuffle partitions, a threshold for flagging heavy devices, feature flags) and gave each variant its own branch and output table. Five variants then ran at the same time over the same hour of data. Wall-clock time dropped from the sum of five runs (about 10 hours) to the length of the slowest one.
Per-branch tables cost nothing extra to manage. Dataflow creates and updates tables from declared schemas, so changing the event-bag structure between experiments needed no hand-written CREATE TABLE or ALTER TABLE statements. The real limit was the shared cluster: at peak hours, competition with company-wide jobs sometimes cut him to two concurrent runs.
The jobs ran on Workbench, Netflix's cloud development environment, inside 5, a terminal multiplexer whose sessions keep running after you disconnect. His laptop was just a terminal he could close. He used one git worktree (a separate checkout) per branch. With this setup he got 4–5 full experiment cycles done during a 24-hour flight home, checking results over in-flight Wi-Fi. He also used an AI agent for the routine work of deploying, polling metrics and comparing runs.
The experiment log
Every run added a row to a Markdown table in the repository with these columns: run number, JAR version, the change from the previous run, the config, and the key metrics (spill, failed tasks, peak memory, duration). He says the "change vs. previous run" column enforced discipline. Whenever he changed several things at once, the log made it obvious that the result couldn't be attributed to any one of them, and he had to rerun. A separate document turned the findings into a lookup table from symptom to the setting that fixes it.
Root cause: rows got heavier
The OOMs traced back to collect_list. Spark holds each session's entire event bag in memory, so what matters is the size of each bag, not the total volume of data. The legacy pipeline had processed the same number of rows without crashing, which meant each event had become heavier. Two changes did that:
Field types and 6 boxing. The session ID changed from an 8-byte Long to a UUID string. Fields were also wrapped in Scala Option (Scala's type for "value or nothing"). That forced Spark to turn rows into JVM Some objects, multiplying object allocations and garbage-collection pressure across tens of thousands of events per session.
Unused strings. Low-value fields such as referrer URLs and A/B test allocations added several hundred bytes per event. Repeated across every bag, they wasted terabytes per hour.
Removing Option from fields that are never null cut peak executor memory from about 112% of the slot to 58%, with zero spill. Dropping unused fields and moving per-session values (app version, country, connection type) into the session header so they are stored once also helped. The author's conclusion is that reducing each event's size did more for stability than adding cluster memory would have.
Two results he didn't expect
Narrower rows triggered a different memory problem. Slimming a table pushed it below Spark's auto-broadcast threshold. The query planner then switched from a shuffle join to a 7, which copies the smaller table into every executor's memory. Total memory pressure went up.
Broadcasts added up.8 is Spark's feature that re-plans queries at runtime based on actual data sizes. It rewrote nine sort-merge joins as broadcast hash joins. Each broadcast stayed under the 400 MB limit, but together they pinned about 3.1 GB at once in a single executor. Rather than disabling broadcasts entirely, he lowered only the runtime threshold, spark.sql.adaptive.autoBroadcastJoinThreshold, to 10 MB and left the compile-time threshold alone. That freed the 3.1 GB without changing the initial query plan.
A fix that only worked off-peak
To cap session size, he used row_number().over(Window...) and kept the first N events. The change passed off-peak tests and then crashed at peak. Spark's Window operator buffers an entire partition (here, a whole session) before it assigns row numbers, so the filter limited the output but not the memory used during buffering. The fix was to truncate before the Window stage. His lesson is to test against worst-case skew.
What shipped, and what's still open
Four changes shipped, each tested alone first: unboxing never-null fields, slimming the event bag, capping session size before the Window stage, and the 10 MB runtime broadcast threshold.
The author is clear that the underlying problem remains. Putting a whole session into one row has a hard ceiling. When the upstream telemetry adds attributes, each event gets heavier again, and memory pressure will come back with no code change on his team's side. In his words, tuning "buys time, not infinite scale." The data model has to change eventually. The post doesn't say what the new design would be.
Remember
If a bug only appears at full scale, run many full-scale experiments at once rather than trying to make one faster.
Spark memory failures can come from the size of each row inside an aggregation, not from total data volume.
Broadcast joins can each be under the size limit and still exhaust executor memory together. Lower the runtime threshold, not both.
4 (ReadOnlyCompactionStrategy). This compacts an immutable slice once so each 5 covers its own token range, and a key lookup touches only one file. SSTables are Cassandra's on-disk files. Netflix says ROCS also cut tail latency on the hot tier.
Local metadata. Backups have a manifest listing every SSTable file. Cassandra downloads the small index, filter and summary files to local disk. Only the large Data.db chunks are fetched from S3.
A custom memtable. Cassandra 4.1 lets a table swap in its own memtable. Netflix's backupmemtable sends reads for cold tables to S3. It fetches in parallel and prefetches, and it turns off read repair and speculative retries, which would only add S3 calls.
Safety checks. Before a table switches over, the control plane uses Cassandra 6 to confirm two things across the ring: compaction has finished and the backup is fully uploaded.
Load isolation. Reading from S3 is CPU-heavy, so cold reads can optionally run on a separate, cheaper cluster with the same topology.
ReadOnlyCompactionStrategy (8)
Postgres classifies expressions as immutable, stable or volatile. lower() and + are immutable. now() and to_char() are stable: constant within a statement, but dependent on session settings, which Postgres calls 3 (Grand Unified Configuration variables). now() is displayed according to the TimeZone GUC. to_char(date, 'TMDay') returns "Wednesday", "Onsdag" or "星期三" depending on lc_time. If a user runs SET lc_time = ..., Neki has to forward that setting to every shard that evaluates the expression. Otherwise the results could mix languages from row to row. The same applies to set_config(). So to_char() can be pushed down once the settings match. now() stays router-only because of the clock problem.