<?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: Ashwin Sridhar</title>
    <description>The latest articles on DEV Community by Ashwin Sridhar (@ashwin_sridhar_koto7).</description>
    <link>https://dev.to/ashwin_sridhar_koto7</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%2F3957718%2Fe99f0cc3-66c1-4310-ba50-808f356ce7de.png</url>
      <title>DEV Community: Ashwin Sridhar</title>
      <link>https://dev.to/ashwin_sridhar_koto7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ashwin_sridhar_koto7"/>
    <language>en</language>
    <item>
      <title>How do you actually test for a failure that leaves no trace?</title>
      <dc:creator>Ashwin Sridhar</dc:creator>
      <pubDate>Fri, 04 Sep 2026 07:53:30 +0000</pubDate>
      <link>https://dev.to/ashwin_sridhar_koto7/how-do-you-actually-test-for-a-failure-that-leaves-no-trace-240c</link>
      <guid>https://dev.to/ashwin_sridhar_koto7/how-do-you-actually-test-for-a-failure-that-leaves-no-trace-240c</guid>
      <description>&lt;p&gt;&lt;em&gt;Code and results: &lt;a href="https://github.com/ashwin-sridhar/silent-failure-harness" rel="noopener noreferrer"&gt;github.com/ashwin-sridhar/silent-failure-harness&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There's a specific kind of bug I have a hard time explaining to people who haven't hit it. Not a crash. Not a stack trace. A test suite that's green, a system that looks "up," and a business event that just — isn't there. Nobody's system logged an error, because from its point of view nothing happened. That's not a metaphor. That's the literal failure mode.&lt;/p&gt;

&lt;p&gt;I wanted to know whether the pattern I'd designed around this was actually safe, or just safe-sounding. So I tried to break it on purpose.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "silent failure" actually means
&lt;/h2&gt;

&lt;p&gt;At-least-once delivery plus a crash at exactly the wrong instant produces a state where the sender believes the event was delivered and the receiver has no record it ever arrived — and both of them are technically correct, from where they're standing. There's no error anywhere in that story. That's what makes it hard to test for: you're not looking for an exception, you're looking for an absence.&lt;/p&gt;

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

&lt;p&gt;Two variants of a minimal webhook handler, and a way to kill the process on demand at an exact, repeatable point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Variant A — ack-then-persist
  1. Receive request
  2. Return 200
  3. Write event to the database

Variant B — persist-then-ack
  1. Receive request
  2. Write event to the database (INSERT ... ON CONFLICT DO NOTHING)
  3. Return 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The crash is a &lt;code&gt;SIGKILL&lt;/code&gt; sent to the process at a pinned point right after step 2 in each variant — not "at some point during the request," but deterministically after the ack or after the write, using an env flag. No graceful shutdown. The same kind of failure a host reboot or an OOM kill actually produces.&lt;/p&gt;

&lt;p&gt;For each trial: send the event, let the process kill itself, restart it clean, resend the identical event ID — exactly what a provider does when it never gets a 200. Then check the actual source of truth: how many rows exist for that event ID. Not the logs. The row count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1: ack-ordering is not a "usually" — it's unconditional
&lt;/h2&gt;

&lt;p&gt;50 trials per variant.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;variant&lt;/th&gt;
&lt;th&gt;lost&lt;/th&gt;
&lt;th&gt;correct&lt;/th&gt;
&lt;th&gt;duplicate&lt;/th&gt;
&lt;th&gt;/ 50&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ack-then-persist&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;persist-then-ack&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ack-then-persist lost the event on every single trial. The 200 went out, the process died before the insert ran, and there was no retry to save it — the provider saw a successful delivery and had no reason to send it again. Fifty for fifty, not a tendency.&lt;/p&gt;

&lt;p&gt;Persist-then-ack didn't lose one. One trial out of fifty is worth noting honestly rather than ignoring: the crash request itself returned a 200 anyway, meaning the kill didn't land in time to suppress the ack that trial — a timing artifact of the harness, not a case where the retry path saved a loss. It still counted as correct, since the write had already committed either way, but it's not evidence of the same mechanism as the other 49.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 2: the race I expected to see wasn't the race that was actually there
&lt;/h2&gt;

&lt;p&gt;I set up a second test for the dedup mechanism itself — ten concurrent requests, same event ID, fired at once, comparing a naive check-then-insert against an atomic &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt;. I expected check-then-insert to sometimes let a duplicate row through.&lt;/p&gt;

&lt;p&gt;It never did. Twenty trials, ten concurrent requests each, for both mechanisms:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;dedup&lt;/th&gt;
&lt;th&gt;exactly 1 row&lt;/th&gt;
&lt;th&gt;2+ rows&lt;/th&gt;
&lt;th&gt;/ 20&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;check-then-insert&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;atomic-conflict&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Zero duplicate rows, either way — but that's not the dedup logic being safe. That's &lt;code&gt;event_id TEXT UNIQUE&lt;/code&gt; at the schema level doing its job regardless of what the application code above it does. A second insert physically cannot land, no matter how the race plays out.&lt;/p&gt;

