<?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: Harish</title>
    <description>The latest articles on DEV Community by Harish (@harishteens).</description>
    <link>https://dev.to/harishteens</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%2F230396%2F001618ad-3dd5-4853-ac33-94df16cc24e2.jpeg</url>
      <title>DEV Community: Harish</title>
      <link>https://dev.to/harishteens</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/harishteens"/>
    <language>en</language>
    <item>
      <title>Coding is only a part of Engineering!</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Sun, 06 Sep 2026 16:48:59 +0000</pubDate>
      <link>https://dev.to/harishteens/coding-is-only-a-part-of-engineering-1ifi</link>
      <guid>https://dev.to/harishteens/coding-is-only-a-part-of-engineering-1ifi</guid>
      <description>&lt;p&gt;&lt;em&gt;How setting up a local test bench uncovered a proxy tier that had never worked, a dashboard that couldn't show it, and a secret-sync failure mode that nobody was watching.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  It started with a browser that wouldn't connect
&lt;/h2&gt;

&lt;p&gt;I was setting up CrawlGym — our local bench for replaying crawls against recorded HTML, so we can test hypotheses and measure regressions without hammering real sites. Everything worked&lt;br&gt;
except the browser tier: every escalation to LightPanda failed to connect.&lt;/p&gt;

&lt;p&gt;So I went to check whether production had the same problem. It didn't. It had a worse one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Production was doing something worse
&lt;/h2&gt;

&lt;p&gt;The datacenter LightPanda sidecar was logging this on every single page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$scope=frame     $level=error $msg="navigate failed" err=CouldntResolveProxy
$scope=telemetry $level=warn  $msg="postEvents"      err=CouldntResolveProxy events=4 dropped=133
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not some pages. Every page — including LightPanda's own telemetry, which has nothing to do with our crawler. Whatever was broken was broken for every outbound request that container made.&lt;/p&gt;

&lt;p&gt;The numbers for &lt;code&gt;proxy_browser_request_total{proxy_type="datacenter"}&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;window&lt;/th&gt;
&lt;th&gt;success&lt;/th&gt;
&lt;th&gt;fail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;67,083&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;793,810&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero successes across the entire metric retention window. This wasn't a regression. Tier 2's browser path had never worked, not once, since the metric existed.&lt;/p&gt;

&lt;p&gt;Meanwhile tier 1 was healthy — 250,557 successful renders in the same 7 days. So it wasn't the browser, the image, or the network. It was specific to the datacenter tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  One secret, two consumers, one of them broken
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CURLE_COULDNT_RESOLVE_PROXY&lt;/code&gt; means libcurl couldn't resolve the proxy hostname — a failure &lt;em&gt;before&lt;/em&gt; any socket opens. But the same pod's Go process was resolving that exact host fine and getting HTTP 200s through it. Same network namespace, same &lt;code&gt;/etc/resolv.conf&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The two processes weren't being handed the same string.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;PROXY_DATACENTER_URL&lt;/code&gt; in Vault ended with a trailing &lt;code&gt;\n&lt;/code&gt;. One byte, and it broke exactly one of the two consumers, because they read the variable by completely different routes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;consumer&lt;/th&gt;
&lt;th&gt;path the value travels&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Go crawler&lt;/td&gt;
&lt;td&gt;env → &lt;code&gt;envsubst&lt;/code&gt; into &lt;code&gt;config.yaml&lt;/code&gt; &lt;strong&gt;text&lt;/strong&gt; → YAML parse&lt;/td&gt;
&lt;td&gt;newline absorbed as end-of-scalar → clean URL, proxy works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LightPanda&lt;/td&gt;
&lt;td&gt;k8s &lt;code&gt;$(VAR)&lt;/code&gt; → &lt;strong&gt;argv&lt;/strong&gt;, no YAML anywhere&lt;/td&gt;
&lt;td&gt;newline preserved → host &lt;code&gt;proxy.example.net:8080\n&lt;/code&gt; → unresolvable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That YAML absorption is the whole reason this survived for months. After substitution the config line becomes a clean scalar followed by a harmless comment line. The Go side parses fine, logs &lt;code&gt;datacenter_http:true&lt;/code&gt; at startup, and pushes real traffic through the proxy. No error, no warning, nothing to find.&lt;/p&gt;

&lt;p&gt;I reproduced it against the exact production image to confirm which input shape produced which error:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;
&lt;code&gt;--http-proxy&lt;/code&gt; value&lt;/th&gt;
&lt;th&gt;error&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;valid host, with or without credentials&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CouldntConnect&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;scheme-less with credentials&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CouldntConnect&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;trailing newline&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;CouldntResolveProxy&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nonexistent host&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CouldntResolveProxy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;literal unexpanded &lt;code&gt;$(VAR)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CouldntResolveProxy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;LightPanda resolves proxy hostnames perfectly well. It just can't resolve one with a line break glued to the end.&lt;/p&gt;

&lt;p&gt;The cost: every page needing a render that tier 1 couldn't get escalated to datacenter, failed 100% of the time, and fell through to the web unlocker at &lt;strong&gt;72× the cost weight&lt;/strong&gt;. In one week,&lt;br&gt;
&lt;strong&gt;112,228 forced escalations&lt;/strong&gt; to the expensive tier — all downstream of a tier that structurally could not succeed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe9xdd1f167nvhvhk6v8z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe9xdd1f167nvhvhk6v8z.png" alt="Three proxy tiers. Tier 1 direct is healthy at 250,557 renders. Tier 2 datacenter is boxed in red dashes: 0 successes against 67,083 failures, structurally impossible. Everything falls through to tier 3, the web unlocker, at 72x the cost weight — 112,228 forced escalations in one week." width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Why the dashboard showed nothing
&lt;/h2&gt;

&lt;p&gt;We have a Proxy Observability dashboard with a panel literally titled &lt;em&gt;"Tier 2 (Datacenter) requests: success vs failure"&lt;/em&gt;. It looked fine.&lt;/p&gt;

&lt;p&gt;It queried &lt;code&gt;proxy_request_total&lt;/code&gt; — the HTTP fast path. Only that. The browser path, &lt;code&gt;proxy_browser_request_total&lt;/code&gt;, wasn't on the panel at all.&lt;/p&gt;

&lt;p&gt;So a tier where the HTTP half worked and the browser half was 100% dead rendered as a healthy green line. The panel wasn't wrong, it was &lt;em&gt;incomplete&lt;/em&gt; — and incomplete in exactly the dimension where the failure lived. Its description even claimed the tier "has recorded no direct successes historically", which was stale and pointed attention away from the real gap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5k48mcxfomsbqlvfuri.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5k48mcxfomsbqlvfuri.png" alt="Two panels side by side. The one we had plots only the HTTP path: a single healthy line near 250k. The one we needed plots both: the same healthy HTTP line, plus a browser series pinned flat at zero for the full 90 days." width="799" height="345"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix was two queries instead of one, with legends that can't blend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A:  sum by (failure_category) (rate(proxy_request_total{...proxy_type="datacenter"}[$__rate_interval]))*3600
    legend: HTTP · {{failure_category}}

