<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kashif Manzer</title>
    <description>The latest articles on DEV Community by Kashif Manzer (@kashif_manzer).</description>
    <link>https://dev.to/kashif_manzer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4141917%2F22f6777f-adeb-4fe8-86bd-58fce72dfb78.png</url>
      <title>DEV Community: Kashif Manzer</title>
      <link>https://dev.to/kashif_manzer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kashif_manzer"/>
    <language>en</language>
    <item>
      <title>The timeout that ate the database pool: how Inngest lost every connection twice in one afternoon</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Wed, 04 Nov 2026 14:10:46 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/the-timeout-that-ate-the-database-pool-how-inngest-lost-every-connection-twice-in-one-afternoon-1k7p</link>
      <guid>https://dev.to/kashif_manzer/the-timeout-that-ate-the-database-pool-how-inngest-lost-every-connection-twice-in-one-afternoon-1k7p</guid>
      <description>&lt;p&gt;At 14:00 UTC on October 10, 2026, both of Inngest's database connection poolers lost every connection to the primary database. Not most of them. All of them. And then, at 15:30, it happened again.&lt;/p&gt;

&lt;p&gt;Inngest runs background jobs for other companies. When you trigger a function there, a scheduler has to look up your account and your function before any work can start. Those lookups go through the database. With no usable connections, run scheduling stalled and queue processing stopped for most accounts.&lt;/p&gt;

&lt;p&gt;The team restarted the poolers. That did not reliably fix it. They raised the pool size. That did not help either. The pool was not too small. It was being emptied faster than it could be refilled, by the very timeouts meant to protect it.&lt;/p&gt;

&lt;p&gt;This is a story about a timeout doing exactly what it was told to do, in exactly the wrong place.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pooler in the middle
&lt;/h2&gt;

&lt;p&gt;First, the pieces, plainly.&lt;/p&gt;

&lt;p&gt;A database connection is expensive to open. So most services do not open a fresh one for every request. They keep a pool of open connections and hand them out as needed.&lt;/p&gt;

&lt;p&gt;A pooler sits between your service and the database. PgBouncer is one such pooler. Your service connects to PgBouncer, PgBouncer connects to the database, and PgBouncer decides which real database connection serves each query. It lets many application connections share a smaller number of database connections.&lt;/p&gt;

&lt;p&gt;A database driver is the library your code uses to talk to the database. Inngest's report points to pgx, the Go driver for Postgres, and to how it interacts with PgBouncer. You do not need to know pgx to follow this story. You only need to know that the driver is the part that sends your query, waits for an answer, and gives up when a deadline passes.&lt;/p&gt;

&lt;p&gt;A deadline, in Go, is usually a context with a timeout. You say: this lookup has 200 milliseconds, and if it is not done by then, stop waiting. That sounds safe. It usually is.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loop that ate the pool
&lt;/h2&gt;

&lt;p&gt;Here is the sequence Inngest describes, slowed down.&lt;/p&gt;

&lt;p&gt;Several frequently used lookups had short deadlines. Under normal load, a lookup waits briefly in PgBouncer for a free database connection, gets one, runs, and returns. The deadline never fires.&lt;/p&gt;

&lt;p&gt;But suppose the lookup is still waiting in PgBouncer when its deadline expires. The driver cancels the query and closes its connection to PgBouncer. That is the normal, polite thing to do when you have given up.&lt;/p&gt;

&lt;p&gt;The problem is that PgBouncer had nothing running to cancel. The query had not started yet. It was still queued, waiting for a connection.&lt;/p&gt;

&lt;p&gt;So PgBouncer later ran the abandoned query on a database connection anyway. Then, instead of returning that connection to the pool for reuse, it closed the connection entirely. One cancelled wait, one dead connection.&lt;/p&gt;

&lt;p&gt;Now repeat that at scale. Each pooler opens new database connections one at a time. That is a slow refill. If cancellations are destroying connections faster than the pooler can open replacements, the pool drains toward zero. Thousands of clients queue behind the empty pool. Their deadlines expire too. More cancellations. More dead connections. The timeout meant to shed load becomes the thing that keeps the load going.&lt;/p&gt;

&lt;p&gt;Inngest reproduced this behaviour in a test environment, so this is not a guess about what might have happened. It is the mechanism the report names.&lt;/p&gt;

&lt;p&gt;Two details made October 10 worse. Both collapses started at the top and bottom of the hour, when cron scheduled work roughly triples the query rate. Cron work is work on a schedule, like a job that runs every hour on the hour, and a lot of it lands at once. At the same moments, a new set of lookups that had been rolled out to all accounts before the weekend began failing in large numbers, adding to the cancellations. A spike plus a fresh source of failures, feeding the same loop.&lt;/p&gt;

&lt;p&gt;That is also why the obvious fixes failed. Restarting the poolers did not reliably recover them, because the loop restarted with them. Raising the pool size did not help, because opening connections, not the pool limit, was the bottleneck. If you can only refill one connection at a time, a bigger bucket does not help when the bucket has a hole in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually stopped it
&lt;/h2&gt;

&lt;p&gt;The second collapse ended when Inngest moved all services to connection poolers run alongside the database by its database provider. Processing recovered between 16:10 and 16:25.&lt;/p&gt;

&lt;p&gt;That move introduced a new problem, and it is worth naming because it shows how incident fixes trade one risk for another. The new route reached the database through network address translation gateways, and some connections through those gateways were silently dropped. Queries on a dropped connection hung for up to 16 minutes before failing, which intermittently stalled processing, most visibly for cron functions. Inngest moved the affected service back to the original poolers at 04:17 on October 11, and improved the gateways' routing on October 12.&lt;/p&gt;

&lt;p&gt;The lasting fix for the pool collapse itself was different, and it is the part you can take home. Inngest changed the lookups involved so that a deadline now ends the caller's wait without cancelling the underlying query. The caller stops waiting. The query is allowed to finish, and its connection goes back to the pool instead of being destroyed. That removes the trigger for this collapse on those lookups. Inngest says it is reviewing the remaining database calls on the execution path and their timeouts, which is the honest version of this fix: it is not done everywhere yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard you can steal this week
&lt;/h2&gt;

&lt;p&gt;The pattern below is a simplified illustration, not Inngest's code. It shows the split that matters: one deadline for how long the caller waits, and a separate life for the query itself.&lt;/p&gt;

&lt;p&gt;The dangerous shape is one deadline doing two jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c"&gt;// If this is still waiting in the pooler when the deadline fires,&lt;/span&gt;
&lt;span class="c"&gt;// the cancellation can destroy the very connection you were waiting for.&lt;/span&gt;
&lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT name FROM accounts WHERE id = $1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The safer shape separates the two:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="nb"&gt;make&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="n"&gt;lookupResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;// queryCtx is deliberately not tied to the caller's deadline.&lt;/span&gt;
    &lt;span class="c"&gt;// The query runs to completion and its connection returns to the pool.&lt;/span&gt;
    &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queryCtx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT name FROM accounts WHERE id = $1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;accountID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt; &lt;span class="n"&gt;lookupResult&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}()&lt;/span&gt;

&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;After&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="c"&gt;// The caller stops waiting. It does not cancel the query.&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"lookup timed out"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this only where it is safe, which is the part people skip. This shape makes sense for a read-only lookup that can safely run to completion even after the caller has given up. It does not make sense as a blanket rule for writes, where a query finishing late could do something the caller already assumed had failed. Check that before you copy it.&lt;/p&gt;

&lt;p&gt;Then add three checks around it, because code alone will not save you from the next spike:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Separate your timeouts in your head before you separate them in code. Ask, for each database call: is this deadline for waiting in the queue, for running the query, or for both? If you cannot answer, you have found a candidate for this bug.&lt;/li&gt;
&lt;li&gt;Alert on pool health, not just on slow queries. A pool draining toward zero usable connections, and a rising count of cancelled waits, is the early signal. Slow queries alone will mislead you, because in this incident the queries were not slow. They never started.&lt;/li&gt;
&lt;li&gt;Load test at the spike, not at the average. Test with the top-of-hour burst, and with a newly deployed lookup failing at the same time. A pool that survives average load can still collapse in the first minute of a burst, and that minute is when everyone is watching.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The wider lesson from the same weekend
&lt;/h2&gt;

&lt;p&gt;October 10 was the second incident in two days, and the first one explains why the second hurt so much. On October 9, from 21:05 to 21:49, one data center provider lost connectivity between its data center and Inngest's other data centers, plus outbound internet access. Inngest runs across three data centers, and function execution depends on all three. Accounts processed in the cut-off data center could not reach the primary database. Accounts elsewhere with run state stored there could not make progress. And accounts with no dependency on that data center were still delayed, because they shared an event-processing pipeline with the affected accounts, and failing work built a backlog there.&lt;/p&gt;

&lt;p&gt;Inngest's structural answer is to consolidate onto a single data center that acts as a single cell, which removes this whole class of cross-data-center failure. That is a bigger bet than a timeout fix, and it is still under way. But the two incidents share one lesson: a dependency you treat as background, a shared pipeline or a pooler queue, can become the whole incident when it fails in a way your timeouts and retries were not designed for.&lt;/p&gt;

&lt;p&gt;In both cases, the internal failures also counted against functions' configured retries, and a step that failed permanently on an internal error left its run stuck in a Running state instead of marking it failed, so failure handlers were not notified. Inngest has since changed that, so such runs are now marked failed. A stuck state that looks alive is its own kind of outage, quieter than a pool collapse and harder to notice.&lt;/p&gt;

&lt;p&gt;So check your own code this week. Find one database lookup with a short deadline that goes through a pooler. Ask what happens if that deadline fires while the query is still waiting, not running. If the answer is "I am not sure", you have found your next test.&lt;/p&gt;

&lt;p&gt;What is the shortest deadline in your codebase, and do you know whether it cancels a waiting query or just stops the wait?&lt;/p&gt;

</description>
      <category>backend</category>
      <category>devops</category>
      <category>database</category>
      <category>go</category>
    </item>
    <item>
      <title>The database log that filled up and stopped every CircleCI pipeline</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Mon, 02 Nov 2026 14:09:12 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/the-database-log-that-filled-up-and-stopped-every-circleci-pipeline-4cf</link>
      <guid>https://dev.to/kashif_manzer/the-database-log-that-filled-up-and-stopped-every-circleci-pipeline-4cf</guid>
      <description>&lt;p&gt;At 15:48 UTC on October 6, 2026, CircleCI stopped starting work. Not some work. All of it.&lt;/p&gt;

&lt;p&gt;You could still trigger a pipeline. The button worked. Nothing happened after it. For 50 minutes, no workflows or jobs started at all. Pipelines triggered from 16:02 failed outright. About 3,500 jobs failed to start during the incident, according to CircleCI's own post incident report. Job start times did not return to normal until 20:30 UTC, five hours after the first delays.&lt;/p&gt;

&lt;p&gt;Nothing had been deleted. No code had been deployed badly. The cause was quieter than that, and it had been building for hours before anyone treated it as an emergency. A routine cleanup job filled a disk, and that disk was the one thing every pipeline depended on.&lt;/p&gt;

&lt;p&gt;This is a story about a log file. If you run a database, or you depend on one, it is worth five minutes of your attention.&lt;/p&gt;

&lt;h2&gt;
  
  
  The machine in the middle
&lt;/h2&gt;

&lt;p&gt;When you trigger a pipeline on CircleCI, your request goes to a workflow orchestration service. Think of it as a dispatcher. It tracks the state of every workflow and job, decides when each job is ready to run, and hands ready jobs to the machines that actually run them. It also drives the job data you see in the user interface, the notifications, and the commit statuses sent back to your version control provider.&lt;/p&gt;

&lt;p&gt;That dispatcher stores its state in a database. Every change to that state is first written to a transaction log. A transaction log is a running receipt book: before the database applies a change, it writes down what is about to change. If the machine crashes mid-change, the receipt book lets it rebuild what happened. Nothing is trusted until it is in the log.&lt;/p&gt;

&lt;p&gt;The database keeps those logs on a dedicated storage volume, a separate disk set aside just for them. It archives the logs continuously, clearing old ones so the space can be reused. That archive step is the quiet hero of this story, and also its failure point. If archiving falls behind and the volume fills, the database stops accepting writes. No writes means no new work can move forward. The dispatcher cannot dispatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cleanup job nobody watches
&lt;/h2&gt;

&lt;p&gt;Databases also run periodic maintenance to keep their internal records valid over time. CircleCI's report calls this process autovacuum. On a very large table, it reads every page of the table and writes a large volume of transaction logs as it goes.&lt;/p&gt;

&lt;p&gt;On October 6, that maintenance started on several of the database's largest tables at around 12:40 UTC. It ran alongside the regular workload, which was generating its own transaction logs. Together, they wrote logs faster than the database could archive them. The log volume started to fill.&lt;/p&gt;