&lt;p&gt;The race still happened. It just didn't show up as a row — it showed up as an error. Across 200 individual concurrent requests against check-then-insert (20 trials × 10), 181 came back &lt;code&gt;200&lt;/code&gt; and 19 came back &lt;code&gt;500&lt;/code&gt;. Two goroutines both ran the &lt;code&gt;SELECT&lt;/code&gt;, both saw no existing row, both proceeded to &lt;code&gt;INSERT&lt;/code&gt; — one won, one hit the unique constraint it never checked for and blew up. Against atomic-conflict, all 200 requests came back &lt;code&gt;200&lt;/code&gt;. Same exact race, same ten-way collision — &lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; just absorbed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually means for you
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Persist before you ack. Not usually — always.&lt;/strong&gt; Fifty trials, zero exceptions, in either direction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put a real &lt;code&gt;UNIQUE&lt;/code&gt; constraint on your dedup key at the schema level, regardless of your application logic.&lt;/strong&gt; It's the thing that actually stopped a duplicate row from ever landing here, independent of whether the code above it was written carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer atomic &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; over check-then-insert — not because it prevents data corruption, the constraint already does that — but because it decides what happens to the losing side of a race.&lt;/strong&gt; Check-then-insert hands the loser a raw 500. From the sender's side, that looks like a failed delivery, which means another retry, which means another race. Atomic conflict resolves it cleanly on the first attempt.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The part where I admit something
&lt;/h2&gt;

&lt;p&gt;I built this test expecting to catch a data-integrity bug — duplicate rows from a sloppy dedup implementation. The schema-level constraint made that outcome structurally impossible before either mechanism got a chance to prove anything. What the test actually caught was a &lt;em&gt;behavioral&lt;/em&gt; difference, not a data one: whether the loser of an inevitable race gets an idempotent-feeling 200 or an ugly exception. That's a more useful thing to know than what I went in looking for, and I wouldn't have found it without running the concurrent version instead of trusting that "no duplicate rows" meant "no race."&lt;/p&gt;

&lt;p&gt;The constraint saved the data. It didn't save the response.&lt;/p&gt;

&lt;p&gt;Full harness and raw results if you want to run it yourself: &lt;a href="https://github.com/ashwin-sridhar/silent-failure-harness" rel="noopener noreferrer"&gt;github.com/ashwin-sridhar/silent-failure-harness&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webhook</category>
      <category>architecture</category>
      <category>distributedsystems</category>
    </item>
    <item>
      <title>Does Postgres RLS actually ruin performance? Let’s look at the data.</title>
      <dc:creator>Ashwin Sridhar</dc:creator>
      <pubDate>Fri, 29 May 2026 07:45:42 +0000</pubDate>
      <link>https://dev.to/ashwin_sridhar_koto7/does-postgres-rls-actually-ruin-performance-lets-look-at-the-data-24jf</link>
      <guid>https://dev.to/ashwin_sridhar_koto7/does-postgres-rls-actually-ruin-performance-lets-look-at-the-data-24jf</guid>
      <description>&lt;p&gt;There's a particular kind of conundrum that has derailed more architectural decisions than I care to count. You know the one - performance optimization. What do you trade off for what and in what scenario and what might it achieve. I had to sit with one of those conundrums recently. &lt;/p&gt;

&lt;p&gt;For a multi-tenant platform, I'd chosen Postgres Row-Level Security for tenant isolation — each tenant's rows locked behind a policy, enforced at the database level, no application code involved. Clean, elegant, one less thing to accidentally screw up in your ORM layer. Then I spent a weekend second-guessing myself. &lt;/p&gt;

&lt;p&gt;So I did what you should always do before making an architectural decision: I measured it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What RLS actually does (the one-sentence version)
&lt;/h2&gt;

&lt;p&gt;You attach a policy to a table. Every query against that table gets an invisible WHERE clause appended by Postgres, based on who's asking. A regular app user asking for rows gets only their rows. A superuser bypasses it entirely. That's it. The question is whether that invisible WHERE clause costs you anything meaningful at scale.&lt;/p&gt;




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