B:  sum by (outcome) (rate(proxy_browser_request_total{...proxy_type="datacenter"}[$__rate_interval]))*3600
    legend: Browser · {{outcome}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A tier with two independent failure paths needs two series. Aggregating them into one number is how you get a dashboard that is technically accurate and operationally useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then dev refused to accept the fix
&lt;/h2&gt;

&lt;p&gt;I patched the newline out of Vault in dev, restarted, and nothing changed. The pods still came up with the old value.&lt;/p&gt;

&lt;p&gt;Two things were going on.&lt;/p&gt;

&lt;p&gt;The boring one: environment variables from &lt;code&gt;secretKeyRef&lt;/code&gt; are resolved &lt;strong&gt;at container start&lt;/strong&gt;. &lt;br&gt;
Updating a Secret never changes a running pod's environment — new pods are always required.&lt;/p&gt;

&lt;p&gt;The second was not boring. The dev Secret wasn't updating &lt;em&gt;at all&lt;/em&gt;. The sync had been dead for seven weeks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error processing spec.data[2] (key: .../my-app-llm-api), err: Secret does not exist
Ready=False   refreshTime=2026-07-15T06:48:31Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The External Secrets Operator resolves every key in an ExternalSecret &lt;strong&gt;atomically&lt;/strong&gt;. If one key fails, it writes &lt;em&gt;nothing&lt;/em&gt; — so one missing key also froze &lt;code&gt;PROXY_DATACENTER_URL&lt;/code&gt;, the database credentials, and everything else in the same object. Seven services, all stuck on a copy of their secrets from 15 July.&lt;/p&gt;

&lt;p&gt;And here's the part that should worry you: &lt;strong&gt;nothing looked broken.&lt;/strong&gt; ESO doesn't delete the Secret when it fails, it just stops updating it. Pods kept booting from the last-good copy.&lt;br&gt;
One of them was still making successful API calls that morning, on a key synced seven weeks earlier. The only signal anywhere was a &lt;code&gt;Ready=False&lt;/code&gt; on an object nobody looks at.&lt;/p&gt;

&lt;p&gt;The fix was to create the one missing Vault path — present in prod, never created in dev — populated with the value already working, so restoring the sync was a no-op for that key rather than a credential rotation. All seven went &lt;code&gt;Ready=False → True&lt;/code&gt; within two refresh cycles.&lt;/p&gt;

&lt;p&gt;Restoring the sync then did something nice: it pushed the corrected proxy URL into the five secrets I'd never hand-patched, which were all still carrying the newline. And it validated the&lt;br&gt;
blind Vault write independently — ESO fetched from Vault and produced byte-for-byte what I'd patched by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode is bigger than us
&lt;/h2&gt;

&lt;p&gt;Once I understood the shape of it, I audited every ExternalSecret in the namespace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dev:&lt;/strong&gt; 15 of 200 failing. Ten were ours. The rest belonged to other teams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three referenced a Vault path prefix that has &lt;strong&gt;never existed&lt;/strong&gt; in that cluster —
&lt;code&gt;secret/data/my-company.org/...&lt;/code&gt; instead of the &lt;code&gt;.../ns/&amp;lt;namespace&amp;gt;/&amp;lt;app&amp;gt;&lt;/code&gt; layout everything
else uses. Their &lt;code&gt;refreshTime&lt;/code&gt; is &lt;code&gt;None&lt;/code&gt;. They have never synced, ever.&lt;/li&gt;
&lt;li&gt;One had the right path but the wrong property name inside it.&lt;/li&gt;
&lt;li&gt;Four wildcard TLS certificates were healthy until 2026-08-17, then the Vault entry vanished.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Prod:&lt;/strong&gt; far better — 195 secrets, only 2 failing. But those two are instructive. They last synced on &lt;strong&gt;2026-03-05&lt;/strong&gt;. Six months ago. The consuming deployment is running 1/1 right now; its&lt;br&gt;
pod started 2026-08-19, five months &lt;em&gt;after&lt;/em&gt; the sync broke, and came up fine on the retained Secret.&lt;/p&gt;

&lt;p&gt;So a production service is quietly running on a credential that &lt;strong&gt;can no longer be rotated&lt;/strong&gt;.&lt;br&gt;
Write a new key to Vault and it will never reach the pod. The eventual failure will look like vendor auth errors, with no obvious connection to a secret change made months earlier.&lt;/p&gt;

&lt;p&gt;That's the real defect. Not any individual missing path — those are ordinary mistakes. The defect is that a broken secret sync is &lt;strong&gt;invisible by design&lt;/strong&gt;: retention means the app keeps&lt;br&gt;
working, so the failure is deferred to whenever someone next needs the value to actually change.&lt;br&gt;
Which is usually a rotation, an incident, or a migration — the worst possible moments to discover your secrets have been frozen since March.&lt;/p&gt;

&lt;p&gt;I raised this with the cloud team. They're now looking at surfacing sync failures during ArgoCD sync, so a &lt;code&gt;Ready=False&lt;/code&gt; ExternalSecret fails loudly at deploy time instead of lurking. I handed&lt;br&gt;
over the other teams' broken secrets along with it.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;A single byte can be invisible on one path and fatal on another.&lt;/strong&gt; The same value went through YAML on one side and argv on the other. Ask what &lt;em&gt;transformations&lt;/em&gt; sit between a config value&lt;br&gt;
and each of its consumers — they're rarely the same.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verification methods can launder the bug.&lt;/strong&gt; Shell &lt;code&gt;$( )&lt;/code&gt; strips trailing newlines. &lt;code&gt;tr&lt;/code&gt; turns them into line breaks. Both "confirmed" the URL was well-formed. Use &lt;code&gt;xxd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A panel that shows one of two failure paths is worse than no panel&lt;/strong&gt;, because it manufactures confidence. If a tier can fail two independent ways, plot two series.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retention makes failures polite, and therefore dangerous.&lt;/strong&gt; ESO keeping the last-good Secret is the right default for availability and the worst possible default for observability. If a system's failure mode is "everything keeps working, just frozen", nothing short of an explicit check will catch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Absence of a metric is not evidence of absence.&lt;/strong&gt; Two signals nearly derailed the diagnosis:&lt;br&gt;
ESO's &lt;code&gt;syncedResourceVersion&lt;/code&gt; didn't change after a write that definitely landed, and a brand-new pod had &lt;em&gt;no&lt;/em&gt; Prometheus series at all — not zero-valued ones — because of scrape discovery lag&lt;br&gt;
and &lt;code&gt;client_golang&lt;/code&gt; only creating a labelled series on first increment. When in doubt, read the pod's own logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that has nothing to do with newlines
&lt;/h2&gt;

&lt;p&gt;Here's what actually bothers me about this bug.&lt;/p&gt;

&lt;p&gt;We use LLMs to generate code now. That's fine — it's a tool, and it lets us implement things faster than we could before. But it only compresses one part of the job. Every other part of&lt;br&gt;
engineering still exists.&lt;/p&gt;

&lt;p&gt;For starters: we still have to make sure the damn thing we built works.&lt;/p&gt;

&lt;p&gt;Testing methodology and observability matter &lt;em&gt;more&lt;/em&gt; now, not less, precisely because we pay less attention to the implementation. If you didn't write the code line by line, you don't carry a&lt;br&gt;
mental model of where it's fragile — so the only thing standing between you and a silent failure is a strict test that fails loudly and an alert that fires when reality diverges from what you&lt;br&gt;
assumed.&lt;/p&gt;

&lt;p&gt;This bug is what that gap looks like in production. A tier shipped that had never worked once. 793,810 failures over 90 days. A dashboard panel that couldn't show the failing half. Seven&lt;br&gt;
services frozen on seven-week-old secrets, and the only evidence was a &lt;code&gt;Ready=False&lt;/code&gt; field on an object nobody reads. Nothing here was hard to detect. It just wasn't being checked.&lt;/p&gt;

&lt;p&gt;Impact assessment, testing, monitoring after deployment — none of that vanished with AI. You can use AI to help with all of it: have it write the test you'd have skipped, generate the alert&lt;br&gt;
rule, review the diff for what you forgot to instrument. The failure here wasn't AI. It was skipping the cadence.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flq5wyasd15nobgtox4vj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flq5wyasd15nobgtox4vj.png" alt="Writing the implementation got faster — minutes, not days. Impact assessment, testing, observability and post-deploy checks did not shrink at all. Four things went unchecked in this bug: a tier that never worked once, a panel plotting one of two failure paths, seven services frozen on seven-week-old secrets, and verification that laundered the bug. None of it was hard to detect — it just wasn't being checked." width="800" height="382"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And I'll be honest about the uncomfortable part: because we aren't writing the code, we care less. When you can generate a working implementation in a minute, it becomes very easy to stop&lt;br&gt;
at "it runs" and never do the complete thing. That pull is real, and I feel it too.&lt;/p&gt;

&lt;p&gt;Doing the complete thing anyway is the job. That's what being a responsible engineer means, and it's the part no tool is going to do for you unless you ask it to.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>monitoring</category>
      <category>production</category>
      <category>testing</category>
    </item>
    <item>
      <title>Thank you so much for the mention Sarvar, really appreciate it!</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Fri, 04 Sep 2026 01:57:04 +0000</pubDate>
      <link>https://dev.to/harishteens/thank-you-so-much-for-the-mention-sarvar-really-appreciate-it-3adn</link>
      <guid>https://dev.to/harishteens/thank-you-so-much-for-the-mention-sarvar-really-appreciate-it-3adn</guid>
      <description>&lt;div class="ltag__link--embedded"&gt;
  &lt;div class="crayons-story "&gt;
  &lt;a href="https://dev.to/sarvar_04/my-devto-cli-got-its-first-community-pr-image-uploads-from-terminal-4562" class="crayons-story__hidden-navigation-link"&gt;My Dev.to CLI Got Its First Community PR. Image Uploads From Terminal.&lt;/a&gt;
    &lt;div class="crayons-article__cover crayons-article__cover__image__feed"&gt;
      &lt;iframe src="https://www.youtube.com/embed/lYM2ue3b6tg" title="My Dev.to CLI Got Its First Community PR. Image Uploads From Terminal."&gt;&lt;/iframe&gt;
    &lt;/div&gt;


  &lt;div class="crayons-story__body crayons-story__body-full_post"&gt;
      &lt;a href="https://dev.to/sarvar_04/my-devto-cli-got-its-first-community-pr-image-uploads-from-terminal-4562" class="crayons-article__context-note crayons-article__context-note__feed"&gt;&lt;p&gt;Solved via cookie and CSRF reverse engineering&lt;/p&gt;

&lt;/a&gt;
    &lt;div class="crayons-story__top"&gt;
      &lt;div class="crayons-story__meta"&gt;
        &lt;div class="crayons-story__author-pic"&gt;

          &lt;a href="/sarvar_04" class="crayons-avatar  crayons-avatar--l  "&gt;
            &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1163149%2F5afa2902-591e-4944-b6fa-9bbba80c6e95.png" alt="sarvar_04 profile" class="crayons-avatar__image"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div&gt;
          &lt;div&gt;
            &lt;a href="/sarvar_04" class="crayons-story__secondary fw-medium m:hidden"&gt;
              Sarvar Nadaf
            &lt;/a&gt;
            &lt;div class="profile-preview-card relative mb-4 s:mb-0 fw-medium hidden m:inline-block"&gt;
              
                Sarvar Nadaf
                
                
              
              &lt;div id="story-author-preview-content-4562013" class="profile-preview-card__content crayons-dropdown branded-7 p-4 pt-0"&gt;
                &lt;div class="gap-4 grid"&gt;
                  &lt;div class="-mt-4"&gt;
                    &lt;a href="/sarvar_04" class="flex"&gt;
                      &lt;span class="crayons-avatar crayons-avatar--xl mr-2 shrink-0"&gt;
                        &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1163149%2F5afa2902-591e-4944-b6fa-9bbba80c6e95.png" class="crayons-avatar__image" alt=""&gt;
                      &lt;/span&gt;
                      &lt;span class="crayons-link crayons-subtitle-2 mt-5"&gt;Sarvar Nadaf&lt;/span&gt;
                    &lt;/a&gt;
                  &lt;/div&gt;
                  &lt;div class="print-hidden"&gt;
                    
                      Follow
                    
                  &lt;/div&gt;
                  &lt;div class="author-preview-metadata-container"&gt;&lt;/div&gt;
                &lt;/div&gt;
              &lt;/div&gt;
            &lt;/div&gt;

          &lt;/div&gt;
          &lt;a href="https://dev.to/sarvar_04/my-devto-cli-got-its-first-community-pr-image-uploads-from-terminal-4562" class="crayons-story__tertiary fs-xs"&gt;&lt;time&gt;Sep 3&lt;/time&gt;&lt;span class="time-ago-indicator-initial-placeholder"&gt;&lt;/span&gt;&lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;

    &lt;div class="crayons-story__indention"&gt;
      &lt;h2 class="crayons-story__title crayons-story__title-full_post"&gt;
        &lt;a href="https://dev.to/sarvar_04/my-devto-cli-got-its-first-community-pr-image-uploads-from-terminal-4562" id="article-link-4562013"&gt;
          My Dev.to CLI Got Its First Community PR. Image Uploads From Terminal.
        &lt;/a&gt;
      &lt;/h2&gt;
        &lt;div class="crayons-story__tags"&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/devto"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;devto&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/opensource"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;opensource&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/python"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;python&lt;/a&gt;
            &lt;a class="crayons-tag  crayons-tag--monochrome " href="/t/webdev"&gt;&lt;span class="crayons-tag__prefix"&gt;#&lt;/span&gt;webdev&lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="crayons-story__bottom"&gt;
        &lt;div class="crayons-story__details"&gt;
          &lt;a href="https://dev.to/sarvar_04/my-devto-cli-got-its-first-community-pr-image-uploads-from-terminal-4562" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left"&gt;
            &lt;div class="multiple_reactions_aggregate"&gt;
              &lt;span class="multiple_reactions_icons_container"&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/exploding-head-daceb38d627e6ae9b730f36a1e390fca556a4289d5a41abb2c35068ad3e2c4b5.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/multi-unicorn-b44d6f8c23cdd00964192bedc38af3e82463978aa611b4365bd33a0f1f4f3e97.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
                  &lt;span class="crayons_icon_container"&gt;
                    &lt;img src="https://assets.dev.to/assets/sparkle-heart-5f9bee3767e18deb1bb725290cb151c25234768a0e9a2bd39370c382d02920cf.svg" width="18" height="18"&gt;
                  &lt;/span&gt;
              &lt;/span&gt;
              &lt;span class="aggregate_reactions_counter"&gt;32&lt;span class="hidden s:inline"&gt;&amp;nbsp;reactions&lt;/span&gt;&lt;/span&gt;
            &lt;/div&gt;
          &lt;/a&gt;
            &lt;a href="https://dev.to/sarvar_04/my-devto-cli-got-its-first-community-pr-image-uploads-from-terminal-4562#comments" class="crayons-btn crayons-btn--s crayons-btn--ghost crayons-btn--icon-left flex items-center"&gt;
              

              9&lt;span class="hidden s:inline"&gt;&amp;nbsp;comments&lt;/span&gt;
            &lt;/a&gt;
        &lt;/div&gt;
        &lt;div class="crayons-story__save"&gt;
          &lt;small class="crayons-story__tertiary fs-xs mr-2"&gt;
            5 min read
          &lt;/small&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;/div&gt;


</description>
      <category>cli</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Building an x402-aware crawler in Go: what Cloudflare's Monetization Gateway means for web scraping</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Sun, 30 Aug 2026 13:13:25 +0000</pubDate>
      <link>https://dev.to/harishteens/building-an-x402-aware-crawler-in-go-what-cloudflares-monetization-gateway-means-for-web-scraping-2f37</link>
      <guid>https://dev.to/harishteens/building-an-x402-aware-crawler-in-go-what-cloudflares-monetization-gateway-means-for-web-scraping-2f37</guid>
      <description>&lt;p&gt;57% of internet traffic today is AI and web scraping bots — up from just 20% in 2021. For site owners, this has meant a frustrating binary: block bots using services like Cloudflare or Akamai and risk cutting off legitimate AI traffic, or allow them and get nothing in return.&lt;/p&gt;

&lt;p&gt;x402 offers a third path. HTTP 402 — "Payment Required" — has existed in the spec since 1991 but was never practically implemented. x402 makes it real: a client hitting a payment-protected endpoint completes an on-chain transaction and attaches the payment signature (&lt;code&gt;X-Payment-Signature&lt;/code&gt;) to the retry request before it can access the content.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;x402 is an open, neutral standard for internet-native payments. It absolves the internet's original sin by natively making payments possible between clients and servers, creating win-win economies that empower agentic payments at scale.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is part of the Linux Foundation, backed by Cloudflare, Coinbase, AWS, Google, Stripe, Visa, and others. This is not a crypto side project — it is foundational internet infrastructure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understanding the payment flow
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmr63evjorhb8pqe4jxml.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmr63evjorhb8pqe4jxml.png" alt="x402 Payment Flow" width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The flow is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The client requests a protected endpoint and gets back a &lt;code&gt;402 Payment Required&lt;/code&gt; response containing the price, destination wallet address, and expected network.&lt;/li&gt;
&lt;li&gt;The client pays on-chain to that wallet via a facilitator, which handles the blockchain interaction.&lt;/li&gt;
&lt;li&gt;The facilitator settles the transaction and returns a transaction hash.&lt;/li&gt;
&lt;li&gt;The client retries the original request with the transaction hash as &lt;code&gt;X-Payment-Signature&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The server verifies the payment on-chain through the facilitator.&lt;/li&gt;
&lt;li&gt;On confirmation, the server serves the actual content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;On facilitators:&lt;/strong&gt; a facilitator is an optional but recommended service that abstracts the verification and settlement process. You can use any facilitator as long as it supports the network the server expects payment on. Since Coinbase co-governs x402, most sites transact on Base, though Solana, Ethereum, and Polygon are also supported.&lt;/p&gt;

&lt;p&gt;For testing x402-crawler, I used &lt;a href="https://sepolia.basescan.org/" rel="noopener noreferrer"&gt;Base Sepolia&lt;/a&gt; (Base's testnet) and &lt;a href="https://facilitator.x402.rs/" rel="noopener noreferrer"&gt;facilitator.x402.rs&lt;/a&gt; — a free public test facilitator run by &lt;a href="https://github.com/x402-rs/x402-rs" rel="noopener noreferrer"&gt;FareSide&lt;/a&gt;, an independent open-source project. You can also run a local facilitator to avoid the blockchain entirely during development.&lt;/p&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fea3b2b54fqmtxfvy8s8l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fea3b2b54fqmtxfvy8s8l.png" alt="x402 Crawler Architecture diagram" width="799" height="278"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three concerns, deliberately kept apart: the client speaks the protocol, the crawler decides what to fetch, and the budget tracker decides what may be paid for. None of them holds another's state.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Package&lt;/th&gt;
&lt;th&gt;Owns&lt;/th&gt;
&lt;th&gt;Deliberately does not own&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the x402 protocol: 402 handling, signing, retry classification, reconciliation&lt;/td&gt;
&lt;td&gt;any spending policy, any ledger, any URL state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;crawler&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;traversal: what to fetch, how deep, when to stop&lt;/td&gt;
&lt;td&gt;how payments are signed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;crawler.BudgetTracker&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;every spending decision and the accounting behind it&lt;/td&gt;
&lt;td&gt;anything to do with HTTP&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;client.Config.Authorize&lt;/code&gt; is required — there is no default. A client with no policy would pay whatever a server asked, and that failure is silent and expensive. Callers who genuinely want no ceiling pass &lt;code&gt;client.AllowAnyPayment&lt;/code&gt;, so the decision is written down rather than implied by an unset field.&lt;/p&gt;




&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Simple BFS crawler, same-domain.&lt;/strong&gt; Workers pull from a shared queue rather than traversing level by level, so one slow page does not hold up the rest. Termination counts queued &lt;em&gt;and&lt;/em&gt; in-flight work — an empty queue is not the end, because a worker mid-fetch may be about to surface more URLs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Budget-aware.&lt;/strong&gt; A per-payment cap and a cumulative total, both claimed under one lock before signing, so concurrent workers cannot each be told there is room for the last payment. Running out of budget is not an error — the crawl reports which URLs it could not reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pays when required, with retries.&lt;/strong&gt; Every failure reason in the x402 spec is classified as terminal, retryable, or &lt;em&gt;needs reconciling&lt;/em&gt;. A &lt;code&gt;settlement_pending&lt;/code&gt; is never retried blind: the chain is asked what became of the transaction first, because retrying blindly is how one fetch gets paid for twice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dry run.&lt;/strong&gt; &lt;code&gt;--dry-run&lt;/code&gt; prices a crawl without funding it, still applying budget caps — so the estimate answers &lt;em&gt;"what would this budget actually buy"&lt;/em&gt; rather than &lt;em&gt;"what would everything cost"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PDF downloads.&lt;/strong&gt; Streamed to disk rather than buffered, under a size cap, with filenames generated from a hash of the URL so a server cannot choose what lands on your filesystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Payment deduplication.&lt;/strong&gt; Concurrent workers racing to fetch the same URL collapse onto one request via &lt;code&gt;singleflight&lt;/code&gt;. The others wait and share the result rather than each paying independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nothing leaks.&lt;/strong&gt; A redacting &lt;code&gt;zapcore.Core&lt;/code&gt; inspects log values — not field names — so a private key logged under any field name is still stripped from output.&lt;/p&gt;

&lt;p&gt;The full implementation is on GitHub: &lt;a href="https://github.com/HarishTeens/x402-crawler" rel="noopener noreferrer"&gt;github.com/HarishTeens/x402-crawler&lt;/a&gt; — give it a star if it's useful.&lt;/p&gt;




&lt;h2&gt;
  
  
  The bigger picture: Cloudflare's two-sided bet on crawler payments
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft8mfy5828xtqgl3jdnz5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft8mfy5828xtqgl3jdnz5.png" alt="Cloudflare Monetisation Gateway" width="800" height="242"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source: &lt;a href="https://blog.cloudflare.com/monetization-gateway/" rel="noopener noreferrer"&gt;Cloudflare's article&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I built for x402 — the open, wallet-native path. But Cloudflare is actually running two separate systems simultaneously, and understanding both clarifies why x402 matters.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;&lt;a href="https://blog.cloudflare.com/introducing-pay-per-crawl/" rel="noopener noreferrer"&gt;Pay Per Crawl&lt;/a&gt;&lt;/strong&gt;. When Cloudflare announced the Monetization Gateway, most coverage missed that it sits alongside an older system with fundamentally different mechanics. Pay Per Crawl uses no blockchain. Cloudflare is the merchant of record: both the crawler operator and the publisher need Cloudflare accounts with billing set up. The crawler gets verified via &lt;a href="https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/" rel="noopener noreferrer"&gt;Web Bot Auth&lt;/a&gt;, payments are settled through Cloudflare's billing infrastructure, and Cloudflare takes a cut. It is targeting enterprise — Perplexity, OpenAI, the players signing actual commercial deals.&lt;/p&gt;

&lt;p&gt;The second is &lt;strong&gt;x402 via the Monetization Gateway&lt;/strong&gt;. No Cloudflare account required. The wallet is the identity. Any agent can pay any site autonomously with no prior relationship. This is what I built support for.&lt;/p&gt;

&lt;p&gt;These are not competing products — they are two halves of the same strategy. Pay Per Crawl locks in the enterprise market through billing relationships. x402 captures the long tail through open-protocol dominance. In both cases, Cloudflare sits in the middle of every transaction — as the billing layer in one, as the edge verification layer in the other.&lt;/p&gt;

&lt;p&gt;That is the VISA parallel. VISA does not hold your money. It just sits between every transaction and charges a small fee. At Cloudflare's scale, with the majority of the web sitting behind their edge, that positioning is enormous — and it does not require owning the money to work.&lt;/p&gt;

&lt;p&gt;As a crawler builder, x402 is the path to support now. It is autonomous, requires no prior relationship, and is the harder engineering problem — which is exactly what makes it worth building.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;x402 protocol: &lt;a href="https://github.com/x402-foundation/x402" rel="noopener noreferrer"&gt;github.com/x402-foundation/x402&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;x402-crawler: &lt;a href="https://github.com/HarishTeens/x402-crawler" rel="noopener noreferrer"&gt;github.com/HarishTeens/x402-crawler&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;FareSide test facilitator: &lt;a href="https://github.com/x402-rs/x402-rs" rel="noopener noreferrer"&gt;github.com/x402-rs/x402-rs&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;x402 spec: &lt;a href="https://x402.org" rel="noopener noreferrer"&gt;x402.org&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>blockchain</category>
      <category>go</category>
      <category>webscraping</category>
    </item>
    <item>
      <title>4 Non-obvious learnings from working with alerts</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Sat, 22 Aug 2026 02:16:30 +0000</pubDate>
      <link>https://dev.to/harishteens/4-non-obvious-learnings-from-working-with-alerts-ba5</link>
      <guid>https://dev.to/harishteens/4-non-obvious-learnings-from-working-with-alerts-ba5</guid>
      <description>&lt;p&gt;Alerting is a details game. Nobody gets paged by their intent — they get paged by the exact query they wrote. If you want to sleep through the night, the query has to mean what you think it means, and most of the time you find out it doesn't at 3am.&lt;/p&gt;

&lt;p&gt;The clearest example I saw wasn't even subtle. A colleague had an alert on message processing count as a throughput signal for our file processor. Perfectly reasonable — if a message were a unit of work. It isn't. One message carries a batch of up to ten URLs, and the thing we actually cared about was PDF downloads.&lt;/p&gt;

&lt;p&gt;The important part is that this was not a deliberate approximation. Nobody weighed the cost of instrumenting per-download and settled for messages as a cheaper proxy. The detail that one message holds many URLs simply wasn't known to the person writing the rule, so there was no tradeoff to weigh in the first place. We were tracking the wrong metric outright.&lt;/p&gt;

&lt;p&gt;And everything downstream inherited it. The threshold, the SLO, the months of accumulated "is this number normal?" intuition — all of it quietly denominated in the wrong unit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4c9dyy988mow4vbqmv2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fv4c9dyy988mow4vbqmv2.png" alt="Five queue messages holding 3, 10, 1, 7 and 5 links. The alert counted 5 messages; the work was 26 downloads." width="800" height="366"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Five messages, twenty-six units of work, and no constant ratio between them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the shape of every lesson below. The metric is fine. The mapping from metric to intent is where it breaks.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Throughput is age-of-oldest-message, not a rate floor
&lt;/h2&gt;

&lt;p&gt;The obvious throughput alert is a floor: fewer than N items in M minutes, page. On a nightly-batch workload this fires every single night, because a floor cannot distinguish "wedged" from "idle, there is genuinely no work."&lt;/p&gt;

&lt;p&gt;Age of the oldest queued message has neither problem. If nothing is queued, there's nothing to be old, so quiet periods are silent for free. If work is queued and not moving, age climbs — and it climbs whether the consumer crashed or is up and simply not consuming, which a liveness check misses entirely.&lt;/p&gt;

&lt;p&gt;It also spares you from inventing a number. "Is 40 messages/minute normal?" requires a model of your workload. "Has anything been sitting here for six hours?" is grounded in the queue's own drain time, and you can defend it in review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9obxfulne6l750mywmi2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9obxfulne6l750mywmi2.png" alt="Two panels over one 24-hour day. A rate floor is breached for 17 of 24 hours, most of it idle time; age-of-oldest-message crosses its threshold only during the wedge." width="800" height="503"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Same day, same incident. The floor fires seventeen hours out of twenty-four; the age signal fires once.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Catching the tail
&lt;/h2&gt;

&lt;p&gt;Queue age tells you that work is not draining. It does not tell you &lt;em&gt;why&lt;/em&gt;, and there are two very different whys:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Is there a system-wide choke, where everything is stalling?&lt;/li&gt;
&lt;li&gt;Or are a handful of genuinely slow jobs dragging out the tail while the main workstream is fine?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A per-unit deadline answers this — "did any single job exceed N minutes?" — and the useful signal is not that it fired, but &lt;em&gt;how many times&lt;/em&gt; it fired.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Many exceedances.&lt;/strong&gt; You almost certainly got the throughput alert too. This isn't a tail at all: the main workstream is failing, and the tail alert is just a second view of the same outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A few exceedances, throughput healthy.&lt;/strong&gt; It really is a handful of jobs. You're now looking at specific inputs — one pathological domain, one oversized file — not at the system.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumpbeii3bnv6yedc1a02.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumpbeii3bnv6yedc1a02.png" alt="Two timelines of deadline exceedances over 14 days: a dense wall of 312 in one stretch, versus 10 isolated marks spread across the fortnight." width="800" height="320"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The alert is identical in both rows. The count is what tells you which situation you're in.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our API is the clean version of the second case. Across a fortnight, about ten requests crossed 10 seconds and everything else was fast. Ten requests out of a fortnight's traffic is not an outage, and a zero-allowance rule at 10s would have paged us for requests that were slow but not broken. So the deadline went where exceedances are genuinely rare — 30 seconds — and the alert now means "something is stuck" rather than "something is slow."&lt;/p&gt;

&lt;p&gt;That distinction is the entire reason to run both alerts. Between them, they tell you which of the two questions you're looking at before you open a single dashboard.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Ratios need a volume gate, not just a non-zero check
&lt;/h2&gt;

&lt;p&gt;A 99% success SLO sounds like it tolerates failure. On a sparse workload it tolerates nothing: one failure breaches &lt;em&gt;any&lt;/em&gt; window holding 100 units or fewer. An hour that saw 3 items and failed 1 reads as 67% and pages you.&lt;/p&gt;

&lt;p&gt;We thought this was covered. The SLI carried a no-data guard — the ratio was only evaluated if the service had seen &lt;em&gt;any&lt;/em&gt; traffic in the window, so a completely idle hour fell through to "healthy" instead of dividing by zero:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="o"&gt;and&lt;/span&gt; &lt;span class="kp"&gt;on&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items_processed_total&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1h&lt;/span&gt;&lt;span class="p"&gt;]))&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That guard does exactly one thing: it excludes the empty window. It says nothing about whether the window holds enough work for a percentage to mean anything. An hour with 4 items clears &lt;code&gt;&amp;gt; 0&lt;/code&gt; comfortably and is still statistically useless.&lt;/p&gt;