&lt;p&gt;Here is the timeline, from CircleCI's report, all times UTC:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;10:02: Monitoring raised short-lived alerts about errors in the orchestration service. Each cleared on its own within about 15 minutes. Engineers had seen similar brief alerts before, so they followed the runbook each time.&lt;/li&gt;
&lt;li&gt;12:40: Maintenance on the largest tables began writing logs faster than archiving could clear them.&lt;/li&gt;
&lt;li&gt;13:30: The orchestration service began to slow down, in short bursts at first.&lt;/li&gt;
&lt;li&gt;14:54: Engineers declared an incident, after alerts from several services piled up together.&lt;/li&gt;
&lt;li&gt;15:30: Delays became continuous. Some jobs began to fail.&lt;/li&gt;
&lt;li&gt;15:43: The transaction log volume was close to full. If it filled, the database would stop accepting writes entirely.&lt;/li&gt;
&lt;li&gt;15:47: Engineers began moving the logs onto the database's main storage volume, which had plenty of free space. The move required taking the database offline, for a length of time they could not predict in advance.&lt;/li&gt;
&lt;li&gt;15:48 to 16:38: No workflows or jobs started. This was the 50 minute outage.&lt;/li&gt;
&lt;li&gt;16:38 to 18:19: The database was back, but with logs and regular data now sharing the same storage, it processed work at about half its normal rate. Jobs waited tens of minutes, up to about 50 minutes at the longest.&lt;/li&gt;
&lt;li&gt;18:19: Engineers changed a database setting to cut the time each write spent waiting on storage. Processing speed recovered.&lt;/li&gt;
&lt;li&gt;20:30: Job start times returned to normal.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read that timeline again and notice what did not happen. Nobody caught the disk filling in time, because CircleCI had no alert on how full the transaction log volume was, and no alert on how far archiving had fallen behind. Their own report says so plainly. Monitoring caught the slowdown. It did not catch the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the fix took five hours
&lt;/h2&gt;

&lt;p&gt;The obvious question is why moving logs to a bigger disk did not fix everything at 16:38. The answer is that the fix created a second problem. With logs and regular data sharing one volume, every write now competed for the same storage. The database ran at about half speed for nearly two hours while it worked through a backlog of unarchived logs. Only after engineers changed a setting that cut how long each write waited on storage did the backlog begin to clear. Extra capacity added later helped drain it faster.&lt;/p&gt;

&lt;p&gt;There is a lesson in that shape. The first failure was a disk filling up. The second failure was the recovery running slowly because the emergency arrangement was worse than the original one. Backlogs do not just wait politely. While jobs queue up, the queue itself becomes load: more retries, more status checks, more work arriving on top of work that has not finished. CircleCI's report notes that during the incident, some jobs failed because their own systems gave up after retrying. A slow recovery can keep hurting after the root cause is gone.&lt;/p&gt;

&lt;p&gt;CircleCI also prepared a full replacement stack of the service during the incident and chose not to switch to it, because switching carried a risk of data loss. That is a decision worth respecting. The fastest recovery is not always the right one when it can lose the very state you are trying to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard you can add this week
&lt;/h2&gt;

&lt;p&gt;CircleCI's own list of fixes starts with the missing piece: alerts on how full the transaction log volume is, and on how far archiving has fallen behind. You can build the first half of that in a small script, and it works for any database that keeps its logs on a known path. The idea is simple. Do not just check how full the disk is right now. Track how fast it is filling, and estimate how many minutes you have left. A disk at 60 percent is fine if it grows slowly. A disk at 60 percent that gains a percent every ten minutes is an incident in about seven hours, and you want to know that at noon, not at midnight.&lt;/p&gt;

&lt;p&gt;Here is a small Python guard you can adapt. It reads the disk holding your log directory, compares it with its last reading, and exits with a warning or critical code your monitoring can alert on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="n"&gt;LOG_PATH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/lib/postgresql/wal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# change to your log directory
&lt;/span&gt;&lt;span class="n"&gt;STATE_FILE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/var/tmp/wal_guard_state.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;WARN_PERCENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;
&lt;span class="n"&gt;CRITICAL_PERCENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;85&lt;/span&gt;
&lt;span class="n"&gt;CRITICAL_MINUTES_LEFT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;read_disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;usage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shutil&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;disk_usage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;used_percent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;used_percent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;free&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;used_percent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;free_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;read_disk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOG_PATH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;minutes_left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATE_FILE&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATE_FILE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;elapsed_minutes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
        &lt;span class="n"&gt;growth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;used_percent&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;used_percent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;elapsed_minutes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;growth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;minutes_left&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;used_percent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;growth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;elapsed_minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;STATE_FILE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dump&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;time&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;used_percent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;used_percent&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;LOG_PATH&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; is &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;used_percent&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;% full, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;free_bytes&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; GB free&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;minutes_left&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, filling in about &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;minutes_left&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; minutes at the current rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;used_percent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;CRITICAL_PERCENT&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;minutes_left&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;minutes_left&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;CRITICAL_MINUTES_LEFT&lt;/span&gt;
    &lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRITICAL: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;used_percent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;WARN_PERCENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WARNING: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OK: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it every few minutes from your scheduler, and wire exit codes 1 and 2 to alerts that page a human. Two details matter. First, alert on the rate, not just the level: the minutes-left estimate is what turns a disk check into an early warning. Second, keep the archive lag check alongside it. A disk can look healthy while archiving quietly falls behind, and by the time the disk fills, your warning window is gone. If your database exposes an archive lag metric, alert on that too, with the same urgency.&lt;/p&gt;

&lt;p&gt;The deeper fix, the one CircleCI is still evaluating, is to remove the single bottleneck: split the service across multiple database instances, each with its own transaction log, or move to a distributed database that spreads writes across many nodes. Either way, one full log would slow down part of the workload instead of all of it. That is a bigger project. The disk guard is not. It is an afternoon of work, and it covers the exact gap that turned a routine cleanup into a five hour incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet part
&lt;/h2&gt;

&lt;p&gt;The most unsettling detail in CircleCI's report is not the disk. It is the morning. Short-lived alerts fired from 10:02, cleared on their own, and were treated as noise because similar alerts had cleared before. By the time the alerts from several services arrived together at 13:35, the log volume had already been filling for nearly an hour. CircleCI's own verdict is blunt: they should have recognised the combination of alerts sooner.&lt;/p&gt;

&lt;p&gt;That is a pattern worth checking in your own systems. A single alert that clears itself is noise. The same alert plus related alerts from neighbouring services, at the same time, is a story. If your runbook only says what to do about one alert at a time, it will keep telling you everything is fine right up until it is not.&lt;/p&gt;

&lt;p&gt;So here is the question to take back to your team: do you have an alert on your database log volume, and on how fast it is filling? If the answer is no, you are one routine cleanup job away from finding out the hard way. What would your morning look like if that cleanup started today?&lt;/p&gt;

&lt;p&gt;Source: CircleCI, "Post Incident Report: Oct 6, 2026 - Delays in pipelines, workflows, UI data, and notifications", discuss.circleci.com.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>database</category>
      <category>backend</category>
    </item>
    <item>
      <title>The new machine with the old map: how GitHub routed writes to servers that no longer existed</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Sun, 01 Nov 2026 14:09:22 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/the-new-machine-with-the-old-map-how-github-routed-writes-to-servers-that-no-longer-existed-3eoh</link>
      <guid>https://dev.to/kashif_manzer/the-new-machine-with-the-old-map-how-github-routed-writes-to-servers-that-no-longer-existed-3eoh</guid>
      <description>&lt;p&gt;On October 7, 2026, GitHub broke in a strange, specific way. You could read almost anything. You could not save almost anything.&lt;/p&gt;

&lt;p&gt;Push a commit, open a pull request, file an issue, start an Actions run, sign in: those are all writes, actions that save or change data. Reads, looking at code and pages that already existed, were largely unaffected. That split is the first clue, and it is worth sitting with before you look for a cause. When reads work and writes do not, the data itself is probably fine. The road that new data travels is not.&lt;/p&gt;

&lt;p&gt;That is the mystery this article works through. It ends with a small guard, in Python, that you can steal for your own routing config. The guard does one job: it refuses to use a map that points to servers that no longer exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clue one: only the writing road was broken
&lt;/h2&gt;

&lt;p&gt;GitHub had two outages that day with the same cause, totalling about 19 minutes. The first ran from 15:06 to 15:16 UTC, about 10 minutes. The second ran from 16:52 to 17:01 UTC, about 9 minutes.&lt;/p&gt;

&lt;p&gt;In the first window, 5.6 percent of all web and API requests failed, peaking at 8.9 percent, and more than 12,000 organizations saw failed requests. Up to 26 percent of Git authentication requests failed, and 17 percent of Actions workflow runs failed with infrastructure errors. In the second window, 6.2 percent of web and API requests failed, peaking at 9.3 percent, and more than 11,000 organizations were affected. Up to 25 percent of Git authentication requests failed, and 19 percent of Actions workflow runs failed with infrastructure errors.&lt;/p&gt;

&lt;p&gt;No databases lost data, according to GitHub, and operations retried after recovery succeeded. So this was not a story about lost work. It was a story about work that could not find its destination.&lt;/p&gt;

&lt;p&gt;GitHub's own status report, posted October 7, 2026, names the affected areas in those windows: Git operations, pull requests, issues, GitHub Actions, the API, webhooks, sign-in and authentication, GitHub Apps and OAuth, code scanning, Codespaces, and Copilot. That list looks random until you ask what those things share. They all need to save something new.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clue two: a new machine with an old map
&lt;/h2&gt;

&lt;p&gt;To understand the cause, you need three plain ideas.&lt;/p&gt;

&lt;p&gt;A database server is a machine that stores data and answers questions about it. A routing config is a list that says which database servers should receive new and updated data. Think of it as a map: it tells each write where to go.&lt;/p&gt;

&lt;p&gt;A quorum is a group of machines that must agree before a change counts. A leader is the machine in that group whose word the others accept for now. Leader election is just the process of choosing which machine leads. If a machine with a wrong map becomes leader, its wrong map becomes the map everyone follows.&lt;/p&gt;

&lt;p&gt;GitHub relies on an internal service that keeps track of which database servers should receive new and updated data. While replacing machines that run this service as part of regular maintenance, one of the new machines started with an outdated copy of its records. That copy still listed database servers GitHub had already retired. Retired here means taken out of service, gone, not answering anymore.&lt;/p&gt;

&lt;p&gt;When this node came online, it incorrectly modified routing configuration with the outdated records, routing write requests to non-existent database servers. Writes were sent to machines that were not there. Reads did not need that road in the same way, so they mostly kept working.&lt;/p&gt;

&lt;p&gt;Each outage ended when one of GitHub's established machines, with the correct configuration, became the leader of the quorum and sent the correct configuration. The good map replaced the bad map. That is why the first outage was only about 10 minutes long. The system healed itself, once the right leader was in charge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clue three: the pause that was not honoured
&lt;/h2&gt;

&lt;p&gt;Here is the part that should worry you more than the first outage. After the first outage, GitHub's engineers identified the new machine as the source and paused automated maintenance to stop it from being leader again. Then, at 16:52 UTC, an automated maintenance task that did not honour the pause put it back in charge, and the outage happened again.&lt;/p&gt;

&lt;p&gt;Read that slowly. The team did the right thing after the first failure. They found the bad machine and they paused the automation around it. But the pause was not honoured by every piece of automation. One task did not know about the pause, or did not check it, and it undid the fix.&lt;/p&gt;

&lt;p&gt;GitHub shut that machine down at 17:02 UTC and has since removed it. GitHub also says the automation now honours the pause, and that this failure to honour it was a new regression, a bug introduced by an update to its Puppet infrastructure. Puppet is a tool for managing machine configuration automatically, which is exactly the kind of tool that can quietly reintroduce a problem while nobody is looking.&lt;/p&gt;

&lt;p&gt;This is the pattern to remember. The first failure was a stale map. The second failure was a safety control that only some of the system respected. A pause that lives in one place, and is ignored by another, is not a pause. It is a hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard: never trust a map you have not checked
&lt;/h2&gt;

&lt;p&gt;GitHub says it is now doing several things to prevent a repeat: clearing retired database servers from the service's records before any new machine is added, keeping new machines from being elected leader until their records have been checked, rejecting configuration that points to servers that do not exist and keeping the last known-good settings instead, giving responders a faster way to take a misbehaving machine out of service, and adding alerts so changes like this are spotted within seconds.&lt;/p&gt;

&lt;p&gt;You can borrow the middle idea, the one about rejecting a bad map, in a few lines. The rule is simple. Before you apply a new routing config, check every destination against the live inventory, the list of servers that actually exist right now. If even one destination is missing, reject the whole config and keep the last known-good one. Do not apply the good half and skip the bad half. A half applied map is still a wrong map.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_routing_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;live_servers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_good_config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Apply new_config only if every destination really exists.

    new_config: a list of server names the new map points writes to.
    live_servers: a set of server names that exist right now.
    last_good_config: the last config we know worked.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;unknown&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;new_config&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;live_servers&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Refusing bad routing config. Unknown servers: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;unknown&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;last_good_config&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;new_config&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole guard. The rest is discipline around it. Check the map at the point where it is applied, not only at the point where it is written. A config file can be correct when it is saved and stale by the time it is used, which is exactly what happened at GitHub: the records were outdated when the new machine started.&lt;/p&gt;