&lt;p&gt;1 million rows in a single table. One tenant owns roughly 10% of them (~100k rows). I ran 50 timed executions per condition, threw away the first 3 as warmup, and measured p50, p95, and p99 latency. (p95 means 95% of your queries finished faster than this number — it's your realistic bad day, not your theoretical worst case.)&lt;/p&gt;

&lt;p&gt;Four conditions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A — superuser,  no index    → RLS completely bypassed
B — app_role,   no index    → RLS active, planner on its own
C — superuser,  with index  → RLS bypassed, index benefit only
D — app_role,   with index  → RLS active + index (this is production)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three query types: a simple &lt;code&gt;LIMIT 100&lt;/code&gt; fetch, a filtered scan with a second condition, and a full &lt;code&gt;COUNT(*)&lt;/code&gt;. The idea was to cover the range from "barely touches the table" to "has to read everything."&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 1: RLS overhead is basically noise
&lt;/h2&gt;

&lt;p&gt;Compare A and B. Same hardware, same data, same queries — the only difference is whether RLS policies are being evaluated.&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.amazonaws.com%2Fuploads%2Farticles%2F0ywydb7gn9wgltu7gza4.jpeg" 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.amazonaws.com%2Fuploads%2Farticles%2F0ywydb7gn9wgltu7gza4.jpeg" alt=" " width="800" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At p95, A vs B differs by less than 2% across every query type. On the &lt;code&gt;count&lt;/code&gt; query — the heaviest one, which scans the entire table — A clocks in at 73.3ms and B at 74.9ms. That 1.6ms is Postgres evaluating your RLS policy. It is, to use a technical term, nothing.&lt;/p&gt;

&lt;p&gt;The performance concern, as it turns out is focussed on the wrong thing. Policy evaluation is not your bottleneck.&lt;/p&gt;




&lt;h2&gt;
  
  
  Finding 2: The index is doing all the work
&lt;/h2&gt;

&lt;p&gt;Now look at C and D. Same queries, but I've added an index on &lt;code&gt;tenant_id&lt;/code&gt;.&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.amazonaws.com%2Fuploads%2Farticles%2Fxc4wextqdrfvxmzzf5hs.jpeg" 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.amazonaws.com%2Fuploads%2Farticles%2Fxc4wextqdrfvxmzzf5hs.jpeg" alt=" " width="799" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;count&lt;/code&gt; query drops from ~73ms (A and B, no index) to 2.2ms (C) and 2.7ms (D). That's a &lt;strong&gt;26× speedup&lt;/strong&gt;. RLS overhead within the indexed conditions? Still less than 25%.&lt;/p&gt;

&lt;p&gt;Here's what's happening under the hood. Without the index, Postgres launches a parallel sequential scan — it reads every single row in the table and throws away the ones that don't belong to your tenant. With the index, it goes straight to your tenant's rows and in the &lt;code&gt;COUNT&lt;/code&gt; case, never even visits the heap at all.&lt;/p&gt;

&lt;p&gt;The EXPLAIN plans make this embarrassingly obvious:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Without index (condition A):&lt;/strong&gt;&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="go"&gt;Parallel Seq Scan on jobs
  Filter: (tenant_id = ...)
  Rows Removed by Filter: 299,876
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With index (condition D):&lt;/strong&gt;&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="go"&gt;Index Only Scan using idx_jobs_tenant_id
  Index Cond: (tenant_id = ...)
  Heap Fetches: 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;299,876 rows examined vs 0 heap fetches. The index doesn't just help — it changes the &lt;em&gt;shape&lt;/em&gt; of the query entirely.&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.amazonaws.com%2Fuploads%2Farticles%2Fucfcwl2af7crwuv1i99m.jpeg" 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.amazonaws.com%2Fuploads%2Farticles%2Fucfcwl2af7crwuv1i99m.jpeg" alt=" " width="799" height="396"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The nuance nobody talks about: secondary predicates
&lt;/h2&gt;

&lt;p&gt;Here's where it gets interesting. The filtered query — which adds a second condition on top of the tenant filter — barely improves with the index. About 42ms without, about 40ms with. A 5% improvement where the count query got 26×.&lt;/p&gt;

&lt;p&gt;Why? Because the second predicate forces Postgres to visit the actual heap rows to check the condition. The bitmap index scan narrows the candidate set using the tenant index, but it still has to physically read all ~11,000 heap blocks to evaluate the second filter. You can't index-only scan your way out of a heap fetch.&lt;/p&gt;

&lt;p&gt;The fix, if this matters for you, is a composite index: &lt;code&gt;(tenant_id, your_second_column)&lt;/code&gt;. That lets the planner push both conditions into the index scan and skip the heap entirely. I didn't test that here — that's a follow-up post — but the query plans point directly at it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this actually means for you
&lt;/h2&gt;

&lt;p&gt;Three things you can take away from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Index your tenant_id column.&lt;/strong&gt; Not optional. Without it, every aggregation query scans your entire table regardless of how tight your RLS policy is. With it, your database goes from doing 300k wasted row evaluations to zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Stop worrying about RLS overhead.&lt;/strong&gt; The policy evaluation cost is real but it's measured in microseconds. The architectural benefits — tenant isolation enforced at the database level, impossible to accidentally leak rows from a missing WHERE clause in your application code — are worth far more than 1.5ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Watch your secondary predicates.&lt;/strong&gt; If your common queries filter on more than just tenant_id, think about whether a composite index makes sense. The planner is smart but it can only use what you give it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The part where I admit something
&lt;/h2&gt;

&lt;p&gt;I'll be honest — I already knew the likely outcome before running this. The Postgres community broadly understands that RLS overhead is index-shaped, not policy-shaped. But "broadly understood" and "here are actual numbers from a real table at 1M rows" are different things.&lt;/p&gt;

&lt;p&gt;One caveat worth naming: this benchmark tests a simple single-condition policy — the kind that covers most multi-tenant SaaS use cases. Complex policies with subqueries or permission table joins are a different story, and not one this experiment speaks to. That's a follow-up for another day.&lt;/p&gt;

&lt;p&gt;Measure things. Then decide.&lt;/p&gt;




</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
      <category>rls</category>
    </item>
  </channel>
</rss>