&lt;p&gt;Over a clean 14-day window with no real incident, one service breached 12 times. Every one of the twelve was an hour holding between 1 and 30 items. Pure denominator artifact.&lt;/p&gt;

&lt;p&gt;The fix is to make the guard a &lt;em&gt;volume&lt;/em&gt; gate rather than a presence check — the same expression, with a real floor in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="o"&gt;and&lt;/span&gt; &lt;span class="kp"&gt;on&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;items_processed_total&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1h&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.027778&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# 100/hour&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero breaches over the same window, and it still catches every genuine high-volume failure episode. The 99% target never changed. The gate was the whole alert.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8om23fnfpgylt8s4wdba.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8om23fnfpgylt8s4wdba.png" alt="Scatter of hourly success rate against items processed per hour, log x-axis. Every breach of the 99% line sits left of the 100-items-per-hour gate." width="799" height="434"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every breach in fourteen clean days, and all of them are hours that barely held any work.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The generalization: any ratio- or percentile-shaped SLI is only meaningful above some volume, and you have to say what that volume is. "Traffic &amp;gt; 0" is not a volume gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. You can only see as far as your largest bucket
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjg6xeg91gey5r3cb0fwo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjg6xeg91gey5r3cb0fwo.png" alt="Histogram of per-job durations. Bars up to the 5-minute bucket are solid; everything past it is hatched, marking the 26% of jobs the old ceiling collapsed into a single overflow bucket." width="800" height="415"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Everything right of the dashed line came back as the same number until the buckets were widened.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Everything above assumes you can answer "how many jobs took longer than N?" With a histogram, you can only answer that at a boundary that actually exists. Buckets are cumulative, so "slower than 45 minutes" is &lt;code&gt;count - bucket{le="2700"}&lt;/code&gt; — and if nothing ever emitted &lt;code&gt;le=2700&lt;/code&gt;, that expression has nothing to subtract.&lt;/p&gt;

&lt;p&gt;This is where it turns dangerous. A rule referencing a bucket that doesn't exist does not error. The series is simply absent, the subtraction yields nothing, and the alert quietly never fires. It looks green. It is blind.&lt;/p&gt;

&lt;p&gt;The ceiling hides things in the other direction too. Our API's histogram topped out at &lt;code&gt;le=10&lt;/code&gt;, so every request slower than ten seconds — 10s, 90s, one wedged request hanging for an hour — landed in the same overflow bucket. We couldn't have set a 30-second deadline if we'd wanted to. Worse, we couldn't even tell whether 30 seconds was the right number, because the data needed to answer that had never been recorded.&lt;/p&gt;

&lt;p&gt;So the instrumentation work has to land before the alerting work: push the top boundary above the deadline you care about, with enough headroom to see the shape of what lies beyond it. Ours went from a 5-minute ceiling to 4 hours, which is how we found out that individual scrapes occasionally run for 11.&lt;/p&gt;

&lt;p&gt;One catch worth planning for: a bucket added today only describes traffic from today. A new boundary gives you no history, so any threshold derived from it is a guess until you've waited out a full window — and until then, the rule depending on it is one of those green-but-blind rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  The through-line
&lt;/h2&gt;

&lt;p&gt;Every one of these is the same failure: a plausible metric standing in for the thing you actually meant. Messages for downloads. A percentile for a stuck job. A rate floor for liveness.&lt;/p&gt;

&lt;p&gt;So before you ship the rule, replay it against real history and read what it &lt;em&gt;did&lt;/em&gt;. Not what it should do — what it did, over a window whose ground truth you already know. It's cheap, and it turns "I think this threshold is right" into "here are the 12 times this would have woken me for nothing."&lt;/p&gt;

</description>
      <category>observability</category>
      <category>sre</category>
      <category>prometheus</category>
      <category>devops</category>
    </item>
    <item>
      <title>Vocabulary based URL Clustering</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Wed, 12 Aug 2026 08:19:18 +0000</pubDate>
      <link>https://dev.to/harishteens/vocabulary-based-url-clustering-353k</link>
      <guid>https://dev.to/harishteens/vocabulary-based-url-clustering-353k</guid>
      <description>&lt;p&gt;&lt;strong&gt;It all started when I kept asking this question every day:&lt;/strong&gt; &lt;br&gt;
Wouldn't it be easy if the crawler can just look at the URL and tell whether its a structure URL pattern or an Identifier(hash, slugs, IDs)&lt;/p&gt;

&lt;h3&gt;
  
  
  Painpoints
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Pattern matching failed badly for distorted Page URLs where the vocabulary was simply different and the slug patterns were inconsistent&lt;/li&gt;
&lt;li&gt;Without pattern matching, we couldn't really identify a large content hub that produces tons of articles/products/blogs…etc This makes crawling miserable of large fan-out pages! The only way was to crawl everything!&lt;/li&gt;
&lt;li&gt;Trie was built in to model a site so we can reasonably determine page structures. But it all went backfired when we entered large Ecom style fan-out sites who simply don't follow Trie URL structure 🙂 &lt;/li&gt;
&lt;li&gt;Deterministic rules helped with Hash detection, numbers, but failed badly with "content slugs". They are always not too long, don't have atleast 2 hyphens, mix of alpha and numbers. Could not really distinguish them based one generalised rule. Kept going back and forth between heuristic rules!&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Iteration 1
&lt;/h3&gt;

&lt;p&gt;I was ready to train a text model that uses some kind of clustering like K-means or Naive Bayes. A small prototype was made based on Naive bayes , used dev URLs as a data test. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Good:&lt;/strong&gt; It actually helped identify positive paths better&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Bad:&lt;/strong&gt; But prod's dataset was too wide. The generalisation needed more dataset and a better pipeline. &lt;/p&gt;

&lt;p&gt;Realising I'm only a noob at building models, gave up and proceeded with next approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Iteration 2
&lt;/h3&gt;

&lt;p&gt;The prototype worked well for already generalised well defined keywords. It didn't help with the site specific hubs. I won't blame the model, who the heck is going to know that application/marine could be a large content hub!&lt;/p&gt;

&lt;p&gt;So I added a local vocabulary on top of the generalised model and let the logic use this as a fallback when the generalised model could not classify it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No good, just bad:&lt;/strong&gt; Although it looked like a good idea, unfortunately what could have been general on the dataset was not the general case in most sites. Similarly what was rare in the general set was again not rare at all for this set. &lt;/p&gt;

&lt;p&gt;Tried adjust scores from global+local, but again its a goose hunt to find the right attribution!&lt;/p&gt;

&lt;h3&gt;
  
  
  Iteration 3
&lt;/h3&gt;

&lt;p&gt;Go all in on local vocab. Every site is a unique beast, all I care about is identifying large fan-out with sparse URL patterns to ban them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Good:&lt;/strong&gt; This performed better than both the previous iterations and was able to identifying structural vs Identifier reliably purely on the sites on vocab.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Bad:&lt;/strong&gt; We lost the predefined judgement which generalised model gave us, it took the crawler time to understand site structure. So it was bucketing similar URLs into separate groups rather than one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Iteration 4
&lt;/h3&gt;

&lt;p&gt;Multiple ideas to solve the problem:&lt;/p&gt;

&lt;p&gt;I actually thought of a fake crawl to learn the site and then do the actual crawl. But bad too costly and time consuming&lt;/p&gt;

&lt;p&gt;How about we limit to 100 page fetches to build vocab then use the judgement. Again a heuristic ticking bomb to blow out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Instead the better approach was to build resilience. Like how we humans think we start with a guess, consume enough volume so adds more signals to the clusters and makes them distinct right!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So we already had a checkpoint program that runs every 15 fetches. First we adjust that to 50 and re-pattern all the URLs using the updated vocab(a bit costly on the DB! but worth it) . This helped with the buckets convergence. And to top it off we have guards in-place so we quantify "Rarity" vs "Structural".&lt;/p&gt;

&lt;p&gt;The rare keywords in a site might not necessarily be 1,2,3..etc. It depends on the site itself, similarly the structural keywords cannot be 40,50...etc. In the beginning, we have no way to guess. But as the crawl progresses we recalibrate these, given the buckets get distinct as we collect more vocabulary. The more we crawl, the more distinct the structural and rare keywords become, and we re-pattern them based on our new finding.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLDR;
&lt;/h2&gt;

&lt;p&gt;Heuristic always fails, there is always a black swan. Building resilience as we gather more evidence and adapting on the fly helps. This exercise made me realise that. It also helped me realise its possible to do without complicated models…&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6h9195ylw9w73dkwedfa.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6h9195ylw9w73dkwedfa.png" alt="I used the stones to destroy the stones" width="596" height="335"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>computerscience</category>
      <category>software</category>
    </item>
    <item>
      <title>Alternative to load test: Model using the Universal Scalability Law</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Mon, 27 Jul 2026 05:23:23 +0000</pubDate>
      <link>https://dev.to/harishteens/alternative-to-load-test-model-using-the-universal-scalability-law-4hbo</link>
      <guid>https://dev.to/harishteens/alternative-to-load-test-model-using-the-universal-scalability-law-4hbo</guid>
      <description>&lt;p&gt;&lt;em&gt;A quick note upfront: I'm barely scratching the surface here. I'm not a performance-engineering expert, and most of this was new to me a couple of weeks ago. But I ran into a real problem, went down a rabbit hole, and found something genuinely interesting — so I'm writing it down.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: 1,000 connections, ~13 doing anything
&lt;/h2&gt;

&lt;p&gt;We run a distributed web crawler. It scales out to ~100 pods, each holding ~10 database connections, so at peak we open roughly &lt;strong&gt;1,000 connections&lt;/strong&gt; to a single Postgres (Aurora) writer.&lt;/p&gt;