&lt;p&gt;A boundary test makes the rule concrete. Test three cases: a config with no unknown servers, which should pass; a config with one unknown server among many good ones, which should fail; and a config where every server is retired, which should also fail. The one-unknown case is the important one. It is tempting to let it through, because most of the map looks fine. That temptation is the bug.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;live&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;last_good&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;apply_routing_config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_good&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;apply_routing_config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-gone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_good&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;last_good&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;apply_routing_config&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-gone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;db-also-gone&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_good&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;last_good&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two more checks belong next to this guard, and they come straight from GitHub's own list. First, do not let a new machine become leader until its records have been checked. A new member of a quorum should prove its map is fresh before it is allowed to lead. Second, make a pause a single, checked thing, not a note in one tool. If a maintenance task can run without asking whether it is paused, it will, on the worst day, run at the worst time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;You probably have a map like this somewhere: a config, a service discovery list, a set of connection strings, a deploy script that names servers. When was it last checked against what actually exists?&lt;/p&gt;

&lt;p&gt;Find the newest machine in your system, the one added most recently, and ask a blunt question about it. Does it hold a copy of anything that was written before something was retired? If you cannot answer that in a minute, that is the audit, and it is worth doing this week.&lt;/p&gt;

&lt;p&gt;When did you last verify that every destination in your routing config still exists?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>backend</category>
      <category>python</category>
      <category>database</category>
    </item>
    <item>
      <title>The migration that kept running after the rollback</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:09:49 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/the-migration-that-kept-running-after-the-rollback-232j</link>
      <guid>https://dev.to/kashif_manzer/the-migration-that-kept-running-after-the-rollback-232j</guid>
      <description>&lt;p&gt;You push a deploy. The dashboard accepts it. Then nothing happens.&lt;/p&gt;

&lt;p&gt;No error. No crash. No failed build log to read. Your live service keeps serving traffic happily, as if nothing is wrong. The deploy just sits in the queue, waiting for a worker that never picks it up.&lt;/p&gt;

&lt;p&gt;That is what happened at Render on Wednesday, October 14, 2026. Between 12:12 and 15:08 PDT, builds and deploys across all regions failed to start, stalled, or were badly delayed, in two periods of impact totalling about 2 hours and 20 minutes. Live user services were not affected. Only the machinery that starts new work was stuck.&lt;/p&gt;

&lt;p&gt;That split is the clue worth sitting with. When the thing that is already running works fine and the thing that starts new work does not, the problem is usually not the whole platform. It is in the path that new work takes, and in whatever that path shares with something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened, in order
&lt;/h2&gt;

&lt;p&gt;Render runs an internal job scheduler. It coordinates builds, deploys, and other critical platform operations. On October 14, the team was rolling out improvements to how that scheduler distributes work, after a successful practice run in a test environment. The planned version upgrade included a database schema migration.&lt;/p&gt;

&lt;p&gt;A schema migration is a change to the shape of a database: adding a column, changing a type, adding an index, that kind of thing. Some migrations are quick. Some need a lock on a table, which means other work on that table has to wait until the migration finishes.&lt;/p&gt;

&lt;p&gt;This migration locked a large table and drove database CPU use to 100 percent. That database stores two kinds of thing: the scheduler's core job state, and secondary data used for search and reporting. With the database that busy, the scheduler could not read or write job state reliably. Builds and deploys stopped or slowed.&lt;/p&gt;

&lt;p&gt;Here is the timeline from Render's postmortem, posted October 22, 2026. All times are PDT on October 14.&lt;/p&gt;

&lt;p&gt;12:13: The upgrade starts, including the migration. Database load rises and builds and deploys stop starting.&lt;/p&gt;

&lt;p&gt;12:24: Impact identified, incident escalated.&lt;/p&gt;

&lt;p&gt;12:34: Engineers revert the upgrade and stop the client performing the migration.&lt;/p&gt;

&lt;p&gt;12:37: The scheduler recovers. Builds and deploys resume, but slowly.&lt;/p&gt;

&lt;p&gt;13:17: Engineers restart one unhealthy instance of the system to rebalance load. Database connections and errors increase. The second period of impact starts.&lt;/p&gt;

&lt;p&gt;13:37: Free tier deploys are paused to reduce load.&lt;/p&gt;

&lt;p&gt;13:49: Engineers find the migration still running on the database and terminate it.&lt;/p&gt;

&lt;p&gt;14:16: The database is restarted. An attempt to add database capacity fails because of an error from the underlying cloud provider.&lt;/p&gt;

&lt;p&gt;14:30: Builds and deploys start to recover, slowly, as the backlog is processed.&lt;/p&gt;

&lt;p&gt;15:08: Deploy start times return to normal and database load returns to pre-incident levels.&lt;/p&gt;

&lt;p&gt;15:18: Incident resolved.&lt;/p&gt;

&lt;p&gt;Read that timeline again and notice the two moments that hurt most. The first is 12:34. The team reverted the upgrade and stopped the client doing the migration, and the system briefly looked better. The second is 13:49, more than an hour later, when they found the migration still running on the database.&lt;/p&gt;

&lt;p&gt;Stopping the client did not stop the migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The lesson most people miss: a migration lives on the server
&lt;/h2&gt;

&lt;p&gt;This is the part of the story that translates directly to your own work, even if you never run a platform like Render.&lt;/p&gt;

&lt;p&gt;When you run a migration, you usually run a small program on your machine, or in CI, that sends a statement to the database and waits. It is easy to think of that program as the migration. It is not. The migration is the work happening inside the database. Your program is just the messenger waiting for an answer.&lt;/p&gt;

&lt;p&gt;If you kill the messenger, the database may keep working. If you roll back the deploy that started it, the database may keep working. If you restart your app, the database may keep working. The statement you sent is still there, still holding its lock, still driving CPU up, until it finishes, or until someone kills it on the database side.&lt;/p&gt;

&lt;p&gt;That is why the partial recovery at 12:37 is so instructive. Deploys resumed, but deploy start times were about 20 seconds instead of a few hundred milliseconds. That is not a healthy system. That is a system still carrying a heavy load it has not named yet. The migration was still running, quietly, underneath a recovery that looked good enough.&lt;/p&gt;

&lt;p&gt;Then a well meaning restart at 13:17, meant to rebalance load, made things worse. Connections and errors rose, and the second, longer period of impact began. When a system is already struggling with a hidden load, adding a restart on top can tip it from slow to stuck.&lt;/p&gt;

&lt;p&gt;There were two more unlucky breaks in this incident, and both are worth noting because they are the kind of thing you only discover when you need them. A pre-upgrade step to create a database clone for rollback could not be completed, because of an error from the underlying cloud provider. So a fast rollback was not available. Later, an attempt to add database capacity failed for the same kind of reason. Two escape routes, both blocked by the same dependency. If your rollback plan depends on a step that can fail, test that step, not just the plan that assumes it worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three guards you can add this week
&lt;/h2&gt;

&lt;p&gt;None of this requires a platform team. The guards below are for the database you already run. Render's postmortem does not name its database engine or the exact migration statement, so treat the code here as a general guard for your own migrations, not as a description of Render's setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Put a leash on every migration
&lt;/h3&gt;

&lt;p&gt;A migration should never be allowed to run forever, holding a lock, while you wonder what happened. Set a lock timeout before you run DDL, which is the database term for statements that change schema. On Postgres, that looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'30s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;statement_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'10min'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;retry_count&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A lock timeout says: if you cannot get the lock in 30 seconds, give up loudly instead of queueing behind everything else. A statement timeout says: if the whole statement takes longer than 10 minutes, stop it. The numbers are yours to choose. The point is that a migration fails fast and visibly, instead of succeeding slowly while it strangles the system. A failed migration you can see is better than a running migration you cannot.&lt;/p&gt;

&lt;p&gt;If you use a migration tool, check whether it sets these for you. Many do not, by default. That one setting is the difference between a bad deploy you roll back in minutes and a bad afternoon you reconstruct from a timeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Kill it on the database side, and prove it is dead
&lt;/h3&gt;

&lt;p&gt;When you stop a migration, do not trust the client. Check the database itself for long running statements, and terminate the one you mean to terminate. On Postgres, you can look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;running_for&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'active'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'5 minutes'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;query_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_stat_activity&lt;/code&gt; is Postgres's view of what every connection is doing right now. &lt;code&gt;pid&lt;/code&gt; is the process id of that connection on the database server. If you find your migration there after you stopped the client, terminate that process id on the database side, then run the query again and confirm it is gone. The second check matters. The Render timeline shows a gap of about an hour and a quarter between stopping the client and finding the migration still running. A check that takes thirty seconds would have closed that gap.&lt;/p&gt;

&lt;p&gt;In Python, the same habit looks like this: never assume a cancelled task cancelled the work. If you cancel a migration runner, follow it with a check against the database before you declare the rollback done.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;migration_is_still_running&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;needle&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pid, query FROM pg_stat_activity &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WHERE state = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; AND query ILIKE %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;needle&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass in a distinctive fragment of the migration statement, and treat a non-empty result as "not rolled back yet". That is a small function, and it is the one Render's team would have wanted at 12:35.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Keep hot job state away from reporting data
&lt;/h3&gt;

&lt;p&gt;The scheduler database held both core job state and secondary data for search and reporting. That is a quiet design choice with a loud failure mode. When one store does two jobs, a problem in the less important job can block the important one. A reporting query, a search index rebuild, or a migration on a shared table can all hold up the thing that actually starts your deploys.&lt;/p&gt;

&lt;p&gt;You do not need to split the database on day one. But you can ask one question before your next migration: does this table also carry the state that decides whether work starts? If it does, that migration deserves a production sized test, a lock timeout, and a named person watching it. A practice run in a test environment passed for Render, and the migration still locked a large table in production. Test data is usually small. Production tables are not. A migration that is instant on a thousand rows can be a lock storm on millions. If you cannot test at production size, test at a size that at least makes the lock visible, and treat "it was fast in test" as weak evidence, not a guarantee.&lt;/p&gt;

&lt;p&gt;Render's planned fixes point the same way: blue-green deploys for this component, so the system is not upgraded under load, cross-zone high availability for the database, and storage mechanisms better suited to the scheduler workload. Blue-green means running the new version alongside the old one and switching traffic over only when the new one is healthy, instead of upgrading the live system in place. That is a bigger change than a lock timeout, but it is the same idea at platform scale: never upgrade the only copy of a critical path while it is carrying traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check before your next migration
&lt;/h2&gt;

&lt;p&gt;Look at the last migration you ran, or the next one in your queue, and ask four questions.&lt;/p&gt;

&lt;p&gt;First, if you stop the client mid-migration, what kills the statement on the database? If you cannot answer that, find the answer before the migration, not during the incident.&lt;/p&gt;

&lt;p&gt;Second, what timeout bounds it? If there is none, add one.&lt;/p&gt;

&lt;p&gt;Third, does the table it touches also hold the state that starts work? If it does, that migration is critical path work, whatever its size looks like in test.&lt;/p&gt;

&lt;p&gt;Fourth, does your rollback depend on a step that can fail on its own, like a clone or a capacity increase? If it does, test that step separately, and have a second way back.&lt;/p&gt;

&lt;p&gt;The Render outage did not come from a exotic bug. It came from an ordinary migration, on an ordinary upgrade, with a rollback plan that had a hole in it and a running statement nobody could see from the client side. That is what makes it worth learning from. Your next migration will also look ordinary, right up until it does not.&lt;/p&gt;

&lt;p&gt;Sources: Render postmortem, "Builds, deploys, and queued deploys are not starting in all regions", October 14, 2026 incident, postmortem posted October 22, 2026, &lt;a href="https://postmortem.io/incidents/render--2026-10-14--job-scheduler-schema-migration-builds-deploys/" rel="noopener noreferrer"&gt;https://postmortem.io/incidents/render--2026-10-14--job-scheduler-schema-migration-builds-deploys/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When was the last time a migration surprised you, and did you check the database side before you called it rolled back?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>database</category>
      <category>backend</category>
      <category>cicd</category>
    </item>
    <item>
      <title>The cluster that would not start, because 1000000 stopped being a number</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Wed, 28 Oct 2026 23:56:14 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/the-cluster-that-would-not-start-because-1000000-stopped-being-a-number-5ck</link>
      <guid>https://dev.to/kashif_manzer/the-cluster-that-would-not-start-because-1000000-stopped-being-a-number-5ck</guid>
      <description>&lt;p&gt;You create a new Kubernetes cluster. You wait. Nothing crashes. Nothing errors in a useful way. The cluster just sits there, with core components pending, as if it is waiting for something that will never arrive.&lt;/p&gt;

&lt;p&gt;That is how this one started, on October 17, 2026, for people creating new Linode Kubernetes Engine-Enterprise clusters, known as LKE-E. Existing clusters kept working. Ongoing workloads kept working. Scaling existing clusters kept working. Only new clusters were stuck.&lt;/p&gt;