&lt;p&gt;On big bursts, the database CPU pegs at &lt;strong&gt;95%&lt;/strong&gt;. The obvious reading is "we've outgrown the DB, scale it up." But when I actually looked at what the database was doing, the story flipped:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Out of ~1,000 open connections, only about &lt;strong&gt;13 were ever active at the same time.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Thirteen. The other ~987 were just… sitting there. Our crawler spends almost all of its time fetching and rendering pages over the network, and only a millisecond here and there actually writing to the DB. So each connection is idle ~99% of the time. We were holding a thousand connections to do about thirteen connections' worth of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connections aren't free
&lt;/h2&gt;

&lt;p&gt;Here's the part I hadn't internalized: &lt;strong&gt;an idle connection still costs you.&lt;/strong&gt; In Postgres each connection is a real backend process — memory, a slot in a shared array the DB scans on every query, a thing the OS scheduler has to juggle. Hold a thousand of them and the database burns a chunk of its CPU just &lt;em&gt;coordinating&lt;/em&gt; connections, before doing any useful work.&lt;/p&gt;

&lt;p&gt;So the 95% CPU wasn't "too much work." It was "too much overhead managing connections we weren't using." More connections were making it &lt;em&gt;worse&lt;/em&gt;, not better. That completely inverted my mental model, which until then was basically "more concurrency = more throughput."&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does AWS let me open a thousand connections in the first place?
&lt;/h2&gt;

&lt;p&gt;This is the part that quietly misled me. If connections are costly and mostly idle, why does the database happily accept a thousand of them?&lt;/p&gt;

&lt;p&gt;Because &lt;code&gt;max_connections&lt;/code&gt; — the limit AWS hands you — is derived from the instance's &lt;strong&gt;memory&lt;/strong&gt;, not from its ability to do work. On Aurora/RDS it's basically a formula: roughly &lt;code&gt;instance memory ÷ a per-connection memory budget&lt;/code&gt;. A 64 GB instance can &lt;em&gt;hold&lt;/em&gt; hundreds or thousands of backend processes before it risks running out of RAM, so that's the number you're "allowed."&lt;/p&gt;

&lt;p&gt;That's the unlock: &lt;strong&gt;&lt;code&gt;max_connections&lt;/code&gt; is a safety ceiling — "how many connections can we hold without running out of memory" — not a target.&lt;/strong&gt; It says nothing about how much concurrent &lt;em&gt;work&lt;/em&gt; the database can do. That second number is set by &lt;strong&gt;CPU&lt;/strong&gt;, and it's far smaller — for us, ~13 active sessions, bounded by 8 cores.&lt;/p&gt;

&lt;p&gt;They're two unrelated axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Connections&lt;/strong&gt; are limited by &lt;strong&gt;memory&lt;/strong&gt; → the number is big.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Useful concurrency&lt;/strong&gt; is limited by &lt;strong&gt;CPU&lt;/strong&gt; → the number is small.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Filling your connection budget doesn't mean you're using the database well. We were sitting at ~1,000 of our allowed connections and still only doing ~13 active sessions' worth of work. The generous limit was quietly &lt;em&gt;inviting&lt;/em&gt; us to over-provision.&lt;/p&gt;

&lt;p&gt;The analogy that stuck with me: &lt;code&gt;max_connections&lt;/code&gt; is the number of &lt;strong&gt;chairs in the waiting room&lt;/strong&gt; (limited by floor space — memory). The number of &lt;strong&gt;doctors&lt;/strong&gt; (limited by CPU) is what decides how many patients actually get seen. Cramming more chairs in doesn't treat anyone faster; it just lets more people wait at once. We'd been proudly filling the waiting room and wondering why the queue wasn't moving.&lt;/p&gt;

&lt;p&gt;So a high &lt;code&gt;max_connections&lt;/code&gt; is &lt;em&gt;permission&lt;/em&gt;, not a &lt;em&gt;goal&lt;/em&gt;. The number worth chasing is active concurrency — and that's exactly what the next two ideas pin down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two old ideas that explained everything
&lt;/h2&gt;

&lt;p&gt;I stumbled onto two pieces of theory that turned the confusion into something I could reason about. I learned most of this from these two talks, which I'd recommend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=lZU6RK0oazM" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=lZU6RK0oazM&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.youtube.com/watch?v=Hda5tMrLJqc" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=Hda5tMrLJqc&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Little's Law&lt;/strong&gt; says the number of things in flight equals arrival rate × how long each takes (&lt;code&gt;L = λ × W&lt;/code&gt;). Plug in our numbers — the insert rate and the ~1.5 ms each insert takes — and out pops ~13. The "why only 13 active?" mystery wasn't a mystery at all; it was arithmetic. Active concurrency is a &lt;em&gt;result&lt;/em&gt; of throughput and service time, not of how many connections you happen to open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Universal Scalability Law (USL)&lt;/strong&gt; is the one that reframed how I think about scaling. It says throughput does &lt;em&gt;not&lt;/em&gt; keep rising as you add concurrency. Two forces fight the linear ideal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;contention&lt;/strong&gt; — waiting for shared resources — which makes throughput flatten into a plateau;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;coherency&lt;/strong&gt; — the cost of keeping everything consistent across workers — which is &lt;em&gt;quadratic&lt;/em&gt;, and eventually pulls throughput back &lt;em&gt;down&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Written out, the whole thing is one small formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    λ · N
   X(N) = ──────────────────────────────
          1 + α(N − 1) + β·N(N − 1)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a set of dependencies. &lt;code&gt;N&lt;/code&gt; is the only thing &lt;em&gt;you&lt;/em&gt; control (concurrency — workers, parallel requests, active DB sessions). The other three are fixed properties of the system you're trying to discover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;λ (lambda) — the ideal slope.&lt;/strong&gt; Throughput of a single worker. The numerator &lt;code&gt;λ·N&lt;/code&gt; is the fantasy world where doubling workers doubles throughput. Everything below the line is the tax on that fantasy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;α (alpha) — contention.&lt;/strong&gt; The &lt;code&gt;α(N − 1)&lt;/code&gt; term grows &lt;em&gt;linearly&lt;/em&gt; with N. It's the share of work that can't be parallelized — a lock, a shared resource, a serial section. As N climbs, this drags throughput toward a ceiling of ~&lt;code&gt;λ/α&lt;/code&gt;: the plateau. (On its own, this term &lt;em&gt;is&lt;/em&gt; Amdahl's Law.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;β (beta) — coherency.&lt;/strong&gt; The &lt;code&gt;β·N(N − 1)&lt;/code&gt; term grows &lt;em&gt;quadratically&lt;/em&gt; (≈ &lt;code&gt;βN²&lt;/code&gt;). It's the cost of workers keeping each other consistent — coordination, cache coherence, cross-talk. Invisible at low N; past a point it dominates the denominator and throughput actually &lt;strong&gt;falls&lt;/strong&gt;. This is the retrograde — the thing Amdahl's Law misses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How the terms trade off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;α = 0, β = 0&lt;/code&gt; → &lt;code&gt;X = λN&lt;/code&gt;. Perfect linear scaling. (Nobody lives here.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;α &amp;gt; 0, β = 0&lt;/code&gt; → a plateau. Diminishing returns, but never &lt;em&gt;worse&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;β &amp;gt; 0&lt;/code&gt; → the curve turns over. The peak sits at &lt;strong&gt;N* = √((1 − α) / β)&lt;/strong&gt; — push concurrency past N* and you go backwards.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;α&lt;/code&gt; decides &lt;em&gt;where&lt;/em&gt; it flattens, and &lt;code&gt;β&lt;/code&gt; decides &lt;em&gt;whether and where&lt;/em&gt; it bends back down. For us &lt;code&gt;α&lt;/code&gt; was ~0 (no locks left — it really was all CPU) and &lt;code&gt;β&lt;/code&gt; was small but nonzero (the coordination cost of ~1,000 backends fighting over 8 cores), which put &lt;code&gt;N*&lt;/code&gt; right around the core count — exactly why piling on more pods stopped helping and started hurting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway: you can &lt;em&gt;model&lt;/em&gt; this instead of guessing
&lt;/h2&gt;

&lt;p&gt;This is the bit I'm most excited about, and the reason I'm writing at all.&lt;/p&gt;

&lt;p&gt;When you need to know "how far will this scale?", there are usually three moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Guess / linearly extrapolate.&lt;/strong&gt; "10 pods gave X, so 100 pods will give 10X." This is just wrong the moment you're past the knee — linear extrapolation predicts up-and-to-the-right forever, while the real curve is bending down. It's confidently, dangerously incorrect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load test.&lt;/strong&gt; Legitimate, but expensive and slow — spin up infrastructure, generate realistic load, run it many times, babysit it. Often you don't have the time or the environment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model it.&lt;/strong&gt; Take a handful of real measurements you already have, fit the USL curve, and &lt;em&gt;predict the whole thing&lt;/em&gt; — where throughput peaks, where it goes retrograde, whether adding capacity will even help.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That third option barely existed in my head before this. You don't always need a full load-testing rig to answer a scaling question. With a few honest data points and a two-parameter model, you can get the &lt;strong&gt;shape&lt;/strong&gt;: does it plateau? does it retrograde? where's the sweet spot? That's usually the exact thing you're trying to decide.&lt;/p&gt;

&lt;p&gt;A caveat, so I don't oversell it: a model is only as good as the data you feed it, and a clean fit really wants a few well-spread measurements (a small controlled ramp beats one noisy production spike). The absolute numbers I got were rough. But even a rough fit told me the thing that mattered — &lt;em&gt;this system peaks near its core count and gets worse if you push past it&lt;/em&gt; — which is what turned "let's just add more pods / a bigger DB" into "we have a connection-management problem, and the fix is pooling."&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;I want to be clear that I've only scratched the surface — the two talks above (and Neil Gunther's work behind the USL) go far deeper than I can. But the shift in perspective was worth the detour: &lt;strong&gt;measure a little, model, predict&lt;/strong&gt; — instead of guessing with a straight line, or brute-forcing with a load test. For a scaling decision, sometimes the cheapest useful answer is a curve you fit on the back of an envelope.&lt;/p&gt;

</description>
      <category>database</category>
      <category>postgres</category>
    </item>
    <item>
      <title>DNS is weird inside k8s on AWS, Part 2: the layer I didn't know existed</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Sun, 12 Jul 2026 11:44:03 +0000</pubDate>
      <link>https://dev.to/harishteens/dns-is-weird-inside-k8s-on-aws-part-2-the-layer-i-didnt-know-existed-3dcp</link>
      <guid>https://dev.to/harishteens/dns-is-weird-inside-k8s-on-aws-part-2-the-layer-i-didnt-know-existed-3dcp</guid>
      <description>&lt;p&gt;&lt;em&gt;A follow-up to &lt;a href="https://dev.to/harishteens/dns-is-weird-inside-k8s-on-aws-570c"&gt;Part 1&lt;/a&gt;, which laid out the three usual suspects for DNS timeouts inside a Kubernetes pod on EKS: &lt;code&gt;ndots&lt;/code&gt; amplification, NodeLocal DNS caching, and the per-ENI 1024 pps limit on the VPC resolver. Part 1 ended with the line "proving which link in the chain is actually responsible takes measurement, not assumption." This is what happened when I did the measurement — and found a fourth link that isn't in most write-ups.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;The symptom was familiar to anyone who's chased flaky Kubernetes DNS. A worker pod, blasting a modest number of outbound requests, would occasionally log this exact line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;read udp 100.99.139.35:41290-&amp;gt;&lt;/span&gt;169.254.20.10:53: i/o &lt;span class="nb"&gt;timeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It wasn't a firehose. Some pods hit it dozens of times an hour, others none. Restarting the offending pod usually cleared it for a few minutes, then it came back.&lt;/p&gt;

&lt;p&gt;Part 1's framework said: figure out which of the three layers is dropping packets. So I did that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the measurement said (and what it didn't)
&lt;/h2&gt;

&lt;p&gt;The three-layer diagnostic worked exactly as advertised — for the layers it covers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1 — &lt;code&gt;ndots&lt;/code&gt;.&lt;/strong&gt; Confirmed the pod's &lt;code&gt;/etc/resolv.conf&lt;/code&gt; had the classic &lt;code&gt;options ndots:5&lt;/code&gt; with four search domains, so each logical lookup for something like &lt;code&gt;example.com&lt;/code&gt; was becoming ~10 packets (five names, each with A + AAAA). Setting &lt;code&gt;ndots:1&lt;/code&gt; cut the amplification by 5×. Timeouts still happened.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2 — NodeLocal DNS.&lt;/strong&gt; The DaemonSet was running fine on every node. &lt;code&gt;coredns_forward_healthcheck_broken_total&lt;/code&gt; was zero. Forward latency to upstream was under 32 ms at the 99th percentile. Cache miss rate was healthy. No restarts, no OOMs, no &lt;code&gt;ratelimit&lt;/code&gt; plugin even loaded. If NodeLocal was the culprit, its own metrics would say so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — EC2 ENI 1024 pps cap.&lt;/strong&gt; This is the tricky one because AWS drops the packets silently. But &lt;code&gt;ethtool -S &amp;lt;primary ENI&amp;gt; | grep linklocal_allowance_exceeded&lt;/code&gt; on the affected nodes returned zero, even during a spike window. No ENI cap was being hit.&lt;/p&gt;

&lt;p&gt;So the packet counts I could measure said: none of the three layers Part 1 covered were the actual problem.&lt;/p&gt;

&lt;p&gt;And yet — pods were still logging &lt;code&gt;169.254.20.10:53: i/o timeout&lt;/code&gt; at the same rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question a DevOps engineer asked me
&lt;/h2&gt;

&lt;p&gt;I posted the metrics in a Slack channel. Someone I hadn't worked with before replied with a single line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This seems to be pointing to istio DNS capture I think. Can be a candidate to disable entirely.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I didn't know what Istio ambient DNS capture was. That turned out to be the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layer that was hiding in plain sight
&lt;/h2&gt;

&lt;p&gt;If you're running Istio in &lt;strong&gt;ambient mode&lt;/strong&gt; (as opposed to the older sidecar mode), you have a component called &lt;strong&gt;ztunnel&lt;/strong&gt; running as a DaemonSet on every node. Ambient mode is the "no sidecars" flavor of the service mesh — instead of a proxy container in every pod, the mesh functions live in ztunnel and get applied to pod traffic transparently at the node level.&lt;/p&gt;

&lt;p&gt;One of the things ztunnel does — if the feature is on — is &lt;strong&gt;intercept DNS syscalls from pods&lt;/strong&gt; and answer them itself for known cluster services, forwarding the rest onward. This is the "DNS capture" feature, and it's meant to help with things like ServiceEntry resolution and consistent DNS behavior across the mesh.&lt;/p&gt;

&lt;p&gt;The catch: it inserts an extra hop between your pod and NodeLocal DNS.&lt;/p&gt;

&lt;p&gt;Where Part 1's mental model was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pod ──► 169.254.20.10 (NodeLocal DNS) ──► kube-dns / VPC resolver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With ambient DNS capture on, the actual path is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pod ──► ztunnel (DNS capture)  ──► 169.254.20.10 ──► kube-dns / VPC resolver
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That extra hop has its own concurrency limits, its own timeouts, its own queue, its own bugs. And crucially: &lt;strong&gt;if ztunnel drops a UDP packet, the client-visible symptom is identical to what you'd see if NodeLocal itself dropped it.&lt;/strong&gt; The pod's syscall returns &lt;code&gt;i/o timeout&lt;/code&gt; on &lt;code&gt;169.254.20.10:53&lt;/code&gt;, because from the pod's perspective, that's the address it was talking to. The interception is invisible.&lt;/p&gt;