&lt;p&gt;That split is the first clue. When old things work and new things do not, the problem is usually not the whole system. It is in the path that only new things take.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;Starting October 17, 2026, customers were unable to create new LKE-E clusters globally. The impact was limited to newly provisioned clusters, while existing clusters, ongoing customer workloads, and cluster scaling remained fully functional throughout the event.&lt;/p&gt;

&lt;p&gt;Investigation found the trigger after Linode Virtual Private Cloud subnet IDs crossed 1,000,000, following growth on October 17, 2026.&lt;/p&gt;

&lt;p&gt;A Virtual Private Cloud, or VPC, is a private network inside a cloud provider. A subnet is a slice of that network. Each subnet gets an ID, a label that other parts of the system use to find it.&lt;/p&gt;

&lt;p&gt;Due to a software logic flaw, the Cloud Controller Manager parsed these 7-digit IDs into scientific notation instead of raw integers, rendering the IDs invalid and causing required core components to remain pending on newly created clusters.&lt;/p&gt;

&lt;p&gt;The Cloud Controller Manager is the part of Kubernetes that talks to the cloud provider. It asks for load balancers, routes, and network resources, and it keeps those resources in step with what the cluster wants. If it cannot match a subnet ID, it cannot finish that work, so the components that depend on it stay pending.&lt;/p&gt;

&lt;p&gt;Akamai deployed a fix to enforce strict integer formatting and patched existing custom resources to restore the affected clusters. Internal testing confirmed that newly provisioned test clusters were fully operational, and the impact was considered mitigated at around 19:49 UTC on October 20, 2026. A postmortem update followed at 15:49 UTC on October 21, 2026.&lt;/p&gt;

&lt;p&gt;That is the whole incident, in one paragraph. The interesting part is not Linode. It is the pattern, because it shows up in ordinary code far more often than it should.&lt;/p&gt;

&lt;h2&gt;
  
  
  An ID is not a number you do math on
&lt;/h2&gt;

&lt;p&gt;An ID looks like a number, but you should treat it as a label. You do not add two user IDs together. You do not average subnet IDs. You only need the label to survive every hop unchanged, from the database, through the API, through JSON, through your code, and back out again.&lt;/p&gt;

&lt;p&gt;A float breaks that promise.&lt;/p&gt;

&lt;p&gt;A float, short for floating point number, is how computers store numbers with fractions. It is good for measurements. It is bad for exact labels, because it does not store every integer exactly, and because formatting a float can change how it looks.&lt;/p&gt;

&lt;p&gt;Take 1000000. As an integer, it is seven digits, nothing more. As a float, a formatter can turn it into scientific notation, something like 1e+06. That means 1 times 10 to the power of 6. It is the same value to a human doing math, but it is not the same string. A downstream system that expects digits will reject it, or look up the wrong thing, or fail to match anything at all.&lt;/p&gt;

&lt;p&gt;That is why the boundary matters. 999999 has six digits. 1000000 has seven. A bug that only appears when an ID grows by one digit can sit quietly for years, then break everything on the day a counter crosses that line. The system did not get worse that day. The data got bigger, and a hidden assumption finally broke.&lt;/p&gt;

&lt;p&gt;You have probably seen a version of this before. A phone number stored as a number loses its leading zero. A credit card number stored as a number loses precision. A timestamp stored as a float loses the last digits. The lesson is the same every time. If you do not do math on it, do not store it as a number you can do math on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard, in Go
&lt;/h2&gt;

&lt;p&gt;In Go, do not parse an ID through float64. Parse it as an integer, and fail loudly if it is not digits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"1000000"&lt;/span&gt;
&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That code is small on purpose. ParseInt with base 10 and 64 bits will reject text that is not a plain integer. If the input is "1e+06", it fails, and you find out at the boundary, not three systems later when a cluster is pending.&lt;/p&gt;

&lt;p&gt;JSON needs the same care. By default, Go's JSON decoder turns numbers into float64. If an ID arrives in JSON, tell the decoder to keep numbers as tokens instead.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;dec&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewDecoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UseNumber&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;SubnetID&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Number&lt;/span&gt; &lt;span class="s"&gt;`json:"subnet_id"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;dec&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;strconv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ParseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SubnetID&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;64&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UseNumber does not fix the ID for you. It stops the decoder from turning it into a float before you get a chance to check it. The ParseInt check after it is the guard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The guard, in Python
&lt;/h2&gt;

&lt;p&gt;Python has the same trap, in a different shape. JSON numbers become int or float depending on the value, and a generic formatter can still change the form. The guard is to reject a float ID, and to validate the digit form before you use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;subnet_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subnet id must be digits, not a boolean&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subnet id must not come through as a float&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;subnet id must be digits only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The boolean check looks odd until you remember that in Python, bool is a subclass of int, so True would otherwise pass as 1. That is exactly the kind of small detail that makes a boundary guard worth writing once and reusing.&lt;/p&gt;

&lt;p&gt;If you want to keep the ID as a string all the way through, that is often the best choice. A string does not get reformatted by a number formatter, and it does not lose leading zeros. Store it as a string, pass it as a string, and only convert it when you have a real reason to, and a check to go with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test that would have caught it
&lt;/h2&gt;

&lt;p&gt;The fix here was strict integer formatting. The test that catches this class of bug is even simpler. Test at the boundary where the form changes.&lt;/p&gt;

&lt;p&gt;Test with 999999. Test with 1000000. Test with 1000001.&lt;/p&gt;

&lt;p&gt;If your code passes the first and fails the second, you have found the bug before your users do. If you only test with small IDs like 1, 2, and 42, you will never see it, because small IDs survive almost every bad conversion.&lt;/p&gt;

&lt;p&gt;Add that test at the place where an ID enters your system, not just where it leaves. By the time a bad ID reaches the cloud controller, the database, or the downstream service, you have lost the context that would tell you where it changed form. Check it at the boundary, log the original form when it fails, and fail loudly. A pending cluster with no useful error is worse than a clear rejection, because it looks like slowness, not a bug.&lt;/p&gt;

&lt;p&gt;There is a second lesson in this incident, and it is about how you read a failure. Existing clusters worked. New clusters did not. That tells you the core system was healthy, and the creation path was not. When you see that split, do not restart everything. Trace the path that only the failing case takes, and check every place where a value changes form along that path. In this case, that path was subnet creation, and the form change was integer to scientific notation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check this week
&lt;/h2&gt;

&lt;p&gt;Look for IDs in your own system that pass through a float, a generic number formatter, or a JSON parser that does not preserve the number form. That includes subnet IDs, user IDs, order IDs, and any other identifier that looks like a number but is really a label.&lt;/p&gt;

&lt;p&gt;Ask three questions for each one. First, does anything do math on this ID? If not, why is it a number at all? Second, what happens when it crosses a power of ten, from 999 to 1000, or from 999999 to 1000000? Third, if the form changes, where do you find out, at the boundary, or days later when something is pending?&lt;/p&gt;

&lt;p&gt;If you cannot answer the third question, that is the bug to fix first. A clear error at the boundary is a feature. A silent pending state is not.&lt;/p&gt;

&lt;p&gt;Sources: Linode status summary for the October 20, 2026 LKE-E incident, as reproduced on Statusfield, &lt;a href="https://statusfield.com/services/linode/incidents" rel="noopener noreferrer"&gt;https://statusfield.com/services/linode/incidents&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Where in your own system does an ID pass through a float, a generic formatter, or a JSON parser that could change its form?&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>backend</category>
      <category>go</category>
    </item>
    <item>
      <title>Your app was patched. Your PHP was not: PHP2Shell explained</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Sun, 25 Oct 2026 20:57:34 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/your-app-was-patched-your-php-was-not-php2shell-explained-3de7</link>
      <guid>https://dev.to/kashif_manzer/your-app-was-patched-your-php-was-not-php2shell-explained-3de7</guid>
      <description>&lt;p&gt;On Monday, October 20, 2026, the secret got out early. Details of two critical PHP flaws leaked before the planned disclosure. The PHP team moved its security release up a day and shipped the fixes on Wednesday, October 21.&lt;/p&gt;

&lt;p&gt;That rushed week has a name now: PHP2Shell. It is not a WordPress plugin bug. It is not a theme bug. It is a flaw in PHP itself, the runtime underneath all of those things.&lt;/p&gt;

&lt;p&gt;If you run anything on PHP, this one is worth understanding properly, because the usual advice, update your app, does not fix it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What PHP2Shell actually is
&lt;/h2&gt;

&lt;p&gt;PHP2Shell is the name researchers gave to two flaws in PHP core:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CVE-2026-109618, rated 9.2 out of 10 under CVSS v4.0&lt;/li&gt;
&lt;li&gt;CVE-2026-109619, also rated 9.2&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both can let an attacker who is not logged in run code on a vulnerable web server by sending a crafted HTTP request. No username. No password. No click from a victim.&lt;/p&gt;

&lt;p&gt;A quick glossary, because the rest only makes sense with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime:&lt;/strong&gt; the program that runs your code. Your app is the recipe. PHP is the kitchen. If the kitchen is broken, a perfect recipe does not help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remote code execution (RCE):&lt;/strong&gt; an attacker gets to run their own commands on your server, as the web server user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker:&lt;/strong&gt; a long lived PHP process that handles one request after another. PHP-FPM and Apache mod_php both use workers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both flaws live in how PHP sets up and cleans up after a web request. That is why they are so wide reaching. The vulnerable code runs before your application code gets a chance to defend itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flaw one: the half written form
&lt;/h2&gt;

&lt;p&gt;CVE-2026-109618 happens when PHP runs out of memory in the middle of setting up request data.&lt;/p&gt;

&lt;p&gt;Think of it like a clerk filling in a form. Halfway through, the clerk runs out of paper and stops. Later, someone else picks up that half finished form and treats it as complete. They follow instructions that were never meant to be real.&lt;/p&gt;

&lt;p&gt;In PHP terms, out of memory handling interrupts the setup while internal request data is only partly built. During cleanup, PHP can then treat that invalid data as valid. That confusion in memory is what an attacker can steer toward running code.&lt;/p&gt;

&lt;p&gt;This is the flaw Cisco Talos describes as out of memory handling interrupting PHP while internal request data is only partially initialized, with cleanup then treating invalid data as valid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Flaw two: the stale sticky note
&lt;/h2&gt;

&lt;p&gt;CVE-2026-109619 is a close cousin, and it is easier to picture.&lt;/p&gt;

&lt;p&gt;Imagine a worker reuses a desk. The last person left a sticky note on it. The new worker is supposed to throw that note away and start fresh, but for a brief moment the old note is still there, and the new worker reads it as if it were their own instructions.&lt;/p&gt;

&lt;p&gt;In PHP terms, request specific state survives after the memory behind it has been freed. A later request on the same worker can reach that stale state before normal setup resets it. Again, memory that should be dead gets treated as live, and an attacker can use that window.&lt;/p&gt;

&lt;p&gt;Neither flaw needs a bug in your code. Your code can be clean, reviewed, and fully patched, and still sit on top of a broken runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why updating WordPress did not fix it
&lt;/h2&gt;

&lt;p&gt;This is the part most people missed in the first wave of headlines.&lt;/p&gt;

&lt;p&gt;PHP powers WordPress, Drupal, Joomla, Laravel apps, and a long list of vendor tools and control panels. When you update WordPress, you update the app. You do not update PHP. Those are two different layers, updated by different people, on different schedules.&lt;/p&gt;

&lt;p&gt;CISA, the US cybersecurity agency, lists the affected setups plainly: Apache with mod_php, PHP-FPM, php-cgi, FrankenPHP, and LiteSpeed. It calls Apache mod_php the highest risk, because the demonstrated exploit there triggers before any site code runs.&lt;/p&gt;

&lt;p&gt;So the checklist question is not "is my CMS up to date?" It is "is my PHP up to date?" If you cannot answer the second question, you have not finished the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why finding every PHP install is the hard part
&lt;/h2&gt;

&lt;p&gt;Patching sounds simple: install the fixed version and move on. In practice, PHP hides.&lt;/p&gt;

&lt;p&gt;It hides inside Docker images built months ago. It hides inside vendor software that never mentions PHP in its marketing. It hides on a shared hosting account you forgot about, and in an internal tool someone set up years ago and never documented.&lt;/p&gt;

&lt;p&gt;CISA makes the same point in its alert: PHP is often a component of other software, and its presence may not be directly visible. That is why the first step is an inventory, not a patch. You cannot patch what you have not found.&lt;/p&gt;

&lt;p&gt;A practical way to start:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List your servers, containers, and hosting accounts.&lt;/li&gt;
&lt;li&gt;On each one, check the runtime itself, not the app. On a server, &lt;code&gt;php -v&lt;/code&gt; tells you the version of the command line PHP, which is a start, but check what your web server actually uses too. A system can have more than one PHP installed.&lt;/li&gt;
&lt;li&gt;Check your container images and rebuild any that bundle an old PHP, even if the app inside them is current.&lt;/li&gt;
&lt;li&gt;Ask your hosting provider or platform what PHP version they run for you, if you do not control the server. Managed WordPress hosts patched many customers automatically. Unmanaged servers did not patch themselves.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What to do today
&lt;/h2&gt;