&lt;p&gt;This is why every metric I could pull from NodeLocal or the VPC resolver came back clean. The drop was happening &lt;em&gt;before&lt;/em&gt; the packet ever left ztunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line fix
&lt;/h2&gt;

&lt;p&gt;Istio has an annotation for exactly this situation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ambient.istio.io/dns-capture&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;false"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Applied to a workload pod, ztunnel stops intercepting that pod's DNS syscalls. Traffic goes straight from the pod to &lt;code&gt;169.254.20.10&lt;/code&gt; (NodeLocal), exactly like Part 1's diagram assumed.&lt;/p&gt;

&lt;p&gt;We rolled the annotation out chart-wide (helm values under &lt;code&gt;default.pod.annotations&lt;/code&gt; so every service inherits it), redeployed, and repushed a batch of 100 domains through the crawler to stress-test.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;i/o timeout&lt;/code&gt; log stream went to zero and stayed there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check if this is you
&lt;/h2&gt;

&lt;p&gt;Three quick probes, no permissions you don't already have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Is your namespace enrolled in ambient?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get ns &amp;lt;your-namespace&amp;gt; &lt;span class="nt"&gt;-o&lt;/span&gt; yaml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'ambient|dataplane-mode'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for &lt;code&gt;istio.io/dataplane-mode: ambient&lt;/code&gt; on the namespace or the pod. If that label is present, ambient is active on your workload and DNS capture may be intercepting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Is ztunnel running on your nodes?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; istio-system get ds ztunnel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that DaemonSet exists and has running pods on your nodes, DNS capture is a possibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Compare error rate before and after the annotation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Put the annotation on one deployment, redeploy just that one, and Loki-count &lt;code&gt;read udp&lt;/code&gt; timeouts before-vs-after over a 1-hour window. If the annotation is the difference-maker, you'll see it drop off cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is worth writing down
&lt;/h2&gt;

&lt;p&gt;The three layers Part 1 covered — &lt;code&gt;ndots&lt;/code&gt;, NodeLocal, ENI cap — are load-bearing knowledge for anyone running Kubernetes DNS on AWS. They're in every good post about the topic.&lt;/p&gt;

&lt;p&gt;But if your cluster runs a service mesh with a DNS interception feature, that mesh becomes a fourth layer in the chain, and it can produce the exact same client-visible symptom as the other three. Its metrics live in a different namespace, its logs are formatted differently, and the pod itself has no idea it's being intercepted.&lt;/p&gt;

&lt;p&gt;The lesson isn't "Istio ambient DNS capture is bad." It's not — it exists for good reasons around ServiceEntry resolution and mesh-consistent behavior. The lesson is that the DNS path from your pod to the upstream resolver &lt;strong&gt;may not be the path you think it is&lt;/strong&gt;, and the way to find out is to enumerate every DaemonSet-scoped component in the request path, not just the ones in Part 1's diagram.&lt;/p&gt;

&lt;p&gt;If I was writing the diagnostic framework from scratch today, I'd add a Layer 0 to the list before you look at ndots:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before assuming your pod talks directly to &lt;code&gt;169.254.20.10&lt;/code&gt;, confirm that assumption.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's it. That's the whole finding. Everything else — the metrics you inspected, the packet-count math, the &lt;code&gt;ethtool&lt;/code&gt; counters — is downstream of that one question.&lt;/p&gt;

&lt;h2&gt;
  
  
  What silent drops teach you
&lt;/h2&gt;

&lt;p&gt;Every layer in the DNS chain that I care about has the same underlying failure mode: it drops a packet without telling the client. The pod's UDP socket has no way to distinguish "the packet reached the resolver and got no reply" from "the packet was dropped one hop away." Both surface as &lt;code&gt;i/o timeout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That means diagnostic technique matters more than any single fix. When a timeout can come from four different layers and none of them log an error, you have to work by process of elimination:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Prove Layer 1 (ndots) by counting search-domain queries. Reduce and re-measure.&lt;/li&gt;
&lt;li&gt;Prove Layer 2 (NodeLocal) via its own /metrics. Watch for spikes in &lt;code&gt;forward_request_duration_seconds&lt;/code&gt; at the 99th percentile.&lt;/li&gt;
&lt;li&gt;Prove Layer 3 (ENI cap) via &lt;code&gt;linklocal_allowance_exceeded&lt;/code&gt;. Nonzero = you hit it, zero = you didn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove Layer 0 (mesh interception) exists at all.&lt;/strong&gt; Check for &lt;code&gt;ztunnel&lt;/code&gt; on your nodes and the ambient label on your namespace before any of the above.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a good six weeks I skipped step 4 because it wasn't in my mental model. Adding it saved me the next six.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 1 covered the three DNS layers everyone talks about. This one is the one nobody mentioned, because for anyone not running Istio ambient it doesn't exist. If you are running it, this annotation may be the one-line change you didn't know you needed. If you're not, the meta-lesson still applies: when a chain of components each fails silently, the answer isn't more metrics — it's an accurate diagram.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>dns</category>
      <category>microservices</category>
    </item>
    <item>
      <title>How Avoiding Locking in Postgres Helped Us Get More Throughput</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Thu, 02 Jul 2026 15:34:58 +0000</pubDate>
      <link>https://dev.to/harishteens/how-avoiding-locking-in-postgres-helped-us-get-more-throughput-37p9</link>
      <guid>https://dev.to/harishteens/how-avoiding-locking-in-postgres-helped-us-get-more-throughput-37p9</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for &lt;a href="https://dev.to/bugsmash"&gt;DEV's Summer Bug Smash: Smash Stories&lt;/a&gt; powered by &lt;a href="https://sentry.io/" rel="noopener noreferrer"&gt;Sentry&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The observation
&lt;/h2&gt;

&lt;p&gt;One of our services is a distributed worker fleet that builds a large &lt;strong&gt;tree&lt;/strong&gt;&lt;br&gt;
in Postgres (Aurora, &lt;code&gt;db.r6g.xlarge&lt;/code&gt;, 4 vCPU, single writer). Each work item a&lt;br&gt;
worker processes yields child records, and every child is upserted as a node&lt;br&gt;
under its parent in the tree.&lt;/p&gt;

&lt;p&gt;Under real load — about &lt;strong&gt;90 jobs running concurrently across 20 pods&lt;/strong&gt; — the&lt;br&gt;
Aurora writer pegged at &lt;strong&gt;~96% CPU&lt;/strong&gt; and stayed there. Throughput collapsed: work&lt;br&gt;
backed up, the queue grew, and pods sat idle waiting on the database instead of&lt;br&gt;
doing work. The dashboard showed the usual trio of alarms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU ~96%&lt;/strong&gt;, sustained&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Elevated WriteIOPS&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;~565 database connections&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Rising lock-wait time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those is a &lt;em&gt;symptom&lt;/em&gt;. The interesting question was which one was the&lt;br&gt;
cause — and, crucially, which were just downstream of something else.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we deliberately did &lt;em&gt;not&lt;/em&gt; do
&lt;/h2&gt;

&lt;p&gt;When a database is on fire, the reflexes are well-worn:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"565 connections — add a connection pooler, or a bigger instance, or cap the
pool."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"High WriteIOPS and table bloat — crank up autovacuum, maybe a &lt;code&gt;VACUUM FULL&lt;/code&gt;."&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"CPU's at 96% — scale the instance up a tier."&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is a plausible-sounding fix, and each one treats a symptom:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bigger hardware&lt;/strong&gt; buys headroom that linear growth eats straight back. We're
targeting 3× the load; a one-tier bump is a few weeks of runway, not a fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggressive vacuum&lt;/strong&gt; fights dead tuples we hadn't yet proven were the problem —
and vacuum itself burns IO and CPU, so "just vacuum harder" can make a
CPU-bound box &lt;em&gt;worse&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Connection caps / poolers&lt;/strong&gt; move the queue from inside Postgres to in front of
it. If the work per query is the problem, throttling connections just makes
clients wait in a different place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these answers the only question that matters: &lt;strong&gt;why is the CPU pinned?&lt;/strong&gt;&lt;br&gt;
So we didn't touch any of them until we knew.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1 — Read the waits, not the vitals (Performance Insights)
&lt;/h2&gt;

&lt;p&gt;Instead of guessing from CPU/IOPS/connections, we opened &lt;strong&gt;Performance Insights&lt;/strong&gt;&lt;br&gt;
and looked at &lt;strong&gt;Top Waits&lt;/strong&gt;. The dominant wait was not query execution, not disk&lt;br&gt;
I/O, not client wait. It was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LWLock:MultiXactOffsetBuffer   (with MultiXactGen close behind)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is an extremely specific fingerprint, and it changed everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MultiXacts&lt;/strong&gt; are how Postgres represents &lt;em&gt;more than one transaction holding a&lt;br&gt;
lock on the same row at the same time&lt;/em&gt;. The most common way to create them is a&lt;br&gt;
flood of &lt;strong&gt;&lt;code&gt;FOR KEY SHARE&lt;/code&gt;&lt;/strong&gt; locks on a shared row. And who takes &lt;code&gt;FOR KEY SHARE&lt;/code&gt;&lt;br&gt;
locks, implicitly, on every write? &lt;strong&gt;Foreign keys.&lt;/strong&gt; When you insert a child row,&lt;br&gt;
Postgres locks the referenced parent row &lt;code&gt;FOR KEY SHARE&lt;/code&gt; for the duration of the&lt;br&gt;
transaction, to guarantee the parent can't vanish underneath you.&lt;/p&gt;

&lt;p&gt;Our hot table &lt;code&gt;tree_nodes&lt;/code&gt; had three foreign keys:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;FK column&lt;/th&gt;
&lt;th&gt;references&lt;/th&gt;
&lt;th&gt;who it locks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;parent_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tree_nodes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the parent node — contended by all its siblings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;source_node_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tree_nodes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the node a child was derived from&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;job_id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jobs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;one row per job — locked by &lt;em&gt;every&lt;/em&gt; insert for that job&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under concurrency this is a worst case. Many inserts share a parent (and &lt;em&gt;all&lt;/em&gt;&lt;br&gt;
inserts for a job share the single &lt;code&gt;jobs&lt;/code&gt; row), so multiple transactions took a&lt;br&gt;
&lt;code&gt;FOR KEY SHARE&lt;/code&gt; lock on the &lt;em&gt;same&lt;/em&gt; tuple simultaneously. Postgres had to allocate&lt;br&gt;
a &lt;strong&gt;MultiXact&lt;/strong&gt; to track the set of lockers, rewrite the locked tuple's &lt;code&gt;xmax&lt;/code&gt; on&lt;br&gt;
every new locker, and push that through the MultiXact &lt;strong&gt;SLRU&lt;/strong&gt; buffers — allocate,&lt;br&gt;
look up, rewrite, WAL, repeat. That SLRU churn &lt;em&gt;was&lt;/em&gt; the 96% CPU.&lt;/p&gt;