&lt;p&gt;The fixed versions, for supported branches, are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PHP 8.5.12&lt;/li&gt;
&lt;li&gt;PHP 8.4.27&lt;/li&gt;
&lt;li&gt;PHP 8.3.36&lt;/li&gt;
&lt;li&gt;PHP 8.2.35&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick the fixed version for the branch you already run. Do not jump branches in a panic unless you have tested it. A branch jump can break an app in ways a patch release should not.&lt;/p&gt;

&lt;p&gt;Then do the step people skip: restart every PHP worker. Restart PHP-FPM, restart Apache if you use mod_php, restart queue workers. A running process keeps the old, vulnerable code in memory until it restarts. Installing the patch on disk is not enough while the old worker is still alive.&lt;/p&gt;

&lt;p&gt;If you run PHP 8.1 or earlier, there is no official patch for your branch. Those versions are out of support. Your real options are to upgrade to a supported branch, or, as CISA puts it, take the PHP based system offline until it can be patched or updated. That sounds drastic. It is the honest version of "we have no fix for this version."&lt;/p&gt;

&lt;p&gt;A web application firewall may block some attack attempts, and it is worth turning on if you have one. But it is not a complete fix. The PHP team and CISA are clear on this: patching is the only complete fix. A firewall rule can miss a variant. A patched runtime cannot be exploited through these two flaws at all.&lt;/p&gt;

&lt;p&gt;Finally, watch for the signs of a worker gone wrong: a PHP worker process launching a shell, a scripting interpreter, or a download tool. That is not normal web server behaviour. If you see it, treat it as an incident, not a curiosity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the AI angle?
&lt;/h2&gt;

&lt;p&gt;You will have seen the other headline: these flaws were found with help from frontier AI models. Researchers at Calif and Wiz have said AI tools helped them trace and verify a complex chain that human researchers might have dismissed as impractical.&lt;/p&gt;

&lt;p&gt;That part matters, but not for the reason the hype suggests. It does not mean AI is now finding every bug overnight. It means the cost of finding a hard, decade old bug just dropped. A flaw that sat in PHP since the 5.4 era, according to the researchers, stayed hidden for years partly because exploiting it looked too complex to bother with. Once that complexity barrier falls, the patching window matters more than ever.&lt;/p&gt;

&lt;p&gt;The lesson is boring and useful: your patch process is now part of your security posture, not an admin chore you get to later. The gap between "patch available" and "patch installed everywhere, workers restarted" is the gap attackers live in.&lt;/p&gt;

&lt;p&gt;Reports of early attack attempts circulated before and just after the patch, including from Cloudflare. But nuance matters here: CISA said that as of its alert, it had not observed successful exploitation in the wild. Early attempts are not the same as confirmed break ins. Do not let either extreme mislead you. The flaws are real and critical, the patch exists, and unpatched servers are the ones at risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Most security advice tells you to patch your app. PHP2Shell is a reminder to patch the layer underneath your app too.&lt;/p&gt;

&lt;p&gt;Check your PHP version. Check it in every place PHP could be hiding, not just the one server you remember. Upgrade to a fixed release. Restart the workers. And if you are on an unsupported version, make the upgrade plan this week, not someday.&lt;/p&gt;

&lt;p&gt;Your app was never the only thing an attacker could reach. This week proved it.&lt;/p&gt;

&lt;p&gt;What did you find when you checked? Did PHP turn up somewhere you did not expect?&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CISA alert: Critical Vulnerabilities in PHP Require Immediate Patching: &lt;a href="https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching" rel="noopener noreferrer"&gt;https://www.cisa.gov/news-events/alerts/2026/10/21/critical-vulnerabilities-php-require-immediate-patching&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Cisco Talos: Two critical RCEs in PHP core: &lt;a href="https://blog.talosintelligence.com/two-critical-rces-in-php-core/" rel="noopener noreferrer"&gt;https://blog.talosintelligence.com/two-critical-rces-in-php-core/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Tenable FAQ on CVE-2026-109618 and CVE-2026-109619: &lt;a href="https://www.tenable.com/blog/php2shell-cve-2026-109618-cve-2026-109619-frequently-asked-questions-about-critical-php-remote" rel="noopener noreferrer"&gt;https://www.tenable.com/blog/php2shell-cve-2026-109618-cve-2026-109619-frequently-asked-questions-about-critical-php-remote&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;DigitalOcean: Critical PHP RCE Vulnerabilities, patch now: &lt;a href="https://www.digitalocean.com/blog/critical-php-rce-vulnerabilities-patch-now" rel="noopener noreferrer"&gt;https://www.digitalocean.com/blog/critical-php-rce-vulnerabilities-patch-now&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>php</category>
      <category>security</category>
      <category>devops</category>
      <category>backend</category>
    </item>
    <item>
      <title>Why the Linux OOM killer killed your container when memory looked fine</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Fri, 23 Oct 2026 16:12:01 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/why-the-linux-oom-killer-killed-your-container-when-memory-looked-fine-4o60</link>
      <guid>https://dev.to/kashif_manzer/why-the-linux-oom-killer-killed-your-container-when-memory-looked-fine-4o60</guid>
      <description>&lt;h1&gt;
  
  
  Why the Linux OOM killer killed your container when memory looked fine
&lt;/h1&gt;

&lt;p&gt;Your phone buzzes at 3 AM. A pod restarted. &lt;code&gt;kubectl describe pod&lt;/code&gt; says the last state was &lt;code&gt;OOMKilled&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So you check the node. It has gigabytes free. Nothing is close to full. You stare at the two facts side by side: killed for lack of memory, and plenty of memory. One of them has to be lying.&lt;/p&gt;

&lt;p&gt;Neither is. There are two different out-of-memory killers on that machine, and only one of them cares about the node's free memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two killers
&lt;/h2&gt;

&lt;p&gt;Every container on your node lives inside a &lt;strong&gt;cgroup&lt;/strong&gt;, short for control group. A cgroup is the kernel's way of putting a fence around a group of processes and saying: this group gets this much CPU, this much memory, no more.&lt;/p&gt;

&lt;p&gt;When you set &lt;code&gt;resources.limits.memory: 512Mi&lt;/code&gt; on a container, you are setting the fence height for its cgroup. When the processes inside cross that line, the kernel steps in and kills something inside the cgroup. That is a &lt;strong&gt;cgroup OOM kill&lt;/strong&gt;. The node can have 30 GB free. It does not matter. The fence is the fence.&lt;/p&gt;

&lt;p&gt;The other killer is the &lt;strong&gt;system OOM killer&lt;/strong&gt;. That one fires when the whole node is genuinely out of memory. It picks a victim from anywhere on the machine.&lt;/p&gt;

&lt;p&gt;So the first question to answer after any OOM kill is: which one fired? The kernel tells you, in the machine's log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dmesg | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"out of memory"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You will see a line like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Out of memory: Killed process 18421 (java) total-vm:2147484kB ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Right above it, the log names the cgroup the victim belonged to. If it names your container's cgroup, the container hit its own limit, not the node's. That is the 3 AM answer nine times out of ten.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the killer picks its victim
&lt;/h2&gt;

&lt;p&gt;Once the kernel decides a kill has to happen, it does not kill at random, and it does not simply kill the biggest process. It scores every candidate and kills the highest scorer.&lt;/p&gt;

&lt;p&gt;The score lives in a file you can read right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/self/oom_score
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mine says 666. Yours will say something else. The number is roughly 0 to 1000 and it is mostly this: how much memory the process uses, as a share of the memory it is allowed to use.&lt;/p&gt;

&lt;p&gt;That "allowed to use" part is the whole trick. Inside a container, your allowance is the cgroup limit, not the node's memory. A process using 400 MB inside a 512 MB container scores far higher than a process using 2 GB on a 64 GB node, because the first one is at 78 percent of its allowance.&lt;/p&gt;

&lt;p&gt;The kernel adds small adjustments on top. Root-owned processes get a small discount. And then there is the knob.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one knob that changes the score
&lt;/h2&gt;

&lt;p&gt;Every process carries a second file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/self/oom_score_adj
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one says 0 by default, and you (or your orchestrator) can set it anywhere from -1000 to +1000. The kernel simply adds it to the score. Set it to -1000 and the process is unkillable by the OOM killer. Set it to +1000 and it is first in line.&lt;/p&gt;

&lt;p&gt;This is the mechanism behind one of the most confusing behaviors in Kubernetes. Kubernetes sets &lt;code&gt;oom_score_adj&lt;/code&gt; for you based on the pod's &lt;strong&gt;quality of service class&lt;/strong&gt;, a label Kubernetes derives from your resource settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guaranteed&lt;/strong&gt; (every container sets equal memory requests and limits): the kernel is told -998. Almost never killed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BestEffort&lt;/strong&gt; (no requests or limits at all): the kernel is told 1000. First in line, always.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Burstable&lt;/strong&gt; (anything in between): somewhere from 2 to 999, scaled from how big your memory request is compared to the node.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So two identical processes can have wildly different survival odds, purely because of the QoS class on the pod spec. Your BestEffort batch job is not unlucky. It volunteered.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually check
&lt;/h2&gt;

&lt;p&gt;When a container dies with &lt;code&gt;OOMKilled&lt;/code&gt; and the node looks healthy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the kernel log.&lt;/strong&gt; &lt;code&gt;dmesg | grep -i "out of memory"&lt;/code&gt; names the cgroup. If it is your container's cgroup, the limit was the ceiling, not the node.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare limit to reality.&lt;/strong&gt; &lt;code&gt;kubectl top pod&lt;/code&gt; shows actual usage. If usage was brushing the limit, the limit was too small, or the app leaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the QoS class.&lt;/strong&gt; &lt;code&gt;kubectl describe pod&lt;/code&gt; shows it. A BestEffort pod on a busy node is living on borrowed time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If it is Java, check the heap.&lt;/strong&gt; The classic trap: the JVM sizes its heap from the &lt;em&gt;node's&lt;/em&gt; memory unless told otherwise, then the container's cgroup fence kills it. Size the heap to fit inside the container limit, with room for the JVM's own overhead.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this requires new tools. The score, the knob, and the log are all plain files and one shell command. The killer is not mysterious once you know it scores against the fence, not the field.&lt;/p&gt;

&lt;p&gt;What is the strangest OOM kill you have ever chased? I want to hear the one where the numbers made no sense until they did.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>docker</category>
    </item>
    <item>
      <title>One zone lost power in London, and the banks went dark with it</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Wed, 21 Oct 2026 16:13:49 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/one-zone-lost-power-in-london-and-the-banks-went-dark-with-it-1efk</link>
      <guid>https://dev.to/kashif_manzer/one-zone-lost-power-in-london-and-the-banks-went-dark-with-it-1efk</guid>
      <description>&lt;h1&gt;
  
  
  Tuesday, just after 7am
&lt;/h1&gt;

&lt;p&gt;Tuesday morning in London. People tapped their bank cards at the till and the payment was declined. Revolut transfers froze mid-send. Lloyds and HSBC customers could not get into their apps. In courts across England and Wales, video hearings stalled because the courts' video platform was down. Royal Mail tracking went quiet, and delivery networks wobbled.&lt;/p&gt;

&lt;p&gt;None of these companies had a power cut. A Google Cloud data center zone did.&lt;/p&gt;

&lt;p&gt;Just after 7am London time on Tuesday, October 20, a power event knocked out europe-west2-b, one of three availability zones inside Google Cloud's London region. Google's own status text put it plainly: "A power event caused an outage within europe-west2-b zone. The power event is mitigated."&lt;/p&gt;

&lt;p&gt;What followed is the part worth studying. One zone, one of three, lost power. And banks, courts, and couriers across the country felt it.&lt;/p&gt;

&lt;p&gt;Google has said it will publish a full analysis once its internal investigation is finished. Until then, nobody outside Google knows the complete chain of events. What follows is what the status page and the press record show, plus the mechanics of what a zone failure actually breaks. If you run anything in the cloud, this is your blast radius lesson for the week.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Google said
&lt;/h2&gt;

&lt;p&gt;The timeline, from Google's status page and press reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Just after 7am London time (06:08 UTC), Tuesday Oct 20.&lt;/strong&gt; The power event hits zone europe-west2-b. Google: "Multiple services are experiencing issues in the europe-west2 region."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Through the morning.&lt;/strong&gt; "Google Cloud services are unavailable in europe-west2-b zone; some services in europe-west2 region may experience elevated errors and intermittent network issues." The incident page listed dozens of affected products: Compute Engine, Google Kubernetes Engine, Cloud Run, Cloud SQL, Cloud Storage, BigQuery, Pub/Sub, Memorystore, Persistent Disk, Cloud Load Balancing, Virtual Private Cloud, and more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Around midday.&lt;/strong&gt; Most services were back to normal by about 12:10pm London time, roughly six hours in. Full recovery of existing workloads was tentatively expected by early afternoon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late afternoon.&lt;/strong&gt; The Ministry of Justice confirmed the courts' Cloud Video Platform had still not fully recovered. Some wounds take longer to close.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The customer list reads like a tour of British daily life: Revolut, Lloyds, HSBC, Halifax, Barclays, Bank of Scotland, NatWest, Nationwide, credit-reference firm Equifax, Royal Mail, DPD, property site Rightmove. The Telegraph reported cards declined at tills. The Guardian reported courts disrupted and millions unable to move money.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a zone is, and what dies with it
&lt;/h2&gt;