&lt;p&gt;The connections and the WriteIOPS were &lt;strong&gt;downstream&lt;/strong&gt; of this: transactions piled&lt;br&gt;
up because they were serializing on the same rows, and the tuple rewrites drove&lt;br&gt;
the writes. Fix the lock and both recede on their own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; drop the three FK &lt;em&gt;constraints&lt;/em&gt;. Integrity is guaranteed by the&lt;br&gt;
workers' own ordering — a parent node is always written before its child, and&lt;br&gt;
&lt;code&gt;job_id&lt;/code&gt; always points at a live row we just created. The &lt;strong&gt;indexes stay&lt;/strong&gt;&lt;br&gt;
(it's the &lt;em&gt;constraint&lt;/em&gt; that takes the lock, not the index), so reads are&lt;br&gt;
unaffected.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Result: MultiXact waits went to &lt;strong&gt;zero&lt;/strong&gt;, and CPU came off the ceiling — even at&lt;br&gt;
the same 90-job, full-fan-out concurrency that had pinned it before.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Step 2 — A suspicious calls-to-rows ratio (a tiny cache)
&lt;/h2&gt;

&lt;p&gt;With the biggest fire out, we went back to Performance Insights and looked at the&lt;br&gt;
top statements. The busiest INSERT told on itself:&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;tree_nodes&lt;/span&gt; &lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;
    &lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="err"&gt;≈&lt;/span&gt; &lt;span class="mi"&gt;1439&lt;/span&gt;   &lt;span class="k"&gt;rows&lt;/span&gt; &lt;span class="err"&gt;≈&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;78&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;per&lt;/span&gt; &lt;span class="n"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We were issuing ~1439 insert attempts to actually write ~3 rows. The workers&lt;br&gt;
re-touch the same shared ancestor nodes constantly — the interior nodes near the&lt;br&gt;
root are referenced by every item beneath them — so the overwhelming majority of&lt;br&gt;
inserts were &lt;strong&gt;conflict-and-discard&lt;/strong&gt;: take the unique-index leaf lock, find the&lt;br&gt;
existing row, do nothing. Correct, but not free — every attempt still touches the&lt;br&gt;
index and costs a round-trip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; a per-run &lt;strong&gt;in-memory seen-set&lt;/strong&gt; — a &lt;code&gt;sync.Map&lt;/code&gt; keyed by the node's&lt;br&gt;
full key, populated &lt;em&gt;after&lt;/em&gt; each commit. Before attempting an insert we check the&lt;br&gt;
set; if we've already persisted that node this run, we skip the database entirely.&lt;br&gt;
It costs a couple of megabytes for a 20k-node run and evaporates when the process&lt;br&gt;
exits — no schema, no eviction policy, no new failure mode.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Result: the INSERT ratio went from &lt;strong&gt;~1439 calls : 2.78 rows&lt;/strong&gt; to&lt;br&gt;
&lt;strong&gt;~131 calls : 129 rows&lt;/strong&gt; — we now almost only touch the database for rows that&lt;br&gt;
are genuinely new.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Step 3 — Batch the transactions (the classic one)
&lt;/h2&gt;

&lt;p&gt;Next wait to surface once the churn was gone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IO:XactSync   ≈ 85% of active samples
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IO:XactSync&lt;/code&gt; is commit / WAL-flush wait — the &lt;code&gt;fsync&lt;/code&gt; at the end of a transaction.&lt;br&gt;
We had been committing &lt;strong&gt;once per child record&lt;/strong&gt;. Thousands of tiny transactions&lt;br&gt;
meant thousands of fsyncs, and the commit itself became the bottleneck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; the oldest trick in the book — &lt;strong&gt;batch&lt;/strong&gt;. Collect all of a work&lt;br&gt;
item's child records and upsert them in a &lt;strong&gt;single transaction&lt;/strong&gt;: one&lt;br&gt;
&lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;COMMIT&lt;/code&gt; per item instead of one per child.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Result: &lt;code&gt;IO:XactSync&lt;/code&gt; dropped from &lt;strong&gt;~85%&lt;/strong&gt; to &lt;strong&gt;~32%&lt;/strong&gt; of active samples.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Same workload (cold re-run of 100 jobs, ramping to an 88-job peak):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writer CPU (peak)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;96%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~68%&lt;/strong&gt; during cold-start ramp, &lt;strong&gt;~20–30%&lt;/strong&gt; steady at peak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top wait&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MultiXactOffsetBuffer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;IO:XactSync&lt;/code&gt; (~32%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MultiXact waiters&lt;/td&gt;
&lt;td&gt;dominant&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INSERT calls : rows&lt;/td&gt;
&lt;td&gt;1439 : 2.78&lt;/td&gt;
&lt;td&gt;131 : 129&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;IO:XactSync&lt;/code&gt; share&lt;/td&gt;
&lt;td&gt;~85%&lt;/td&gt;
&lt;td&gt;~32%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DB connections&lt;/td&gt;
&lt;td&gt;~565&lt;/td&gt;
&lt;td&gt;~246&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We got more throughput &lt;strong&gt;without&lt;/strong&gt; a bigger instance, a connection pooler, or&lt;br&gt;
touching autovacuum — the three things we'd have reached for first if we'd trusted&lt;br&gt;
the vitals instead of the waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next (being honest about the tail)
&lt;/h2&gt;

&lt;p&gt;Optimizations unmask each other. With MultiXact and most commit-sync gone, the new&lt;br&gt;
top wait is a &lt;strong&gt;bursty &lt;code&gt;Lock:transactionid&lt;/code&gt;&lt;/strong&gt; on hot parent-counter rows&lt;br&gt;
(&lt;code&gt;children_total&lt;/code&gt;, &lt;code&gt;children_processed&lt;/code&gt;, …) that every child bump serializes on&lt;br&gt;
during the initial fan-out of high-fan-out parents. That's the next thing to&lt;br&gt;
attack — likely by deferring and coalescing those counter bumps to the end of each&lt;br&gt;
item's batch — and we only know it's next because, again, we read the waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Symptoms lie about causes.&lt;/strong&gt; Connections, IOPS, and CPU were all genuinely
high — and all three were &lt;em&gt;downstream&lt;/em&gt; of one lock. Fixing any of them directly
would have burned time and money without moving the real number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the wait events.&lt;/strong&gt; &lt;code&gt;MultiXactOffsetBuffer&lt;/code&gt; pointed us straight at
FK-induced &lt;code&gt;FOR KEY SHARE&lt;/code&gt; locking — a diagnosis we would &lt;em&gt;never&lt;/em&gt; have guessed
from a CPU graph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix in dependency order, and re-measure between each.&lt;/strong&gt; Kill the dominant
wait, look again, kill the next. The seen-set and the batching were cheap and
almost obvious — but only measurement told us they mattered, and in what order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The cheapest fixes are invisible until the expensive one is gone.&lt;/strong&gt; You can't
see the commit-sync problem while a lock is burning 96% of the CPU. Peel the
layers.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>performance</category>
      <category>postgres</category>
      <category>devchallenge</category>
      <category>bugsmash</category>
    </item>
    <item>
      <title>You can't test your way to certainty — so falsify instead</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Sun, 21 Jun 2026 08:27:19 +0000</pubDate>
      <link>https://dev.to/harishteens/you-cant-test-your-way-to-certainty-so-falsify-instead-2fek</link>
      <guid>https://dev.to/harishteens/you-cant-test-your-way-to-certainty-so-falsify-instead-2fek</guid>
      <description>&lt;p&gt;I'm currently building a PoC that rebuilds one of our core services from scratch — same job, completely different architecture. The design didn't come from a whiteboard moment; it accreted. I'd solved one issue in the existing system, then another, then another, and one day I looked back at the pile of fixes and something just spoke: &lt;em&gt;this doesn't seem right anymore.&lt;/em&gt; The old shape couldn't hold all the patches. So I started over.&lt;/p&gt;

&lt;p&gt;And that's where the trouble began.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fear of infinite examples
&lt;/h2&gt;

&lt;p&gt;As I implemented the new architecture, I worked the way most of us do: run it through an example, watch it break, add a tweak to handle that case, repeat. It felt productive. Each example I fixed made the system a little more capable.&lt;/p&gt;

&lt;p&gt;Then a quiet dread crept in: &lt;em&gt;what if this never ends?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I had 20 examples to test the PoC. But 20 was only the start — soon it would be 500, then eventually a million. I could feel myself on a treadmill. Every increment in scale would surface new cases, and I'd be patching forever, never able to point at the system and say "it's done." I couldn't see the end. The space of possible inputs was just too vast, and I was trying to map it one example at a time.&lt;/p&gt;

&lt;p&gt;The worst part wasn't the work. It was not knowing &lt;em&gt;how much&lt;/em&gt; work was left — or whether "left" even had a bottom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engineers have empirical bias
&lt;/h2&gt;

&lt;p&gt;Here's the thing I eventually named. We engineers make a huge number of design decisions based on the examples we happen to observe. We see a few hundred cases, spot the patterns, and bake those patterns into the system. Data scientists have a term for this trap: &lt;strong&gt;empirical bias&lt;/strong&gt; — over-fitting your conclusions to the sample you happened to look at.&lt;/p&gt;

&lt;p&gt;In a small project that's fine; you've basically seen everything. But in a large project, &lt;strong&gt;the unknowns are unknown.&lt;/strong&gt; Nobody has observed them. They're not on any list of edge cases because no one knows they exist yet.&lt;/p&gt;

&lt;p&gt;Which leads to the question that was actually keeping me up:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do you know the things that you don't know?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  A surprising answer: Karl Popper's falsification
&lt;/h2&gt;

&lt;p&gt;The answer didn't come from an engineering blog. It came from philosophy of science.&lt;/p&gt;

&lt;p&gt;Karl Popper was a 20th-century philosopher who asked what separates real science from things that merely &lt;em&gt;sound&lt;/em&gt; scientific. His answer was &lt;strong&gt;falsifiability&lt;/strong&gt;. A claim is scientific only if it makes a prediction that could, in principle, be proven &lt;em&gt;wrong&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;His famous example is swans. No matter how many white swans you observe, you can never prove the statement "all swans are white" — there could always be another swan around the corner. A thousand confirmations don't make it true. But a &lt;strong&gt;single black swan&lt;/strong&gt; disproves it instantly. So the logic of discovery is asymmetric: confirmation is weak, refutation is decisive.&lt;/p&gt;

&lt;p&gt;From this, Popper drew a sharp line. A good theory is a &lt;strong&gt;bold conjecture&lt;/strong&gt; that sticks its neck out — it tells you exactly what observation would kill it, and then survives every attempt to kill it. A theory that &lt;em&gt;can't&lt;/em&gt; be falsified by any conceivable observation — one that's compatible with literally any outcome — isn't strong, it's empty. That's not science. That's &lt;strong&gt;pseudo-science.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is, more or less, how mainstream science actually works. Anyone can cook up a theory from observations. What earns it the name "scientific" is that it can be falsified and hasn't been — yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this applies to engineering
&lt;/h2&gt;

&lt;p&gt;Once I saw it, I couldn't unsee the parallel. I had been doing the unscientific thing: accumulating confirming examples ("look, it handles this one too!") and hoping the pile would eventually feel tall enough. It never would. That's the white-swan game, and it has no end.&lt;/p&gt;

&lt;p&gt;The shift was to invert it. Instead of trying to confirm my system on more and more cases, &lt;strong&gt;try to falsify it.&lt;/strong&gt; Concretely:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write down every design decision, heuristic, and assumption&lt;/strong&gt; the system rests on. Make the implicit explicit. You can't test a belief you haven't articulated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For each one, name its black swan.&lt;/strong&gt; Ask: &lt;em&gt;what would disprove this? What exact thing has to happen for this decision to be wrong?&lt;/em&gt; If you genuinely can't think of anything that would break it, be suspicious — that's the pseudo-science smell. A decision that can't fail usually isn't saying anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrument the black swan.&lt;/strong&gt; Put a log line or a metric on the precise condition that would prove the assumption false. Now the system itself watches for its own counterexamples.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you're big enough, go hunting.&lt;/strong&gt; This is exactly what Netflix did with Chaos Monkey. Rather than wait for a server to die at 3 a.m. and hope the system coped, they wrote a tool that randomly kills production instances &lt;em&gt;during business hours&lt;/em&gt; — deliberately manufacturing the black swan while engineers are awake to watch it. The assumption under test is "we can lose any single instance and survive," and Chaos Monkey tries to falsify it on a schedule. It later grew into a whole "Simian Army" (latency injection, zone failures, and so on). The philosophy is pure Popper: don't trust that you're resilient because nothing has broken yet — actively try to break it, and let the failures find you before your customers do.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point isn't to predict every unknown. It's to set a tripwire on each assumption so the unknown announces itself the moment it arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The peace it gave me
&lt;/h2&gt;

&lt;p&gt;This reframe did something I didn't expect: it gave me peace.&lt;/p&gt;

&lt;p&gt;I stopped trying to imagine the million examples in advance, because I finally understood that I &lt;em&gt;couldn't&lt;/em&gt;, and that chasing them was the wrong game anyway. Instead: state the assumptions, instrument the black swans, and &lt;strong&gt;ship it.&lt;/strong&gt; Unless and until a tripwire fires, there's nothing to worry about. And when one does fire, I'll know instantly — and I'll fix it then, with a real counterexample in hand instead of a hypothetical fear.&lt;/p&gt;

&lt;p&gt;The treadmill stopped. Not because the unknowns went away, but because I'd outsourced the worrying to my logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Over to you
&lt;/h2&gt;

&lt;p&gt;I'm genuinely curious how others think about this — building large-scale systems that have to handle countless cases you can't enumerate up front. Does the falsification lens resonate, or do you think I'm forcing a philosophy metaphor onto something that needs plain engineering? Tell me where this breaks. And if a particular idea ever gave you peace in the face of the unknown, I'd love to hear it.&lt;/p&gt;

</description>
      <category>software</category>
      <category>architecture</category>
      <category>computerscience</category>
    </item>
    <item>
      <title>DNS is weird inside k8s on AWS</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Sun, 21 Jun 2026 05:18:46 +0000</pubDate>
      <link>https://dev.to/harishteens/dns-is-weird-inside-k8s-on-aws-570c</link>
      <guid>https://dev.to/harishteens/dns-is-weird-inside-k8s-on-aws-570c</guid>
      <description>&lt;p&gt;&lt;em&gt;A ~6 minute read — just three concepts that, once you know them, change how you reason about DNS inside a cluster.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;While chasing some DNS timeouts recently, I went down a rabbit hole and came out with three concepts I wish I'd known earlier. None of them is exotic, but together they explain a surprising amount of "why is DNS being weird" behaviour on Kubernetes-on-AWS.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ndots&lt;/code&gt;&lt;/strong&gt; — why one hostname lookup can become many DNS queries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NodeLocal DNS&lt;/strong&gt; — the per-node caching layer your queries actually hit first&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The EC2 per-ENI DNS packet limit&lt;/strong&gt; — a hard ceiling most people never hear about until they hit it&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  1. &lt;code&gt;ndots&lt;/code&gt;: one lookup is rarely one query
&lt;/h2&gt;

&lt;p&gt;Pull &lt;code&gt;/etc/resolv.conf&lt;/code&gt; from inside almost any Kubernetes pod and you'll see three interesting lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;nameserver&lt;/span&gt; &amp;lt;&lt;span class="n"&gt;cluster&lt;/span&gt;-&lt;span class="n"&gt;dns&lt;/span&gt;&amp;gt;
&lt;span class="n"&gt;search&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt;-&lt;span class="n"&gt;namespace&lt;/span&gt;.&lt;span class="n"&gt;svc&lt;/span&gt;.&lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;svc&lt;/span&gt;.&lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;cluster&lt;/span&gt;.&lt;span class="n"&gt;local&lt;/span&gt; &lt;span class="n"&gt;ec2&lt;/span&gt;.&lt;span class="n"&gt;internal&lt;/span&gt;
&lt;span class="n"&gt;options&lt;/span&gt; &lt;span class="n"&gt;ndots&lt;/span&gt;:&lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;nameserver&lt;/code&gt;&lt;/strong&gt; — where queries are sent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;search&lt;/code&gt;&lt;/strong&gt; — a list of suffixes the resolver may append to a name before giving up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;options ndots:N&lt;/code&gt;&lt;/strong&gt; — the rule that decides &lt;em&gt;when&lt;/em&gt; those suffixes get appended.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;ndots&lt;/code&gt; is the quiet one, and it's the one that surprises people. It says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"If the name being looked up contains **fewer than N dots&lt;/em&gt;&lt;em&gt;, treat it as a partial/relative name — try it with each search suffix appended **first&lt;/em&gt;&lt;em&gt;, and only try it as an absolute name if all of those fail."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Kubernetes defaults to &lt;strong&gt;&lt;code&gt;ndots:5&lt;/code&gt;&lt;/strong&gt;. That default exists for a good reason: service discovery. It lets your code say &lt;code&gt;redis&lt;/code&gt; or &lt;code&gt;payments.billing&lt;/code&gt; and have the resolver expand it to &lt;code&gt;redis.my-namespace.svc.cluster.local&lt;/code&gt;. Very convenient inside the cluster.&lt;/p&gt;

&lt;p&gt;The catch is what happens to &lt;strong&gt;real external hostnames&lt;/strong&gt;, which usually have fewer than 5 dots. Take &lt;code&gt;data.example.com&lt;/code&gt; — that's 2 dots. Since &lt;code&gt;2 &amp;lt; 5&lt;/code&gt;, the resolver assumes it's relative and walks the entire search list before trying the real thing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. data.example.com.my-namespace.svc.cluster.local   → NXDOMAIN
2. data.example.com.svc.cluster.local                → NXDOMAIN
3. data.example.com.cluster.local                    → NXDOMAIN
4. data.example.com.ec2.internal                     → NXDOMAIN
5. data.example.com                                  → the real answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;strong&gt;5 queries to resolve one name&lt;/strong&gt;, four of them guaranteed misses. And remember the resolver issues &lt;strong&gt;A (IPv4) and AAAA (IPv6) records in parallel&lt;/strong&gt;, so realistically you're looking at up to &lt;strong&gt;~10 DNS queries for a single hostname&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix is one line:&lt;/strong&gt; set &lt;strong&gt;&lt;code&gt;ndots:1&lt;/code&gt;&lt;/strong&gt;. Then any name with at least one dot is tried &lt;strong&gt;as an absolute name first&lt;/strong&gt;, and &lt;code&gt;data.example.com&lt;/code&gt; resolves in a single shot (well, two — A and AAAA). You only lose the convenience of short, suffix-less service names — which most apps that talk to FQDNs and external hosts don't rely on anyway.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# pod spec&lt;/span&gt;
&lt;span class="na"&gt;dnsConfig&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ndots&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Takeaway: on Kubernetes, the number of DNS &lt;em&gt;queries&lt;/em&gt; your app generates is not the number of &lt;em&gt;lookups&lt;/em&gt; it makes. &lt;code&gt;ndots&lt;/code&gt; is the multiplier, and the default of 5 is tuned for in-cluster discovery, not external traffic.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  2. NodeLocal DNS: the (optional) cache hop you might not know is there
&lt;/h2&gt;

&lt;p&gt;First, the baseline that &lt;em&gt;is&lt;/em&gt; effectively universal: every cluster has a &lt;strong&gt;cluster DNS service&lt;/strong&gt; — historically &lt;code&gt;kube-dns&lt;/code&gt;, and &lt;strong&gt;CoreDNS&lt;/strong&gt; by default since Kubernetes 1.13. It runs centrally as a Deployment (a handful of pods behind a &lt;code&gt;ClusterIP&lt;/code&gt; Service, usually at &lt;code&gt;.10&lt;/code&gt; of the service CIDR, e.g. &lt;code&gt;10.96.0.10&lt;/code&gt;), and every pod's &lt;code&gt;resolv.conf&lt;/code&gt; points its &lt;code&gt;nameserver&lt;/code&gt; at that Service IP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NodeLocal DNSCache is &lt;em&gt;not&lt;/em&gt; mandatory&lt;/strong&gt; — it's an optional add-on you opt into. When present, it inserts a per-node caching layer &lt;em&gt;between&lt;/em&gt; the pod and CoreDNS, so your pod resolves against the local node first instead of reaching across the network to the central CoreDNS Service on every query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌─────────┐  link-local IP     ┌──────────────────┐
  │  Pod    │ ─────────────────► │  NodeLocal DNS   │  (DaemonSet — one per node)
  │ (glibc) │   UDP :53          │  on-node cache   │
  └─────────┘                    └────────┬─────────┘
                                          │ on cache miss, forward
                          ┌───────────────┴────────────────┐
                          │                                 │
                  *.cluster.local                    everything else
                          │                                 │
                          ▼                                 ▼
                  ┌───────────────┐                ┌──────────────────┐
                  │ CoreDNS /     │                │  Upstream / VPC  │
                  │ kube-dns      │                │  resolver        │
                  │ (in-cluster)  │                │  (cloud-provided)│
                  └───────────────┘                └──────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it is and why it exists:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NodeLocal DNS&lt;/strong&gt; runs as a &lt;strong&gt;DaemonSet&lt;/strong&gt; — one instance per node — and listens on a &lt;strong&gt;link-local address&lt;/strong&gt; (an IP in the &lt;code&gt;169.254.0.0/16&lt;/code&gt; range, which is node-local and never routed off the box).&lt;/li&gt;
&lt;li&gt;Every pod on that node sends its DNS queries to this local instance &lt;strong&gt;first&lt;/strong&gt;. The query never leaves the node for a cache hit.&lt;/li&gt;
&lt;li&gt;This solves two real problems: it &lt;strong&gt;cuts latency&lt;/strong&gt; (no network hop for cached answers), and it &lt;strong&gt;avoids a known conntrack/UDP race&lt;/strong&gt; that caused intermittent 5-second DNS hangs when pods talked to a cluster-wide DNS service directly.&lt;/li&gt;
&lt;li&gt;On a &lt;strong&gt;cache miss&lt;/strong&gt;, NodeLocal forwards the query upstream — cluster-internal names (&lt;code&gt;*.svc.cluster.local&lt;/code&gt;) go to &lt;strong&gt;CoreDNS/kube-dns&lt;/strong&gt;; everything else goes to the &lt;strong&gt;upstream resolver&lt;/strong&gt; (on AWS, the VPC resolver / &lt;code&gt;AmazonProvidedDNS&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important mental model (when NodeLocal &lt;em&gt;is&lt;/em&gt; present): &lt;strong&gt;caching only helps for repeated names, and only within a TTL.&lt;/strong&gt; A positive answer is cached for its record's TTL; a negative answer (NXDOMAIN) is cached according to the zone's SOA. If you re-resolve the same host frequently, NodeLocal absorbs most of it. If your names or TTLs churn, more queries forward upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The EC2 per-ENI DNS packet limit (~1024 pps)
&lt;/h2&gt;

&lt;p&gt;This is the one almost nobody knows until it bites: &lt;strong&gt;EC2 caps DNS traffic at roughly 1024 packets per second per network interface (ENI).&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's specifically the path to the &lt;strong&gt;VPC resolver&lt;/strong&gt; (the &lt;code&gt;.2&lt;/code&gt; address / &lt;code&gt;AmazonProvidedDNS&lt;/code&gt;) — packets to the Route 53 Resolver are what's metered.&lt;/li&gt;
&lt;li&gt;The limit is &lt;strong&gt;per-ENI&lt;/strong&gt;, which in practice usually means &lt;strong&gt;per-node&lt;/strong&gt; (or per-pod, if pods get their own ENIs). It is &lt;strong&gt;not&lt;/strong&gt; a cluster-wide pool and it &lt;strong&gt;cannot be raised&lt;/strong&gt; via a support ticket — it's a fixed allowance.&lt;/li&gt;
&lt;li&gt;When you exceed it, AWS doesn't return an error. It &lt;strong&gt;silently drops the excess packets.&lt;/strong&gt; A dropped UDP query gets no response, so the client just waits and eventually reports &lt;code&gt;i/o timeout&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;How you'd actually &lt;em&gt;prove&lt;/em&gt; you're hitting it (rather than assuming): AWS exposes a per-ENI counter, &lt;code&gt;linklocal_allowance_exceeded&lt;/code&gt;, that increments each time a packet is dropped for crossing this limit. Read it on the node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ethtool &lt;span class="nt"&gt;-S&lt;/span&gt; &amp;lt;interface&amp;gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;allowance_exceeded
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few honest caveats, because this is exactly where people jump to conclusions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The counter only proves anything if it's &lt;strong&gt;increasing during the timeout window&lt;/strong&gt;. A reading of &lt;code&gt;0&lt;/code&gt;, or a value on an idle/freshly-provisioned node, tells you nothing.&lt;/li&gt;
&lt;li&gt;Hitting this limit requires &lt;strong&gt;genuinely high DNS packet rates&lt;/strong&gt;. A service that resolves a small set of hosts repeatedly — where NodeLocal caches the answers — typically won't get anywhere near 1024 pps. So before blaming this limit, confirm the packet rate is actually high and the counter is actually moving.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;ndots&lt;/code&gt; amplification from concept #1 is what makes this limit &lt;em&gt;easier&lt;/em&gt; to hit than you'd expect — because each logical lookup can be ~10 packets — but amplification of a low request rate is still a low request rate.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Takeaway: the ~1024 pps per-ENI DNS cap is real and unraiseable, and its failure mode (silent drops → timeouts) is genuinely confusing. But it's a ceiling you have to &lt;strong&gt;measure&lt;/strong&gt;, not assume — &lt;code&gt;linklocal_allowance_exceeded&lt;/code&gt; is the only thing that proves it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  How the three fit together
&lt;/h2&gt;

&lt;p&gt;These aren't three separate facts — they're a chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your app makes a DNS lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ndots&lt;/code&gt;&lt;/strong&gt; decides how many actual queries that becomes (default &lt;code&gt;ndots:5&lt;/code&gt; → potentially ~10×).&lt;/li&gt;
&lt;li&gt;Those queries hit &lt;strong&gt;NodeLocal DNS&lt;/strong&gt; first; cache hits stay on the node, misses forward to CoreDNS or the VPC resolver.&lt;/li&gt;
&lt;li&gt;The forwarded traffic to the VPC resolver is metered against the &lt;strong&gt;~1024 pps per-ENI limit&lt;/strong&gt;, and silently dropped past it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Knowing the chain is what lets you reason about a DNS problem instead of guessing: &lt;em&gt;is my query count inflated (&lt;code&gt;ndots&lt;/code&gt;)? is my cache actually being used (NodeLocal + TTLs)? am I genuinely hitting the packet ceiling (&lt;code&gt;linklocal_allowance_exceeded&lt;/code&gt;)?&lt;/em&gt; Three different questions, three different places to look.&lt;/p&gt;

&lt;p&gt;That's the toolkit. Where it points in any specific incident is a separate investigation — and proving which link in the chain is actually responsible takes measurement, not assumption.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>aws</category>
    </item>
    <item>
      <title>Your DNS check is lying to you</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Sat, 06 Jun 2026 03:08:29 +0000</pubDate>
      <link>https://dev.to/harishteens/your-dns-check-is-lying-to-you-297n</link>
      <guid>https://dev.to/harishteens/your-dns-check-is-lying-to-you-297n</guid>
      <description>&lt;p&gt;&lt;em&gt;Or: how a "this host is dead" verdict from a single &lt;code&gt;net.LookupHost&lt;/code&gt; call quietly broke our crawler, and what we did about it.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;We run a crawler that fetches tens of thousands of corporate websites a day from a datacenter. Before we spend any budget on a fetch — the actual HTTP request, the residential proxy hop, the S3 upload — we run a cheap &lt;strong&gt;reachability gate&lt;/strong&gt;. The job of the gate is one thing: answer the question &lt;em&gt;"is it even worth trying to fetch this host from here?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The first version of that gate was the obvious thing: resolve the host. If DNS returns an IP, the host exists. If it doesn't, mark the URL dead and move on.&lt;/p&gt;

&lt;p&gt;That gate was wrong often enough to matter. This is the story of the four ways it was wrong, and the gate we ended up with.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why "just resolve the host" isn't enough
&lt;/h2&gt;

&lt;p&gt;A naive reachability check has the shape:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Call &lt;code&gt;net.LookupHost&lt;/code&gt;. If it returns IPs, the host is reachable. If it errors, it isn't.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every clause in that sentence is a lie in production. Here are the four leaks we hit, in order of how painful they were.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leak 1 — CNAME chains the resolver doesn't finish in time
&lt;/h3&gt;

&lt;p&gt;A lot of corporate sites don't resolve directly. They sit behind a CDN, which sits behind a tenant-specific alias, which sits behind a regional load-balancer name. From DNS's point of view, that's a CNAME chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ir.bigcorp.com  →  bigcorp.cdnvendor.net  →  edge-eu-west-3.cdnvendor.net  →  A 203.0.113.42
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LookupHost&lt;/code&gt; is supposed to chase the chain transparently and hand you the final IP. It usually does. But "usually" hides two real failure modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The resolver chases the chain in series under a single deadline. A slow hop two-thirds of the way down eats the whole budget; the call returns a timeout, not the IP it would have found with another 200ms.&lt;/li&gt;
&lt;li&gt;An intermediate hop misbehaves — wrong record type, NXDOMAIN at a tier the resolver doesn't expect, a stub that's been decommissioned. The lookup fails &lt;em&gt;even though the host is registered and reachable through other paths&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both look identical to the caller: &lt;code&gt;LookupHost&lt;/code&gt; returned an error. The naive gate calls the host dead. The next day a human checks, the site loads fine in a browser, and we've burned a perfectly good URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leak 2 — datacenter IPs get blocked silently
&lt;/h3&gt;

&lt;p&gt;Plenty of origins explicitly drop connections from cloud IP ranges. From a residential connection they answer instantly; from our crawler's egress IP the TCP handshake just times out. There's no error code that says "I'm filtering you" — it looks exactly like a dead origin.&lt;/p&gt;

&lt;p&gt;DNS resolves fine in that case, so the naive gate passes the host through. The fetch then burns its full timeout on a connection that was never going to land. Worse, when we &lt;em&gt;do&lt;/em&gt; go through our residential proxy, the host fetches cleanly. The check we wanted to make — &lt;em&gt;"is the origin actually down or is it just down **for us&lt;/em&gt;&lt;em&gt;"&lt;/em&gt; — wasn't being made anywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leak 3 — TLS versions that our real client will refuse anyway
&lt;/h3&gt;

&lt;p&gt;Our production HTTP clients pin &lt;code&gt;MinVersion: TLS 1.2&lt;/code&gt;. Some long-tail origins still only negotiate TLS 1.0/1.1. DNS passes, the TCP handshake passes, the TLS handshake fails with a protocol-version alert, and we've spent a residential proxy request finding that out.&lt;/p&gt;

&lt;p&gt;If we'd noticed at the gate that the server's best offer was below our floor, we could have failed the URL immediately and saved the spend.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leak 4 — costing the proxy on requests that didn't need it
&lt;/h3&gt;

&lt;p&gt;Residential proxy requests are not free. Routing &lt;em&gt;every&lt;/em&gt; uncertain host through the proxy "just to be sure" turns a reachability check into one of the most expensive parts of the pipeline. Whatever we built had to use the proxy as a tiebreaker, not as a first resort.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we built instead
&lt;/h2&gt;

&lt;p&gt;A three-stage gate, ordered cheapest-and-most-certain first. Each stage can short-circuit the result; the proxy is only touched when the cheap stages genuinely can't tell.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 1 — DNS that walks the CNAME chain by hand
&lt;/h3&gt;

&lt;p&gt;The fast path is still &lt;code&gt;LookupHost&lt;/code&gt;. It works for the vast majority of hosts, including most CNAME chains, and it costs nothing extra.&lt;/p&gt;

&lt;p&gt;The slow path is what changes. When &lt;code&gt;LookupHost&lt;/code&gt; fails, we don't conclude "dead host" — we conclude "the resolver couldn't finish the chain in one shot." So we walk the chain ourselves: &lt;code&gt;LookupCNAME&lt;/code&gt;, advance one hop, try &lt;code&gt;LookupHost&lt;/code&gt; at that level, repeat. Several things fall out of this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A bounded hop count (we picked 5) protects us from CNAME loops and pathologically deep chains.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;visited&lt;/code&gt; set catches loops that don't show up as depth — a CNAME that points back to a name we've already seen.&lt;/li&gt;
&lt;li&gt;A canonical-name dead-end (CNAME points to itself, or to a name with no further records) returns the &lt;em&gt;original&lt;/em&gt; error, so genuine NXDOMAINs still surface as NXDOMAINs.&lt;/li&gt;
&lt;li&gt;An intermediate hop that resolves where the full chain timed out is treated as a pass. The reasoning: the original timeout was almost certainly cumulative, not terminal. If any level in the chain has a working A record, the host is alive.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This single change moved a measurable chunk of URLs out of the "dead" bucket.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 2 — TLS as a three-way verdict, not a boolean
&lt;/h3&gt;

&lt;p&gt;A direct TLS dial from our datacenter is doing two jobs at once. It's checking whether the server speaks a version we accept, and it's checking whether the server answers us at all. Those two outcomes need different handling, so we classify the dial as one of three things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Usable&lt;/strong&gt; — handshake completed, negotiated version ≥ TLS 1.2. Pass. The fetch will work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rejected&lt;/strong&gt; — the server sent a TLS alert, or negotiated below our floor. Fail fast, and importantly: do not waste a proxy probe on this. Our real client would be rejected the same way; the proxy can't fix a TLS-version mismatch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inconclusive&lt;/strong&gt; — a bare network error (timeout, connection refused, reset). From a datacenter IP this could mean a dead origin, &lt;em&gt;or&lt;/em&gt; an origin that filters cloud ranges. We don't know yet, so we defer to stage 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trick that makes stage 2 work is dialing with a &lt;strong&gt;permissive&lt;/strong&gt; &lt;code&gt;MinVersion&lt;/code&gt; (TLS 1.0). We want to see what the server &lt;em&gt;can&lt;/em&gt; do, then enforce our floor ourselves — otherwise the handshake fails the version check before we get to see what was actually negotiated, and "version too old" becomes indistinguishable from "didn't answer."&lt;/p&gt;

&lt;p&gt;Distinguishing a TLS alert from a network error needs a little care: timeouts are network, &lt;code&gt;tls.AlertError&lt;/code&gt; is a server-sent alert, anything else carrying a &lt;code&gt;tls:&lt;/code&gt; marker (record-header mismatch, plaintext where TLS was expected, protocol-version errors) is a TLS-layer rejection.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stage 3 — residential proxy as a tiebreaker
&lt;/h3&gt;

&lt;p&gt;Only the inconclusive case reaches here. The question we're answering is &lt;em&gt;"is the origin actually dead, or is it just dead **for our datacenter IP&lt;/em&gt;&lt;em&gt;?"&lt;/em&gt; — and the only way to answer it is to ask from a non-datacenter vantage point.&lt;/p&gt;

&lt;p&gt;We send a single GET through the residential proxy with a generous budget (~20s — a residential hop is much slower than a local dial, and a tight budget would itself produce false negatives). Reachability semantics, not success semantics: any HTTP response — 200, 301, 403, even 404 — proves the origin is up and answering, so it passes the gate. The gate fails only when the request never reaches a responding origin: a transport error, or a proxy-upstream 5xx (502/503/504, the way our proxy signals it couldn't reach the upstream).&lt;/p&gt;

&lt;p&gt;One retry with a small randomized backoff absorbs transient edge failures at the proxy without ballooning cost. If the proxy is unconfigured or its URL is malformed, the gate refuses to declare a host dead on the strength of our own broken config — it returns "cannot confirm" and lets the host through.&lt;/p&gt;




&lt;h2&gt;
  
  
  The flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                                ┌─────────────────────────────┐
                                │     ResolveHost(host)       │
                                └──────────────┬──────────────┘
                                               │
                                               ▼
                            ┌──────────────────────────────────┐
                            │   Stage 1: DNS                   │
                            │   LookupHost(host)               │
                            └──────────────┬───────────────────┘
                                           │
                       ┌───────────────────┴────────────────────┐
                       │                                        │
                       ▼ ok                                     ▼ error
              (continue to stage 2)                ┌────────────────────────┐
                       │                           │  Walk CNAME chain      │
                       │                           │  hop-by-hop, max 5     │
                       │                           │  - dedupe via visited  │
                       │                           │  - retry LookupHost    │
                       │                           │    at each hop         │
                       │                           └────────────┬───────────┘
                       │                                        │
                       │              ┌─────────────────────────┴──────────────┐
                       │              │ any hop resolves    chain dead / loop  │
                       │              ▼                     ▼                  │
                       │      (continue to stage 2)   FAIL (real NXDOMAIN /    │
                       │                              chain exhausted)         │
                       │                                                       │
                       ▼                                                       │
            ┌──────────────────────────────────────────┐                       │
            │ Stage 2: TLS probe                       │                       │
            │   tls.Dial host:443                      │                       │
            │   MinVersion = TLS 1.0 (permissive)      │                       │
            │   classify the outcome                   │                       │
            └─────────────┬────────────────────────────┘                       │
                          │                                                    │
       ┌──────────────────┼─────────────────────────┐                          │
       ▼ usable           ▼ rejected                ▼ inconclusive             │
  handshake ok,     TLS alert, or                 bare net error               │
  negotiated ≥ 1.2  negotiated &amp;lt; 1.2              (timeout/refused/reset)      │
       │                  │                            │                       │
       ▼                  ▼                            ▼                       │
     PASS              FAIL (fast,             ┌─────────────────────────┐     │
                       don't touch proxy)      │ Stage 3: proxy probe    │     │
                                               │   GET https://host/     │     │
                                               │   via residential proxy │     │
                                               │   retry once + jitter   │     │
                                               └─────────────┬───────────┘     │
                                                             │                 │
                                              ┌──────────────┴───────────┐     │
                                              ▼ origin answered          ▼     │
                                              (any HTTP status)     transport  │
                                              PASS                  err / 5xx  │
                                                                     FAIL      │
                                                                               │
                                                                               │
                          ◄────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  What we learned along the way
&lt;/h2&gt;

&lt;p&gt;A few generalisable things fell out of building this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reachability gate has to model where it's running from.&lt;/strong&gt; A check that's perfectly accurate from a laptop is wrong half the time from a datacenter. The vantage point is part of the system.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure has more states than success does.&lt;/strong&gt; "Dead" is at least three different things — DNS doesn't resolve, TLS won't handshake, network won't connect — and conflating them means you can't act on them differently. The three-way TLS outcome was the single biggest fix in this whole thing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Order checks by cost, and let cheap checks short-circuit expensive ones.&lt;/strong&gt; Stage 1 catches most dead hosts. Stage 2 catches version-incompatible hosts before we burn proxy budget. Stage 3 only runs when the first two genuinely couldn't tell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trust your own outputs more than the resolver's outputs.&lt;/strong&gt; Walking the CNAME chain by hand felt like working around the standard library, but the standard library is doing one thing (give the caller an IP) and we needed another (tell the caller whether the host exists at all). They're not the same question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration failures must not look like host failures.&lt;/strong&gt; A missing proxy URL is our problem, not the host's. The gate has to fail open on its own broken config — otherwise a config regression silently nukes thousands of perfectly good URLs.&lt;/p&gt;

&lt;p&gt;If you're building anything that touches the long tail of the public web from a datacenter, your "is this host alive" check is probably hiding two or three of these leaks. It's worth a look.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>How a 500 MB Buffer Killed Our Archival Job — And Why Streaming Fixed It</title>
      <dc:creator>Harish</dc:creator>
      <pubDate>Thu, 28 May 2026 13:23:43 +0000</pubDate>
      <link>https://dev.to/harishteens/how-a-500-mb-buffer-killed-our-archival-job-and-why-streaming-fixed-it-4iek</link>
      <guid>https://dev.to/harishteens/how-a-500-mb-buffer-killed-our-archival-job-and-why-streaming-fixed-it-4iek</guid>
      <description>&lt;h2&gt;
  
  
  The job that kept dying
&lt;/h2&gt;

&lt;p&gt;We run a nightly archival job that exports a few large Postgres tables to S3 as gzipped JSONL. On paper it's a humble piece of glue: read rows, serialize, compress, upload. In practice, it was getting OOM-killed by Kubernetes most nights on the larger tenants.&lt;/p&gt;

&lt;p&gt;The pod's memory limit was 1 GiB. The biggest table being archived had ~466K rows. At peak we measured the Go heap pushing past 700 MB before the kernel reaped us.&lt;/p&gt;

&lt;p&gt;That ratio — 466K small rows producing hundreds of megabytes of heap — was the smell. None of those rows is large. Something in the pipeline was hoarding all of them at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the original code looked like
&lt;/h2&gt;

&lt;p&gt;Roughly, the archival path was this:&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;rows&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;q&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetFetchQueueForArchive&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="n"&gt;domainID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c"&gt;// []Row, materialized&lt;/span&gt;

&lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;buf&lt;/span&gt; &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Buffer&lt;/span&gt;
&lt;span class="n"&gt;gz&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewWriter&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;buf&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;_&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="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;line&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;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&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;gz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;gz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&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;gz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PutObject&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="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PutObjectInput&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Key&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;bytes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewReader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;buf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Bytes&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;Read that with a memory profiler in your head and the bug is obvious:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;GetFetchQueueForArchive&lt;/code&gt; is a SQLC &lt;code&gt;:many&lt;/code&gt; query. It loops over &lt;code&gt;rows.Next()&lt;/code&gt; internally, scans every row into a struct, and appends to a slice. The whole result set lands in the heap before the function returns. For 466K rows of ~1 KB each, that's about &lt;strong&gt;500 MB&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Then we compress into &lt;code&gt;bytes.Buffer&lt;/code&gt; — another &lt;strong&gt;~100 MB&lt;/strong&gt; of gzipped bytes, in memory.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;s3.PutObject&lt;/code&gt; requires either a &lt;code&gt;Content-Length&lt;/code&gt; header or a seekable body. We satisfy that by handing it the fully-buffered bytes. So the whole compressed payload has to coexist in RAM with the source slice until at least the slice goes out of scope.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Peak heap ≈ rows slice + gzip buffer ≈ &lt;strong&gt;~600 MB&lt;/strong&gt;. Add the rest of the process (DB driver, AWS SDK, goroutine stacks) and we cross the 1 GiB ceiling. The pod dies. K8s restarts it. It dies again on the same table.&lt;/p&gt;

&lt;p&gt;The crucial observation: &lt;strong&gt;memory usage scales linearly with table size&lt;/strong&gt;, even though we never need more than one row at a time to do the work. That's a design bug, not a tuning problem. No amount of bumping the memory limit fixes it — the next tenant with twice the rows blows past whatever ceiling you pick.&lt;/p&gt;

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

&lt;p&gt;The goal: &lt;strong&gt;never hold more than one row's worth of data in Go memory&lt;/strong&gt;. The pipeline should look like a hose, not a tank. Bytes flow Postgres → JSON → gzip → S3 in a single producer/consumer pipeline, where each stage processes one small unit at a time and exerts backpressure on the previous stage.&lt;/p&gt;

&lt;p&gt;Concretely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pgx.Rows (server-side cursor)
   ↓  rows.Next() → scan one row → ~1 KB
json.Marshal(row)
   ↓                              ~1 KB line
gzip.Writer                       ~32 KB internal compression window
   ↓
io.Pipe (synchronous, zero-buffer)
   ↓
s3manager.Uploader                 5 MB per multipart part
   ↓
S3 (multipart upload)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Peak memory: 1 row + 32 KB gzip window + 5 MB part buffer × upload concurrency ≈ &lt;strong&gt;~25 MB&lt;/strong&gt;, regardless of whether the table has 1K rows or 100M rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four primitives that make this work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;pgx.Rows&lt;/code&gt; — a server-side cursor
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;pool.Query()&lt;/code&gt; returns a &lt;code&gt;Rows&lt;/code&gt; handle backed by a Postgres cursor. &lt;code&gt;rows.Next()&lt;/code&gt; pulls one row at a time across the wire; only the row currently being scanned lives in Go memory. This is fundamentally different from what SQLC's &lt;code&gt;:many&lt;/code&gt; generates, which loops and appends every row into a slice before returning. The streaming path bypasses SQLC and talks to the pool directly with the raw SQL string.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;io.Pipe&lt;/code&gt; — a synchronous in-memory pipe
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pipe&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;pr&lt;/code&gt; is a &lt;code&gt;Reader&lt;/code&gt;, &lt;code&gt;pw&lt;/code&gt; is a &lt;code&gt;Writer&lt;/code&gt;. Bytes written to &lt;code&gt;pw&lt;/code&gt; become readable from &lt;code&gt;pr&lt;/code&gt; — with &lt;strong&gt;no internal buffer&lt;/strong&gt;. It's a synchronization point, not a queue. &lt;code&gt;Write&lt;/code&gt; blocks until something reads &lt;code&gt;pr&lt;/code&gt;, and &lt;code&gt;Read&lt;/code&gt; blocks until something writes &lt;code&gt;pw&lt;/code&gt;. Producer and consumer rate-match each other naturally. If S3 upload stalls, our DB iteration stalls too, and memory stays flat.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;compress/gzip.Writer&lt;/code&gt; — streaming compression
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;gzip.NewWriter(w io.Writer)&lt;/code&gt; returns a writer that compresses on the fly. As you &lt;code&gt;Write()&lt;/code&gt; to it, it buffers up to ~32 KB internally, then flushes compressed bytes to the underlying writer. You never have to hold the whole input or output in memory.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;s3manager.Uploader&lt;/code&gt; — multipart upload from an &lt;code&gt;io.Reader&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The naive &lt;code&gt;s3.PutObject(Body: io.Reader)&lt;/code&gt; is a trap: it needs a &lt;code&gt;Content-Length&lt;/code&gt; or seek support to compute the body size, neither of which a true stream provides. &lt;code&gt;s3manager.Uploader&lt;/code&gt; reads the body in 5 MB chunks (configurable), uploads each as a multipart "part", and stitches them together with &lt;code&gt;CompleteMultipartUpload&lt;/code&gt;. Memory bound: 5 MB × concurrency (default 5) ≈ ~25 MB, well below pod limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The producer/consumer pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;Archiver&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;streamJSONL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ctx&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;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;s3Key&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sqlText&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;scanFn&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="n"&gt;pgx&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&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;rowCount&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pipe&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;// On exit, close pw with the producer's error (or nil) so the&lt;/span&gt;
        &lt;span class="c"&gt;// reader side learns the outcome.&lt;/span&gt;
        &lt;span class="k"&gt;defer&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="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="n"&gt;pw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CloseWithError&lt;/span&gt;&lt;span class="p"&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;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&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;gz&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewWriter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pw&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;gz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c"&gt;// flushes gzip trailer BEFORE pipe closes&lt;/span&gt;

        &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&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;Query&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="n"&gt;sqlText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&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;qErr&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;qErr&lt;/span&gt;
            &lt;span class="k"&gt;return&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;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&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;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;scanFn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rows&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;sErr&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sErr&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mErr&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;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&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;mErr&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mErr&lt;/span&gt;
                &lt;span class="k"&gt;return&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;wErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;wErr&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wErr&lt;/span&gt;
                &lt;span class="k"&gt;return&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;wErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;gz&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="n"&gt;wErr&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wErr&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;rowCount&lt;/span&gt;&lt;span class="o"&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;rErr&lt;/span&gt; &lt;span class="o"&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;Err&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;rErr&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rErr&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}()&lt;/span&gt;

    &lt;span class="c"&gt;// Hand pr to s3manager. Blocks until the producer finishes AND all&lt;/span&gt;
    &lt;span class="c"&gt;// parts are uploaded.&lt;/span&gt;
    &lt;span class="n"&gt;upErr&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s3Uploader&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UploadStream&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="n"&gt;s3Key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"application/gzip"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"content-encoding"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"gzip"&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;upErr&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="n"&gt;pr&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CloseWithError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;upErr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// tell producer to stop if still running&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"s3 upload failed: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;upErr&lt;/span&gt;&lt;span class="p"&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;rowCount&lt;/span&gt;&lt;span class="p"&gt;,&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;A few subtleties that look small but matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;defer gz.Close()&lt;/code&gt; is registered before &lt;code&gt;defer pw.Close()&lt;/code&gt; and runs first.&lt;/strong&gt; Gzip needs to flush its trailer bytes through &lt;code&gt;pw&lt;/code&gt;. If the pipe closed first, the gzip stream on the consumer side would be truncated and unreadable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CloseWithError&lt;/code&gt; on both ends.&lt;/strong&gt; If the producer hits a DB error mid-iteration, it closes &lt;code&gt;pw&lt;/code&gt; with that error. The consumer reading &lt;code&gt;pr&lt;/code&gt; sees &lt;code&gt;ErrClosedPipe&lt;/code&gt; wrapping the cause, and &lt;code&gt;s3manager&lt;/code&gt; aborts the multipart upload, sending &lt;code&gt;AbortMultipartUpload&lt;/code&gt; to S3 — no orphaned partial uploads. The same trick goes the other way: if the S3 upload fails, we &lt;code&gt;pr.CloseWithError(upErr)&lt;/code&gt; so the producer's next &lt;code&gt;gz.Write&lt;/code&gt; returns and the goroutine exits instead of hanging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No mutex needed.&lt;/strong&gt; &lt;code&gt;io.Pipe&lt;/code&gt; is its own synchronization primitive; the producer's writes serialize naturally against the consumer's reads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reading &lt;code&gt;rowCount&lt;/code&gt; after the call is safe&lt;/strong&gt; because &lt;code&gt;UploadStream&lt;/code&gt; only returns after &lt;code&gt;pr&lt;/code&gt; is fully drained, which only happens after the producer goroutine closes &lt;code&gt;pw&lt;/code&gt;. By the time we return, the producer is done writing to &lt;code&gt;rowCount&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the numbers looked like after
&lt;/h2&gt;

&lt;p&gt;Same 466K-row table, same pod, same 1 GiB memory limit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Before:&lt;/strong&gt; heap peaked around 700 MB; OOM-killed roughly 4 nights out of 5.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After:&lt;/strong&gt; heap held flat at ~30 MB through the whole archive. Zero OOM kills since deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Wall-clock time went up slightly (a couple of percent) because we no longer issue one fat &lt;code&gt;PutObject&lt;/code&gt; — but that's a fair trade for not getting murdered by the kernel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What streaming doesn't fix
&lt;/h2&gt;

&lt;p&gt;Worth being honest about the limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DB-side memory.&lt;/strong&gt; Some of the queries powering these archives do non-trivial joins. That's Postgres' problem, not the pod's. Streaming doesn't make the planner cheaper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry cost.&lt;/strong&gt; If the upload fails 90% of the way through, we re-iterate from row zero on retry. For 50M-row tables that's slow but still memory-flat. Idempotent multipart resumption is a follow-up, not a blocker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-memory tree builds elsewhere.&lt;/strong&gt; Other parts of the system still build whole trees in memory before serializing. They're small enough today that it's fine, but it's the same anti-pattern waiting to bite.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Whenever your data pipeline's memory usage scales with input size and you don't actually need random access to the data, you have a streaming bug waiting to happen. The fix is almost always the same set of primitives: a cursor on the source, an &lt;code&gt;io.Pipe&lt;/code&gt; for backpressure, a streaming codec in the middle, and a chunked uploader at the sink. Bound the working set to a few megabytes and the pipeline stops caring whether you throw a thousand rows at it or a billion.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>go</category>
      <category>kubernetes</category>
      <category>performance</category>
    </item>
  </channel>
</rss>