&lt;p&gt;A quick glossary, because the rest of this piece depends on it.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;region&lt;/strong&gt; is a geographic area where a cloud provider operates, like London. Inside each region are &lt;strong&gt;availability zones&lt;/strong&gt;: separate buildings, or clusters of buildings, each with its own power feed, its own cooling, and its own network connections. The London region, europe-west2, has three: a, b, and c. The idea is simple. If one building has a bad day, the other two keep going.&lt;/p&gt;

&lt;p&gt;Your virtual machine lives in exactly one zone. Your disk lives in one zone too, with copies kept elsewhere for safety, but those copies are managed by the platform, not by you.&lt;/p&gt;

&lt;p&gt;So when the power goes in zone b, everything physically in zone b stops at once. Virtual machines halt instantly, with no graceful shutdown. Disks stop being reachable. Anything that had its database primary in zone b stops answering. There is no orderly wind-down. The lights go out and the machines stop.&lt;/p&gt;

&lt;p&gt;This is the part people nod along to and then get wrong in practice, because "we run in multiple zones" feels like a shield. It is a shield with holes. Here are the holes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the surviving zones did not just absorb it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Failover takes time.&lt;/strong&gt; Nothing in a distributed system switches over instantly. A database has to notice its primary is gone, hold an election, and promote a replica. A load balancer has to fail several health checks before it stops sending traffic to dead backends. DNS answers are cached, and each cached answer has a TTL, a countdown timer after which it expires. During every one of those windows, requests fail. Multi-zone does not mean zero-downtime. It means downtime measured in minutes instead of hours, if everything works as designed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your dependencies have zones too.&lt;/strong&gt; Your app can run happily in zone a and still die, because it calls something that lived in zone b. A database primary. A NAT gateway. A load balancer component. A queue. Google's own status text admitted the mechanism: services across the region saw "elevated errors and intermittent network issues" because of shared infrastructure dependencies. A zone is not a sealed box. The region shares plumbing, and the plumbing runs through every zone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You could not build replacements during the fire.&lt;/strong&gt; Read this line from the status page again: "Customers may not be able to yet create new workloads." The system that creates virtual machines and clusters, the &lt;strong&gt;control plane&lt;/strong&gt;, was itself degraded. Your running apps are the &lt;strong&gt;data plane&lt;/strong&gt;. The API that manages them is the control plane. They are different systems, and this event hurt both. The usual escape plan, spin up fresh capacity in the surviving zones, was partially locked during the exact hours people needed it. The fire escape was bolted during the fire.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two zones doing the work of three is not the same as three zones.&lt;/strong&gt; Failover plans quietly assume spare capacity. When a third of a region's machines vanish, the survivors absorb their load on top of their own. Everything gets slower, queues grow, and some things fail that would have survived a smaller shock. One engineer's account in the press said the peer zones did not fully absorb the load. Capacity is the quiet part of every failover plan, and it is the part nobody load-tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  The single-region trap
&lt;/h2&gt;

&lt;p&gt;Here is the structural fact that made this outage national news instead of a footnote: europe-west2 is Google's only UK region. Customers who had built everything in London, sensibly, to keep data close to users, had no second UK location to fail over to.&lt;/p&gt;

&lt;p&gt;Multi-zone protects you against one zone failing. Nothing inside a single region protects you against the region's shared systems failing, or against a zone failure cascading the way this one did through shared dependencies. Real protection is multi-region: running in London &lt;em&gt;and&lt;/em&gt; somewhere else, with a tested plan to shift traffic. That roughly doubles your bill and your complexity, which is why most teams do not have it. The banks that went dark on Tuesday are a reminder of what that savings costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  This happened before
&lt;/h2&gt;

&lt;p&gt;The London region has been here before. On July 19, 2022, during the hottest day ever recorded in the UK, cooling failed in a building hosting europe-west2-a. Google's engineers could not get temperatures down, so they deliberately powered down part of the zone to save the hardware. That decision terminated about 35 percent of the virtual machines in that zone.&lt;/p&gt;

&lt;p&gt;Different zone, different cause, same lesson. A zone is a unit of failure. It can go dark from heat, from power, from a bad config push, from a backhoe through a fiber bundle. The cause changes. The shape of the failure does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Audit your own blast radius
&lt;/h2&gt;

&lt;p&gt;"We run multi-AZ" is a claim. Here is how to check whether it is true.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Where do your VMs actually live? Not where you think. Where they are.&lt;/span&gt;
gcloud compute instances list &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"table(name,zone.basename())"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Are your Kubernetes nodes spread across zones, or quietly all in one?&lt;/span&gt;
kubectl get nodes &lt;span class="nt"&gt;-L&lt;/span&gt; topology.kubernetes.io/zone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask the questions no dashboard answers for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which zone holds your database primary right now? Your managed database console will tell you. Write it down.&lt;/li&gt;
&lt;li&gt;If that zone went dark, how long until a replica gets promoted, and have you ever watched it happen? If the answer is "the docs say it is automatic," you have not tested it.&lt;/li&gt;
&lt;li&gt;Which zone do your NAT gateways, load balancers, and file stores live in? These are the shared plumbing. They have zones too.&lt;/li&gt;
&lt;li&gt;During the last incident, could you have created new capacity? If your control plane calls were failing, your runbook's step one was fiction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cheapest version of this audit is a game day: pick a zone, pretend it is gone, and walk through what breaks. The 2022 incident's official workaround was "relaunch your workloads in another region." That advice only helps if you know, before the bad day, that you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question
&lt;/h2&gt;

&lt;p&gt;Draw your blast radius on paper. Pick the single thing, a zone, a database primary, a region, whose failure at 7am would ruin your day. If you cannot name it, that is the audit, and it is overdue.&lt;/p&gt;

&lt;p&gt;Do you know, right now, which zone your database primary lives in? When did you last check?&lt;/p&gt;

</description>
      <category>devops</category>
      <category>cloud</category>
      <category>backend</category>
      <category>googlecloud</category>
    </item>
    <item>
      <title>I kept approving Claude Code commands I didn't understand, so I built clear-caption</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Tue, 20 Oct 2026 21:18:54 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/i-kept-approving-claude-code-commands-i-didnt-understand-so-i-built-clear-caption-a64</link>
      <guid>https://dev.to/kashif_manzer/i-kept-approving-claude-code-commands-i-didnt-understand-so-i-built-clear-caption-a64</guid>
      <description>&lt;p&gt;I approve dozens of commands from Claude Code every day.&lt;/p&gt;

&lt;p&gt;Most I skim. Some I don't really understand, especially the long, complex ones.&lt;/p&gt;

&lt;p&gt;I click yes anyway and hope nothing breaks.&lt;/p&gt;

&lt;p&gt;Like everyone else.&lt;/p&gt;

&lt;p&gt;So I built clear-caption.&lt;/p&gt;

&lt;p&gt;Now, before anything runs, Claude tells me in plain English what it is about to do. A command, a file edit, doesn't matter. One quiet line above the call, in words I can actually read.&lt;/p&gt;

&lt;p&gt;The real command stays on screen, unchanged. Nothing gets hidden or rewritten. The caption just sits above it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like
&lt;/h2&gt;

&lt;p&gt;Before, a normal session looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;● Bash&lt;span class="o"&gt;(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.tmp"&lt;/span&gt; &lt;span class="nt"&gt;-exec&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\;&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

● Bash&lt;span class="o"&gt;(&lt;/span&gt;git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; HEAD&lt;span class="o"&gt;)&lt;/span&gt;

● Update&lt;span class="o"&gt;(&lt;/span&gt;math.js&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three calls. One deletes files by pattern, one throws away local work, one edits code for a reason you have to guess from the diff.&lt;/p&gt;

&lt;p&gt;With clear-caption, the same session looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;● clear-caption: Delete all .tmp files

● Bash(find . -name "*.tmp" -exec rm {} \;)

● clear-caption: ⚠ Throw away all local changes

● Bash(git reset --hard HEAD)

● clear-caption: Fix clamp: Math.min should cap at hi, not lo.

● Update(math.js)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole idea. Reviewing a step takes a glance instead of a decode.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets a caption
&lt;/h2&gt;

&lt;p&gt;Every shell command and every subagent call gets one. It is the call's own description, shown right above its row.&lt;/p&gt;

&lt;p&gt;Every file edit gets one too. Edit and Write have no description of their own, so the mod asks Claude to write one sentence on why the file is changing, and shows it above the diff.&lt;/p&gt;

&lt;p&gt;Subagents' own calls stay quiet. A busy subagent running twenty commands should not flood your screen with captions, so only the top level gets them.&lt;/p&gt;

&lt;p&gt;Captions are also their own lines, separate from the tool rows. If you use a skin or another mod that redraws those rows, the captions still show.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warning is the part I wanted most
&lt;/h2&gt;

&lt;p&gt;Some commands can wipe out your work in one keystroke. Those get a ⚠ in the caption, even when the description sounds harmless.&lt;/p&gt;

&lt;p&gt;The warning is based on the real command, not on what Claude says about it. It triggers on things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recursive deletes (&lt;code&gt;rm -r&lt;/code&gt;) outside temp folders&lt;/li&gt;
&lt;li&gt;Commands that throw away git work, like &lt;code&gt;git reset --hard&lt;/code&gt;, &lt;code&gt;git clean -f&lt;/code&gt;, &lt;code&gt;git branch -D&lt;/code&gt;, or dropping and clearing stashes&lt;/li&gt;
&lt;li&gt;Force pushes&lt;/li&gt;
&lt;li&gt;Deletes by search (&lt;code&gt;find ... -delete&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sudo&lt;/code&gt;, piping &lt;code&gt;curl&lt;/code&gt; into a shell, or &lt;code&gt;chmod -R&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deleting files only under &lt;code&gt;/tmp&lt;/code&gt; or a scratchpad folder is not flagged. Cleanup in a temp folder is normal work, not a risk.&lt;/p&gt;

&lt;p&gt;It is a short list of common spellings, matched as patterns. It is not a guarantee, and I won't pretend otherwise. It catches the stuff people actually type, which is where the accidents live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn it up or down
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/caption all     every caption (default)
/caption risky   only ⚠ captions
/caption off     no captions
/caption         show the current setting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your choice is saved, so it carries across sessions.&lt;/p&gt;

&lt;p&gt;If you only want the safety net and none of the narration, &lt;code&gt;/caption risky&lt;/code&gt; is a good place to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limits
&lt;/h2&gt;

&lt;p&gt;The caption is Claude's own words. It can be vague, and it can be wrong. That is exactly why the real command and the real diff always stay on screen. The caption is a reading aid, not a replacement for the call itself.&lt;/p&gt;

&lt;p&gt;On testing: edit captions landed on 57 of 57 edits across Opus, Sonnet, and Haiku. The average cost on the Opus test tasks was the same with captions on or off, for about 3% more output tokens.&lt;/p&gt;

&lt;p&gt;It is built on Claude Code's mod hooks, which are early access. Hooks can change between releases, so things may shift. It is tested on Claude Code 2.1.296.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works, briefly
&lt;/h2&gt;

&lt;p&gt;Two hooks do the work. One asks Claude for a one-line description on Edit and Write calls, then strips that description out before the call is checked, so the edit itself runs exactly as it would have. The other holds each call until its arguments arrive, logs the caption, and passes the call on unchanged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;Paste this at the Claude Code prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/plugin install clear-caption --marketplace KashifManzer/clear-caption
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Answer &lt;code&gt;y&lt;/code&gt; to add the marketplace, pick a scope, and you are done.&lt;/p&gt;

&lt;p&gt;It is free and open source under the MIT license. The repo is here: &lt;a href="https://github.com/KashifManzer/clear-caption" rel="noopener noreferrer"&gt;https://github.com/KashifManzer/clear-caption&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If it saves you a squint, a star helps other people find it. Bug reports, ideas, and pull requests are all welcome too.&lt;/p&gt;

&lt;p&gt;One question before you go.&lt;/p&gt;

&lt;p&gt;Have you ever approved something you didn't fully understand?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opensource</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <item>
      <title>How a DNS race condition took DynamoDB offline in us-east-1</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Mon, 19 Oct 2026 16:12:33 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/how-a-dns-race-condition-took-dynamodb-offline-in-us-east-1-16jg</link>
      <guid>https://dev.to/kashif_manzer/how-a-dns-race-condition-took-dynamodb-offline-in-us-east-1-16jg</guid>
      <description>&lt;h1&gt;
  
  
  How a DNS race condition took DynamoDB offline in us-east-1
&lt;/h1&gt;

&lt;p&gt;Sunday night, 11:48 PM Pacific. One of the largest databases on Earth is running perfectly. Every server is up. Every disk is fine. And nobody can reach it, because its name no longer has an address.&lt;/p&gt;

&lt;p&gt;This happened in October 2025. DynamoDB in us-east-1 did not crash, did not slow down, did not lose data. It vanished from DNS. Every system that tried to connect, customer apps and AWS's own internal services alike, got the same answer: the name &lt;code&gt;dynamodb.us-east-1.amazonaws.com&lt;/code&gt; resolves to nothing.&lt;/p&gt;

&lt;p&gt;AWS later published a full summary of the event, and it is one of the most instructive postmortems I have ever read. Not because the bug was exotic, but because it shows how the cloud actually holds together: layers of automation, each depending on the layer below, until one quiet race condition pulls the thread and half the region unravels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a database needs hundreds of thousands of DNS records
&lt;/h2&gt;

&lt;p&gt;First, the part that surprised me. DynamoDB maintains hundreds of thousands of DNS records. Not as a directory for humans. As a scaling primitive.&lt;/p&gt;

&lt;p&gt;DynamoDB runs a very large, heterogeneous fleet of load balancers in every region. When capacity is added, when hardware fails, when traffic needs to shift, the way the service steers clients is DNS. Automation constantly rewrites those records: add the new load balancers, drop the dead ones, rebalance the weights. DNS is not the phone book here. It is the steering wheel.&lt;/p&gt;

&lt;p&gt;That automation is split into two independent components, deliberately, for availability. The first is the DNS Planner. It watches the health and capacity of the load balancers and periodically produces a new DNS plan for each endpoint: a set of load balancers plus weights. One regional plan is shared across endpoint variants, the public regional endpoint, a FIPS endpoint, an IPv6 endpoint, account-specific endpoints, which keeps capacity management simple.&lt;/p&gt;

&lt;p&gt;The second component is the DNS Enactor. It takes plans and applies them as changes in Route 53. It is designed to have minimal dependencies so it can recover the system in any scenario, and it runs redundantly and fully independently in three availability zones. Each Enactor picks up new plans and applies them using Route 53 transactions, so an endpoint always gets a consistent plan even when several Enactors are working at once.&lt;/p&gt;

&lt;p&gt;Before an Enactor starts applying a plan, it does one check: is my plan newer than the one already applied? One check, at the start. Remember that detail. It is the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The race
&lt;/h2&gt;

&lt;p&gt;Right before the outage, one Enactor hit unusually high delays. It kept getting blocked trying to apply its plan to several endpoints and had to retry, slowly working its way through the list.&lt;/p&gt;

&lt;p&gt;While it was stuck, two things happened. The Planner kept running and produced many newer generations of plans. Then a second Enactor picked up one of the newer plans and applied it rapidly across all endpoints.&lt;/p&gt;

&lt;p&gt;When the second Enactor finished, it ran the plan clean-up process. Clean-up deletes plans that are significantly older than the one just applied. Housekeeping. Reasonable.&lt;/p&gt;

&lt;p&gt;But at that exact moment, the first, delayed Enactor finally applied its much older plan to the regional DynamoDB endpoint, overwriting the newer plan. The freshness check it had done at the start was stale by now, after all those delays, so nothing stopped it.&lt;/p&gt;

&lt;p&gt;Then the second Enactor's clean-up ran and deleted that older plan, because it was many generations behind the plan it had just applied. Deleting the plan removed every IP address for the regional endpoint. And because the plan that was deleted was the active one, the system was left in an inconsistent state where no subsequent plan update could be applied by any Enactor. The automation that was supposed to repair itself could not. A human had to step in.&lt;/p&gt;

&lt;p&gt;From the outside, this is what it looked like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;
&lt;span class="c1"&gt;# What every client, human or machine, effectively saw that night:
&lt;/span&gt;&lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getaddrinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dynamodb.us-east-1.amazonaws.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# socket.gaierror: [Errno -2] Name or service not known
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not a timeout. Not a 500. The name did not resolve. The database was right there, healthy, unreachable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then AWS forgot how to launch servers
&lt;/h2&gt;

&lt;p&gt;Here is where it gets properly strange. The DynamoDB DNS failure was fixed by 2:25 AM. But EC2 could not launch new instances until 1:50 PM, more than fourteen hours after the first failure. Why? Because inside AWS, EC2's own machinery depends on DynamoDB.&lt;/p&gt;

&lt;p&gt;Deep inside EC2 there is a subsystem called the DropletWorkflow Manager. It manages the physical servers, which AWS calls droplets, that host EC2 instances. Each manager holds a lease on the droplets it manages, renewed by state checks every few minutes. Those state checks depend on DynamoDB.&lt;/p&gt;

&lt;p&gt;At 11:48 PM, the checks started failing. Running instances were fine, but over the next two and a half hours, leases across the fleet quietly timed out. And a droplet without an active lease is not considered a candidate for new launches. So EC2 started refusing new instance launches with "insufficient capacity" errors. There was plenty of capacity. The system just could not prove to itself that any server was allowed to take work.&lt;/p&gt;

&lt;p&gt;When DynamoDB recovered at 2:25 AM, the lease manager tried to re-establish leases across the entire fleet at once. Too much work arrived too fast, the queues choked, and the work kept timing out before it could complete. The system had entered what AWS called congestive collapse: too busy failing to recover to actually recover. Engineers throttled incoming work and selectively restarted the lease manager hosts at 4:14 AM. By 5:28 AM, every droplet had a lease again.&lt;/p&gt;

&lt;p&gt;But new instances still had no network. A second subsystem, Network Manager, propagates network configuration to new instances, and it was sitting on a huge backlog of delayed state changes. Propagation times did not return to normal until 10:36 AM. Full EC2 recovery landed at 1:50 PM.&lt;/p&gt;

&lt;p&gt;The blast radius kept widening from there. Lambda function creation and updates broke, and an internal SQS polling subsystem failed and had to be restored by hand at 4:40 AM. ECS, EKS, and Fargate could not launch containers or scale clusters until 2:20 PM. Amazon Connect callers heard busy tones and dead air. IAM user sign-ins to the console failed. Redshift query processing, which reads and writes through DynamoDB endpoints, broke, and because Redshift resolves IAM user groups through an IAM API pinned to us-east-1, Redshift customers in every region lost the ability to run queries with IAM credentials for over an hour.&lt;/p&gt;

&lt;p&gt;My favorite detail: the AWS Support Center failed over to another region exactly as designed, and still blocked legitimate users. The failover bypass was built to kick in when the account metadata subsystem returned unsuccessful responses. That night it returned invalid responses instead. The designers had predicted one failure mode. Reality chose the adjacent one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the fix
&lt;/h2&gt;

&lt;p&gt;AWS disabled the DNS Planner and Enactor automation worldwide and said it would fix the race condition and add protections before turning it back on. The protections are the interesting part for the rest of us, because the pattern applies to any system that applies versioned plans. In sketch form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_dns_plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;route53&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# The freshness check must be re-done at apply time,
&lt;/span&gt;    &lt;span class="c1"&gt;# not once at the start of a slow walk over endpoints.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_generation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip: stale plan&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;route53&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_generation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generation&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cleanup_old_plans&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Never delete the plan that is currently serving traffic,
&lt;/span&gt;        &lt;span class="c1"&gt;# no matter how old it looks.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_generation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generation&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active_generation&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;KEEP_GENERATIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two guards. Never apply a plan older than what is live, checked at the moment of applying. Never delete the plan that is live. Either guard alone would have prevented the outage. The original system had a version of the first guard, but it checked once at the beginning of a long, retried operation, and time made liars of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I take from this
&lt;/h2&gt;

&lt;p&gt;Three things stuck with me.&lt;/p&gt;

&lt;p&gt;First, your dependencies have dependencies. Nobody who launched an EC2 instance that night thought about DynamoDB DNS records. Nobody running Redshift in another region thought their queries depended on an IAM API in Virginia. The dependency graph of a real system is always deeper than the diagram on the wall.&lt;/p&gt;

&lt;p&gt;Second, automation that cannot repair itself is automation with a single point of failure wearing a costume. The DNS system was designed for resilience against many operational issues, and it met an issue that left it in a state its own recovery logic could not process. If your self-healing has states it cannot handle, you do not have self-healing. You have a pager with extra steps.&lt;/p&gt;

&lt;p&gt;Third, error messages lie during cascades. "Insufficient capacity" meant "we lost our leases." Health checks failed on healthy machines because network state had not caught up. When everything is failing at once, read the errors as symptoms, not diagnoses.&lt;/p&gt;

&lt;p&gt;AWS's summary is public and worth reading in full if you run anything on the cloud. It is a rare, honest look at the machinery underneath the dashboard.&lt;/p&gt;

&lt;p&gt;What is the deepest hidden dependency you have found in your own stack, the one that would surprise everyone on your team?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: AWS "Summary of the Amazon DynamoDB Service Disruption in the Northern Virginia (US-EAST-1) Region" (aws.amazon.com/message/101925/); press coverage at the time listed Venmo, Snapchat, Fortnite, and Amazon's own Alexa among affected services.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>dns</category>
      <category>devops</category>
      <category>backend</category>
    </item>
    <item>
      <title>Your pipeline failed but your YAML was fine: how a Gitaly timeout broke GitLab CI on Friday</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Sun, 18 Oct 2026 16:10:56 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/your-pipeline-failed-but-your-yaml-was-fine-how-a-gitaly-timeout-broke-gitlab-ci-on-friday-24gb</link>
      <guid>https://dev.to/kashif_manzer/your-pipeline-failed-but-your-yaml-was-fine-how-a-gitaly-timeout-broke-gitlab-ci-on-friday-24gb</guid>
      <description>&lt;h1&gt;
  
  
  Your pipeline failed but your YAML was fine: how a Gitaly timeout broke GitLab CI on Friday
&lt;/h1&gt;

&lt;p&gt;Friday afternoon. You push a commit, the pipeline kicks off, and instead of green checks you get this:&lt;/p&gt;

&lt;p&gt;"CI configuration fetch from Gitaly timed out. This may indicate Gitaly service slowness or an outage."&lt;/p&gt;

&lt;p&gt;Your first instinct is to open your &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; and hunt for the typo. Stop. There is no typo. Your config was never read at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happened
&lt;/h2&gt;

&lt;p&gt;On October 16, GitLab.com had a rough afternoon. At 11:33 AM UTC, GitLab posted that CI/CD was failing for some customers with exactly that error, and that the UI was slow too. They applied a temporary mitigation at 12:28 PM UTC and marked it resolved at 1:21 PM UTC. One hour and forty-eight minutes.&lt;/p&gt;

&lt;p&gt;Then, later the same day, a second incident: 503s across the website, the API, Git operations, both registries, Pages, and the hosted runners on Linux, Windows, and macOS, from 4:10 PM UTC to 5:24 PM UTC.&lt;/p&gt;

&lt;p&gt;Two incidents, one afternoon. The first one is the interesting one, because the error message tells you exactly where the system is coupled.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why creating a pipeline reads your repo
&lt;/h2&gt;

&lt;p&gt;Here is the part most people never think about. Your &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; lives in your repository. When you push and a pipeline is created, GitLab has to go read your config out of the repo, merge it with any includes, and only then can it build the list of jobs. There is no warm copy sitting next to the scheduler. Every pipeline starts with a read.&lt;/p&gt;

&lt;p&gt;All Git reads in GitLab go through a service called Gitaly. Think of it as the front desk for every repository: instead of each part of GitLab opening raw Git data directly, they call Gitaly over RPC and Gitaly does the reading.&lt;/p&gt;

&lt;p&gt;So the chain for every single pipeline looks like this:&lt;/p&gt;

&lt;p&gt;push -&amp;gt; pipeline created -&amp;gt; fetch config from the repo via Gitaly -&amp;gt; parse and merge -&amp;gt; schedule jobs&lt;/p&gt;

&lt;p&gt;That fetch step is synchronous. It sits on the critical path, and it has a timeout. When Gitaly got slow on Friday, the fetch hit the timeout, and pipeline creation failed before a single job was scheduled. Nothing ran. Nothing could run. The config was fine; the reader was sick.&lt;/p&gt;

&lt;p&gt;That is also why the UI was slow at the same time. The same Gitaly layer serves the reads behind project pages, file views, and merge requests. One slow layer, many symptoms.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to read this error
&lt;/h2&gt;

&lt;p&gt;This is the practical skill the incident teaches. Not every red pipeline means the same thing, and the fix depends on which kind of red you got.&lt;/p&gt;

&lt;p&gt;If your YAML has a syntax error, GitLab tells you your YAML has a syntax error. It names the file and the line. That is a config problem, and the fix is in your editor.&lt;/p&gt;

&lt;p&gt;"CI configuration fetch from Gitaly timed out" is a different sentence. It says: I could not fetch. The failure is in the reading, not the writing. Debugging your YAML against this error is like proofreading a letter the post office lost.&lt;/p&gt;

&lt;p&gt;So the first move is not your editor. It is the status page. If GitLab is having a Gitaly moment, no amount of YAML polishing will turn that pipeline green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do while you wait
&lt;/h2&gt;

&lt;p&gt;You still want that pipeline to run. The instinct is to hammer the retry button, or re-push the same commit five times. That instinct makes the problem worse.&lt;/p&gt;

&lt;p&gt;Every retry is another config fetch through the same struggling layer. Thousands of developers retrying at once is a thundering herd aimed directly at the thing that is already down. (I wrote about why retries need jitter a couple of weeks ago: &lt;a href="https://dev.to/kashif_manzer/why-your-retries-need-jitter-the-thundering-herd-explained-3eje"&gt;https://dev.to/kashif_manzer/why-your-retries-need-jitter-the-thundering-herd-explained-3eje&lt;/a&gt;. This is exactly the scenario it is for.)&lt;/p&gt;

&lt;p&gt;The calmer version: retry with backoff, and let the error message decide whether retrying even makes sense. Here is a small wrapper I keep around for triggering pipelines from scripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://gitlab.com/api/v4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRIVATE-TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-token-here&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;main&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;  &lt;span class="c1"&gt;# seconds
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tries&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/projects/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pipeline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ref&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;# Only retry fetch-side timeouts. Anything else fails fast.
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gitaly&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;tries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempt &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: config fetch timed out, backing off &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The logic is simple. If the failure mentions the config fetch timing out, wait and try again with a growing, jittered delay. If it fails for any other reason, fail fast, because retrying a genuinely broken config is just burning API calls.&lt;/p&gt;

&lt;p&gt;Two more habits that pay off during incidents like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check the status page before you touch your code. Five seconds on the GitLab status page saves twenty minutes of phantom debugging.&lt;/li&gt;
&lt;li&gt;Keep your includes lean. Every include is another file GitLab has to fetch and merge before your pipeline exists. A config that pulls in six included files gives the fetch step six files to read before a single job is scheduled.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The design lesson
&lt;/h2&gt;

&lt;p&gt;Zoom out and the incident is a textbook coupling story. A config file feels static, like something the system should just know. But GitLab treats it as dynamic: fetched fresh on every pipeline, through a network service, on the critical path, with a timeout.&lt;/p&gt;

&lt;p&gt;Dynamic fetching buys you real things. Edit the YAML, push, and the next pipeline picks it up with no cache to invalidate and no stale config to chase. The price is a dependency: your pipeline cannot exist faster than the slowest read in its setup path.&lt;/p&gt;

&lt;p&gt;Every system has a version of this. A service that reads its feature flags from a remote store on every request. A deploy that pulls its manifest from an API before it starts. The question is always the same: is this read on the critical path, and what happens when it is slow instead of down? Slow is the cruel one. Down fails fast. Slow burns through timeouts, holds connections open, and takes the UI down with it.&lt;/p&gt;

&lt;p&gt;Friday's error message was honest about all of this. It named the layer, named the timeout, and told you it might be slowness rather than an outage. Most error messages are not that generous. When one is, believe it.&lt;/p&gt;

&lt;p&gt;What is the fetch-on-the-critical-path in your own system, the read everything quietly depends on? Have you ever watched it get slow?&lt;/p&gt;

</description>
      <category>gitlab</category>
      <category>ci</category>
      <category>devops</category>
      <category>backend</category>
    </item>
    <item>
      <title>How the DNS root rotated its master key on October 11 without breaking the internet</title>
      <dc:creator>Kashif Manzer</dc:creator>
      <pubDate>Fri, 16 Oct 2026 16:19:58 +0000</pubDate>
      <link>https://dev.to/kashif_manzer/how-the-dns-root-rotated-its-master-key-on-october-11-without-breaking-the-internet-pm7</link>
      <guid>https://dev.to/kashif_manzer/how-the-dns-root-rotated-its-master-key-on-october-11-without-breaking-the-internet-pm7</guid>
      <description>&lt;h1&gt;
  
  
  How the DNS root rotated its master key on October 11 without breaking the internet
&lt;/h1&gt;

&lt;p&gt;On October 11, 2026, the most trusted key on the internet changed hands. No deploy went out. No status page turned red. Nobody got paged. The DNS root zone simply stopped signing its list of keys with KSK-2017 and started signing with KSK-2024.&lt;/p&gt;

&lt;p&gt;It was only the second time this has ever happened. The first was in 2018.&lt;/p&gt;

&lt;p&gt;In Malaysia that weekend, some websites stopped loading for users on Maxis DNS. Local press tied the reports to the rollover. Maxis has not confirmed a network-wide cause, so treat that link as reported, not proven. But the failure shape fits exactly, and the shape is the interesting part: when a resolver misses this kind of change, nothing looks broken except everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one key you cannot look up
&lt;/h2&gt;

&lt;p&gt;DNSSEC lets a resolver check digital signatures on DNS answers, so it knows the answer is authentic and unmodified. Trust forms a chain. To believe &lt;code&gt;example.com&lt;/code&gt;'s keys, your resolver checks the signature from &lt;code&gt;.com&lt;/code&gt;. To believe &lt;code&gt;.com&lt;/code&gt;'s keys, it checks the signature from the root. Each parent vouches for its child with a DS record, which is just a fingerprint of the child's key.&lt;/p&gt;

&lt;p&gt;The root has no parent. So every validating resolver ships with one key it believes without proof: the root key-signing key, kept as a trust anchor in its software or configuration.&lt;/p&gt;

&lt;p&gt;The division of labor is neat. The KSK signs only one thing: the root zone's DNSKEY record set, which is the published list of the root's public keys. That list contains the zone-signing key, the ZSK. The ZSK signs everything else, including the DS records for top-level domains like &lt;code&gt;.com&lt;/code&gt;. So the KSK is the anchor for the anchor list. Lose it, and the chain has no starting point. Not one domain fails. Every domain fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Changing a lock while the building is full
&lt;/h2&gt;

&lt;p&gt;You cannot email every resolver operator on earth and ask them to update a config on the same Sunday. Firewalls, home routers, containers, and appliances run resolvers whose owners never chose their DNS behavior and rarely check it. So ICANN ran the rollover in slow motion. The full schedule, from IANA's trust anchor page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;October 27, 2016:&lt;/strong&gt; KSK-2017 generated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;October 11, 2018:&lt;/strong&gt; KSK-2017 takes over signing the root. First rollover ever done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;April 27, 2023:&lt;/strong&gt; KSK-2023 generated. Then abandoned, superseded before it ever signed anything. Someone made a master key for the internet and threw it away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;April 26, 2024:&lt;/strong&gt; KSK-2024 generated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;January 11, 2025:&lt;/strong&gt; KSK-2024 published in the root DNSKEY set, alongside KSK-2017. Nothing trusts it yet. It just sits there, its presence signed by the old key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;February 10, 2025:&lt;/strong&gt; resolvers following RFC 5011 can finish adopting it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;October 11, 2026:&lt;/strong&gt; the switch. KSK-2024 signs the DNSKEY set. KSK-2017 stops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;January 11, 2027:&lt;/strong&gt; KSK-2017 published with the REVOKE bit set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;March 22, 2027:&lt;/strong&gt; KSK-2017 removed from the root zone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The quiet hero here is RFC 5011, automated trust-anchor updates. Your resolver watches the root DNSKEY set. One day it notices a new key in there, and the new key's presence is signed by the old key it already trusts. It does not trust it immediately. It waits. For 30 days it must keep seeing the new key, consistently, in normally signed responses. Only then does it promote the key to a trust anchor on its own. No config push, no restart, no human.&lt;/p&gt;

&lt;p&gt;The 30-day hold-down is the whole security model in one number. It exists so a brief spoof or glitch cannot plant a fake anchor. But read the assumption hiding inside it: the resolver must be up and querying regularly through the transition. A validator powered off for a month, a VM snapshot resumed after the window, a container image that restores stale state on every deploy, a trust-anchor file on a read-only filesystem where updates fail silently. Any of them can still trust only KSK-2017 on the morning of the switch. Automation is not magic. It is a scheduled job with preconditions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question you are not allowed to ask
&lt;/h2&gt;

&lt;p&gt;This is my favorite part of the story. Suppose you run a fleet of resolvers and want to know which ones trust the new key. There is no DNS query for "list your trust anchors." So RFC 8509 encodes the question inside a domain name.&lt;/p&gt;

&lt;p&gt;Try these two lookups:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dig root-key-sentinel-is-ta-38696.dnstest.dev
dig root-key-sentinel-not-ta-38696.dnstest.dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The authoritative server answers both names. But a validating resolver that trusts key tag 38696 will fail validation on the second name and return SERVFAIL to you. The resolver's own trust state leaks out through which answers survive. It is an interrogation disguised as a lookup.&lt;/p&gt;

&lt;p&gt;How to read your result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;is-ta&lt;/code&gt; answers, &lt;code&gt;not-ta&lt;/code&gt; returns SERVFAIL: your resolver trusts KSK-2024. You are fine.&lt;/li&gt;
&lt;li&gt;Both names answer: inconclusive. Your resolver either does not validate DNSSEC or does not speak the sentinel protocol. Most home setups land here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;is-ta&lt;/code&gt; returns SERVFAIL: your resolver validates but does not trust the new key. Fix it before January, when the old key gets revoked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What "not ready" looks like
&lt;/h2&gt;

&lt;p&gt;It does not look like an error page. A validating resolver that only trusts KSK-2017 can no longer verify the root's key list, which means it cannot verify anything below it. Every lookup returns SERVFAIL. The sites are up. Your connection is fine. The names just stop resolving, all of them, at once.&lt;/p&gt;

&lt;p&gt;That is why the scattered reports after October 11 are worth noting even though none of them is a confirmed smoking gun. Users in Malaysia reported &lt;code&gt;.my&lt;/code&gt; sites failing to load on Maxis DNS between October 11 and 13. Router vendor forums had users describing lookups failing with DNSSEC enabled and recovering the moment they switched it off. No broad outage materialized, which is the good news. The bad news is the shape: this failure is silent and total, and it lands on exactly the devices nobody inventories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the root yourself
&lt;/h2&gt;

&lt;p&gt;I pulled the live root DNSKEY set today, October 16, and computed the key tags from it. Five days after the rollover, three keys are published:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;8763&lt;/strong&gt;, the zone-signing key, doing the daily signing work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;20326&lt;/strong&gt;, KSK-2017: still published, no longer signing, revocation scheduled for January 2027.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;38696&lt;/strong&gt;, KSK-2024: the new signer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can do the same check from any machine with Python. DNS-over-HTTPS avoids depending on your local resolver's mood, and the key tag follows RFC 4034's recipe. One detail that bit me: the tag is computed over the full DNSKEY RDATA in wire format, not just the key bytes. My first attempt hashed only the public key and produced nonsense tags. Adding the four header bytes (flags, protocol, algorithm) fixed it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://cloudflare-dns.com/dns-query?name=.&amp;amp;type=DNSKEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/dns-json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;key_tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rdata&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rdata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;wire&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;struct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!HBB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;algo&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;wire&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;byte&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wire&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;byte&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ans&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ans&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ZSK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KSK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  key tag &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;key_tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ans&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real output, October 16, 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ZSK  key tag 8763
KSK  key tag 20326
KSK  key tag 38696
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The numbers going in
&lt;/h2&gt;

&lt;p&gt;The headline telemetry looked strong. ICANN reported that more than 95 percent of resolvers signaling their trust anchors had adopted KSK-2024. Verisign's measurements in March 2025 put adoption at 91.3 percent of monitored resolvers.&lt;/p&gt;

&lt;p&gt;Two numbers complicate the picture. About 3.5 percent of reporting resolvers still carried KSK-2010, a key revoked back in 2019. Seven years after its funeral, a dead master key is still sitting in trust stores on real machines. That is how long stale trust state survives.&lt;/p&gt;

&lt;p&gt;And APNIC's Geoff Huston measured from the user side rather than the root side. In April 2026, his sentinel-based tests found under 20 percent of users behind DNSSEC-validating resolvers showing KSK-2024 as trusted, which he called, quote, "certainly not the result that we had anticipated." Different populations, different methods, noisy data. But it is a reminder that the view from the root and the reality at the edge can disagree, and the edge is where your users live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual lesson
&lt;/h2&gt;

&lt;p&gt;Strip away the cryptography and the root KSK rollover is a distributed state problem. One organization publishes a key. Millions of independently operated resolvers have to observe it, keep that observation across restarts, upgrades, snapshots, and config management, and then the entire world switches which key it depends on. There is no central dashboard that can say everyone is ready, because DNS does not work that way. The 21-month runway, the 30-day hold-down, the sentinel interrogation protocol, the staged revocation: every piece of the design is an admission that trust, at internet scale, is a coordination problem wearing a cryptography costume.&lt;/p&gt;

&lt;p&gt;Run the two sentinel queries against your own resolver and post what you got. I am genuinely curious about the ratio of clean passes to inconclusives out there.&lt;/p&gt;

</description>
      <category>dns</category>
      <category>security</category>
      <category>networking</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
