<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Jake Casto on Medium]]></title>
        <description><![CDATA[Stories by Jake Casto on Medium]]></description>
        <link>https://medium.com/@0x15f?source=rss-28ec4c580b52------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*lkpOksLcZL114xl2d_If-A.jpeg</url>
            <title>Stories by Jake Casto on Medium</title>
            <link>https://medium.com/@0x15f?source=rss-28ec4c580b52------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 05:42:49 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@0x15f/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[Shipping 103,802 Dots in 82 KB]]></title>
            <link>https://layers.engineering/shipping-103-802-dots-in-82-kb-b4f7d3a89779?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/b4f7d3a89779</guid>
            <category><![CDATA[engineering]]></category>
            <category><![CDATA[animation]]></category>
            <category><![CDATA[dot2]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Sun, 25 Oct 2026 15:42:25 GMT</pubDate>
            <atom:updated>2026-10-25T17:48:26.134Z</atom:updated>
            <content:encoded><![CDATA[<p><em>How we packed our hand plotted dot art into a very compact binary format, reduced geometry delivery by 93% and stopped rendering the background entirely when nothing was changing.</em></p><p>We do sell AI powered search and we use AI/LLMs for a whole host of things on a day to day basis at Layers but art generation is not one of them and that’s on purpose.</p><p>Every illustration on <a href="http://uselayers.com">uselayers.com</a> was drawn by Alberto Larizza. Alberto is one of my closest friends and a fantastic pattern designer. He’s from Tuscany and did pattern work for houses like Gucci in Italy prior to coming over to the US.</p><p>He drew it all in dots. 103,802 of them across 13 illustrations, each dot placed by hand over a number of months earlier in the year.</p><p>We’ve always wanted them to move. This month Sumit, one of our product designers, finally got around to it, and the prototype was beautiful:</p><p>Hover over one and the dots get pushed out of the way and then spring back. Tap it, and a shockwave goes through.</p><p>There was one problem: the prototype had an 8MB JSON file that had every dot from the SVG. I had to figure out how to feasibly get these into the browser and have it work for lower-end devices w/o any jitter or lag on the page.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*jYKwf5nkJqKT6Zq-8gY1dg.png" /><figcaption>All 13 Layers illustrations rendered from their hand-placed dots: merchandising, search, recommendations and other product marks on the top row, category illustrations on the bottom row.</figcaption></figure><h3>175 MB of Illustrator Exports</h3><p>Alberto did the Illustrator artwork. Sent over as SVG exports (65 files, a few zips at around 175MB) and 13 of those ended up on the site.</p><p>Illustrator doesn’t export a dot as a dot. You end up with a mixture of &lt;circle&gt;, &lt;ellipse&gt; and &lt;path&gt; elements and any dot that’s been clipped or expanded will come out as a little bezier path with its own fill attribute.</p><p>The security padlock is a perfect example: 2,510 circles, 470 ellipses, and 3,644 paths, which comes to precisely 6,624 dots in around 730KB.</p><p>The browser only needs x, y and a radius per dot so step one was converting each SVG down to that:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5xdqEcxr2YYAtPnJaBxTuQ.png" /><figcaption>Log-scale bar chart: about 175 MB of Illustrator SVG exports, 1.25 MB as a Float32 export, 519 KB as uncompressed DOT2 and 82 KB as DOT2 with Brotli.</figcaption></figure><p>There’s not much to say about the conversion really. The rest of this post is about getting 1.25MB down to 82KB.</p><h3>Three Floats per Dot</h3><p>The initial version shipped a Float32Array per illustration: [width, height, x, y, r, x, y, r, …], little-endian. 12 bytes a dot.</p><pre>// The original loader<br>const floats = new Float32Array(await res.arrayBuffer())<br>return {<br>  width: floats[0],<br>  height: floats[1],<br>  dots: Array.from(floats.subarray(2)),<br>}</pre><p>Float32 is around 7 significant digits. The widest example is 900px in source units and it renders at a few hundred CSS pixels. 412.3871 and 412.3906 end up on the same pixel so most of those digits are effectively wasted.</p><p>(Same version also ran the hero background loop at 60fps continuously, even if the mouse was just sitting still. Getting to that.)</p><h3>What Hand-Placed Dots Look Like as Data</h3><p>Before I went to any format I spent a bit of time fiddling with the files.</p><p>The first thing I noticed was rows. If you sort by y and then x, 78% to 99% of dots have the exact same y as the dot right before them.</p><p>Alberto builds the pattern row by row and it appears in the numbers.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qV7YU9f_U4PuNwITysvm7Q.png" /><figcaption>A crop of the merchandising illustration with faint lines joining dots that share a row, above two strips showing that 90% of the y deltas are zero while none of the x deltas are.</figcaption></figure><p>Radii are pretty much limited too. The original nine illustrations each have somewhere between 104 and 228 distinct radius values. The three latest have 900+ (again that’s our SVG converter reading the radii off the bounding boxes, float noise). 8-bit quantization will have flattened that out anyway.</p><p>X is where the entropy is. Nearly every x is fractional, and the neighbors in a row are a few px apart.</p><p>I didn’t write a clever codec at all. I just laid the data out so the compressor could see the rows.</p><h3>DOT2</h3><p>DOT2 is the format I went with. It’s built at deploy time via a small Node script and the decoder is around 40 lines.</p><p>Encoding, in order:</p><ol><li>Sort by y then by x. Each dot in an illustration is one color, so draw order is irrelevant.</li><li>Quantize x and y to uint16 across the width and height of the artwork. Radius becomes uint8, scaled to the largest dot in that illustration.</li><li>Store each x and y as a delta from the previous dot, wrapped mod 65,536 so negative steps still fit.</li><li>Write all the x deltas, then all the y deltas, then all the radii (planes, not interleaved).</li><li>Emit raw Brotli q11 and gzip -9 copies with a content hash in the filename.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*CuH8dmY06fTltutBBokb6Q.png" /><figcaption>DOT2 layout for the merchandising illustration: a 24-byte header, 24 KB of x deltas, 24 KB of y deltas and 12 KB of radii. Compressed on their own, the planes shrink to 6.0 KB, 2.0 KB and 1.6 KB.</figcaption></figure><p>Compressed one plane at a time the y deltas reduce to 8% of their original size and the x deltas get down to 25%.</p><p>5 bytes per dot before compression. With Brotli it’s 0.79 on average.</p><p>Encoder inner loop:</p><pre>const x = Math.round((dots[j] / width) * 65535)<br>const y = Math.round((dots[j + 1] / height) * 65535)<br>view.setUint16(HEADER + i * 2, (x - previousX) &amp; 65535, true)<br>view.setUint16(HEADER + count * 2 + i * 2, (y - previousY) &amp; 65535, true)<br>view.setUint8(<br>  HEADER + count * 4 + i,<br>  Math.round((dots[j + 2] / maxRadius) * 255),<br>)</pre><p>Decoder side:</p><pre>x = (x + view.getUint16(HEADER + i * 2, true)) &amp; 65535<br>dots[i * 3] = (x / 65535) * width</pre><p>On the 900px illustration one uint16 step is approximately 0.014px. Worst center error after a round trip across all assets: less than 0.011px. Worst radius error: less than 0.0065px.</p><p>It’s lossy, no doubt about it. I wasn’t going to trust the maths on that so there’s a pixel by pixel comparison further down.</p><p>The decoder validates prior to allocation. It checks the DOT2 magic (which is also the version), checks the flags, caps the caps count at 100k, checks the bounds on width and height and requires the buffer to be exactly 24 + count x 5 bytes.</p><p>Anything off and it throws, and you keep seeing the PNG that was already there.</p><h3>Where the Bytes Actually Went</h3><p>Halfway through writing this I went to explain why each step had been useful and realized I’d never actually measured the steps, only the final number.</p><p>So I went back and ran Brotli q11 over every intermediate layout, all 13 assets:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*RSmedQRG74GWQttYVhAFRA.png" /><figcaption>Bar chart of all 13 illustrations: Float32 source 1.25 MB, Float32 plus Brotli 224 KB, quantized interleaved 227 KB, quantized sorted interleaved 240 KB, quantized sorted planar 83 KB, DOT2 82 KB.</figcaption></figure><p>I would have got the majority of this wrong if you’d asked me before hand.</p><ul><li><strong>Quantizing on its own did nothing.</strong> It was 1% larger than Brotli on the raw floats.</li><li><strong>Sorting on its own made it worse (one file actually almost doubled). </strong>I suspect the export order had already tended to group the nearby dots together and sorting the interleaved data broke that up.</li><li><strong>Planes did the majority of it actually </strong>(more than half off). Once it’s its own stream Brotli will see thousands of repeated row values in a row.</li><li><strong>Deltas saved around 1KB. </strong>Everyone goes for the deltas first and that was by far the smallest gain. I left them in there anyway as the Y plane ends up being pretty much all zeros.</li></ul><p>So: 93% smaller than the uncompressed source, and 63% smaller than Brotli on the original floats. The 63% is the one I care about, since the 93% mostly measures “you weren’t compressing before.”</p><h3>Let the Browser Decompress It</h3><p>Browsers already do the decompression of Brotli and gzip so I’ve skipped a WASM decoder or a decompression library. Delivery was all about serving the correct file with the correct headers.</p><p>`npm run dots` takes each .dots.bin path, writes out the compact files and a manifest to its corresponding .dots URL (hashed). The pages continue to reference the original paths and the component then looks up the hashed one.</p><p>A Cloudflare Worker handler only matches against paths in the manifest. It correctly parses Accept-Encoding (q values, identity;q=0, all of it) and then serves the .br, the .gz or the raw file. The browser does the decompression and the client then decodes the entire buffer into a Float32Array.</p><pre>Content-Type: application/octet-stream<br>Content-Encoding: br<br>Vary: Accept-Encoding<br>Cache-Control: public, max-age=31536000, immutable</pre><p>immutable is fine as the hash is in the filename. Change the art and you get a new URL. The old hashed files remain so the cached pages don’t 404 their dots.</p><p>Two smaller things in the handler:</p><ul><li>It drops Range and If-Range on the way to the asset binding. The files are small and are always sent as a whole. A range against a .br file is going to be a different byte range to a range against the raw file. Not worth working out which one the client intended.</li><li>It sets encodeBody: ‘manual’ as the bytes are already compressed. Otherwise the Workers runtime might compress them again.</li></ul><h3>Unit Tests Passed, the Page Didn’t</h3><p>Codec tests were green from the start. The page was still broken in local preview.</p><p>The preview bridge decompresses responses on the way through but kept the compressed Content-Length. So decoded bytes showed up with the old, smaller length and got truncated. The decoder’s length check caught it and threw (good, that’s its job).</p><p>Fix was deleting Content-Length and piping the body through an empty TransformStream so that nothing downstream had a length to forward. I reproduced it in an isolated Miniflare runtime rather than a mocked Response as the mock would have just happily passed.</p><h3>Smaller Files, Same Frames</h3><p>Smaller files don’t make a frame any faster, though. The illustrations and the background grid are separate engines, and each had its own problem.</p><h3>The Illustrations</h3><p>The original loader ran Array.from on the Float32Array and then built an object per dot:</p><pre>// Before: one object per dot, with a color string on each<br>list.push({ ux, uy, hx: 0, hy: 0, x: 0, y: 0, vx: 0, vy: 0, r: 1, sr, color })</pre><p>Biggest illustration is 12,115 dots. So 12,115 objects, eleven fields each, plus setting ctx.fillStyle on every dot every frame. And every dot is the same color.</p><p>Now it’s ten flat typed arrays:</p><pre>type ParticleBuffers = {<br>  ux: Float64Array; uy: Float64Array; sr: Float64Array  // source space<br>  hx: Float64Array; hy: Float64Array                    // home, canvas space<br>  x: Float64Array;  y: Float64Array                     // current<br>  vx: Float64Array; vy: Float64Array                    // velocity<br>  r: Float64Array                                       // radius, canvas space<br>}</pre><p>Yes those are Float64Arrays (after I’d spent a whole section cutting precision). The wire format will be small, but the simulation should behave exactly as it did before. JS numbers are doubles so Float64 will preserve the spring maths bit for bit.</p><p>fillStyle is set once per frame for single color art (image sampled art still sets it per dot as those are effectively different colors). Each dot still has its own arc and fill. The sleep when settled and pause when offscreen behavior was already in place; I left it be.</p><h3>The Background Grid</h3><p>The hero background is a grid of glyphs (·, ◦, •, ●) which light up around your cursor and then fade out.</p><p>The old loop would touch every cell every frame. Build a string key like “14,22”, do a map lookup, apply the decay, do the brush test and draw. Then just requestAnimationFrame again unconditionally.</p><p>At 390×844 with 11 px cells that’s 2,772 cells and 2,772 template strings per frame, ~60 frames a second, on a page where nobody has moved the mouse.</p><p>New loop:</p><pre>// Before<br>for (every row) for (every column) { /* key, decay, brush test, draw */ }<br>frameId = requestAnimationFrame(draw)<br><br>// After<br>for (const [key, value] of influence) { /* decay active cells only */ }<br>if (painting) { /* visit only the brush&#39;s bounding box */ }<br>for (const [key, value] of influence) { /* draw active cells only */ }<br>if (painting || influence.size) frameId = requestAnimationFrame(draw)</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*8w3V3si522Cbxm8H7gP2PA.png" /><figcaption>Three phone-sized grids. Before: all 2,772 cells are visited every frame. After, with the pointer moving: only the 625 cells in the brush bounding box. After, idle: no frames at all.</figcaption></figure><p>Numeric keys (row * columns + column). Decay only walks lit cells. The brush only walks its own bounding box. When the last lit cell fades out, it stops requesting frames; pointer movement or a resize starts it again.</p><p>I went through and changed a lot of this at once and benchmarked it all together so I can’t break down the improvement per change.</p><h3>The Optimization I Threw Out</h3><p>The obvious next step with the illustrations was to batch up every arc into a single path and then just call fill() once. Thousands of Canvas calls down to one.</p><p>I built it and it was faster, though the artwork was different.</p><p>When circles are on the same path they get antialiased together rather than individually. The edges which were previously feathering into the background become a bit heavier and the whole image ends up looking a little darker, as if the contrast has been nipped up a bit.</p><p>Most people probably wouldn’t notice. The whole point was animating Alberto’s work without changing it, though, so I reverted it and kept individual fills.</p><h3>Proving It Looks the Same</h3><p>I didn’t want to eyeball it so I wrote a benchmark that renders old engine + old data side by side with new engine + DOT2 at DPR 2.</p><p>Every illustration (240 and 600 CSS px), once at rest and once after 12 scripted frames of pointer movement plus a shockwave. 52 comparisons.</p><p>The cost per comparison: mean channel difference less than 0.2/255 and less than 0.5% of pixels off by more than 16 on any channel.</p><p>Worst case was 0.153/255 at rest, 0.134/255 in motion and 0.211% of pixels over 16 (that run was headless Chrome on Linux). The two images below are from an earlier run on macOS.</p><p>Merchandising at rest, original on the left:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*MxQj0OiQ3l1xlHuBZqRorA.png" /><figcaption>Original merchandising dot artwork on the left and compact-format rendering on the right at 600 CSS pixels.</figcaption></figure><p>Toys mid-shockwave, same scripted input on both:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*wLtirkJ7N3f3cS-DC8EdZw.png" /><figcaption>Original and compact-format toys artwork during the same deterministic shockwave at 240 CSS pixels.</figcaption></figure><p>They aren’t pixel-identical, and the numbers above show it. I couldn’t see a difference.</p><h3>Still His Dots</h3><p>Every dot that Alberto placed is still there, within about a hundredth of a pixel of where he put it. The 13 illustrations come to 82KB on the wire and the grid behind them stops drawing when you stop moving.</p><p>The codec itself isn’t what I’m most interested in. It only worked because of the way the art was created. Alberto draws in rows so the data came in rows and the real win was in the way we laid it out so Brotli could see it.</p><p>New illustrations go through the same pipeline: illustrator export, a script that pulls the x, y and radius out of the SVG, then npm run dots. Alberto is still drawing by hand. The site just hosts it for less.</p><p>If you’re shipping art that someone has made by hand then open the files before you pick a format. How it’s been drawn will usually tell you what format to store it in.</p><p><em>If you like this kind of work, follow along at </em><a href="https://layers.engineering">layers.engineering</a><em>.</em></p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*G9SwLpRA84M-Xb21fELzxg.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b4f7d3a89779" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/shipping-103-802-dots-in-82-kb-b4f7d3a89779">Shipping 103,802 Dots in 82 KB</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How PlanetScale TIN Became the Foundation for Layers Search]]></title>
            <link>https://layers.engineering/how-planetscale-tin-became-the-foundation-for-layers-search-df5cd9997197?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/df5cd9997197</guid>
            <category><![CDATA[full-text-search]]></category>
            <category><![CDATA[tin]]></category>
            <category><![CDATA[planet-scale]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Mon, 05 Oct 2026 17:52:35 GMT</pubDate>
            <atom:updated>2026-10-05T18:10:25.035Z</atom:updated>
            <content:encoded><![CDATA[<blockquote>Quick note first. If you’ve been following TIN, you’ve seen the takes: it’s fast, “is it fast?”, dense-term elision is a mistake, etc. We have opinions on all of it, and each deserves its own post. This one is the backstory: years of prototypes and dead ends, and why TIN is the block we rank on.</blockquote><p>Every search on Layers runs as one Postgres query. Vector and keyword retrieval, dozens of availability and channel filters, merchandising and behavioral signals, reranking, and facets all happen in one statement. For years, the part of that query that hurt most was keyword relevance.</p><p>Keyword relevance is one piece of that puzzle, not the whole thing. This post is about why TIN fit that piece when nothing else did, and how we use it as a building block: first under keyword scoring, then as a retrieval channel next to vectors.</p><h3>What one Layers search has to do</h3><p>A storefront search on Layers is one SQL statement with more than 25 CTEs. Every stage feeds a weighted blend, which is how merchandising, behavioral, and inventory signals end up in one score.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*AI8Qo9NrfxKhPHZeYJCUIA.png" /></figure><p>Two stages do a lot of quiet work. Query understanding turns “diamond chain necklace” into the query itself plus weighted expansions like “diamond link necklace,” and each gets its own vector search. Expansions blend into a product’s match when they fit it better than the original query, and they can bring in products the query alone missed. Intent works inside the same statement: when we detect a product type, products of that type move up a recall tier and get a small, capped boost, unless they already fill more than a third of the top 20.</p><p>The data underneath makes it harder. Brands store product data in wildly different shapes. We keep normalized tables for all of it, plus rolled-up tables for serving so nothing gets aggregated at read time. One retrieval query can touch tens to hundreds of tables and apply dozens of filters to decide whether a product can be sold on a given channel or in a given region.</p><p>Whatever we put under keyword search had to live with these constraints:</p><ul><li><strong>One query per search.</strong> Product retrieval, reranking, and facet retrieval happen together.</li><li><strong>Many searches per request.</strong> Intent and query understanding can turn a few terms into as many as six searches.</li><li><strong>Heavy row churn.</strong> 60–80% of rows are updated throughout the day.</li><li><strong>Dynamic schemas.</strong> Tables are partitioned and indexed per tenant workload.</li><li><strong>Partitions and partial indexes</strong>, and the ability to combine scans.</li><li><strong>Latency back under our SLAs by Q4</strong>, or we’d risk losing customers.</li></ul><h3>The ranking DSL</h3><p>Our legal name is Building Blocks for Ecommerce, and we mean it. We want to give technical teams, or our solutions team, the pieces to build exactly what a brand needs, without it getting complicated or us babysitting it forever. Today there are more than thirteen billion distinct ways to rank a search or category page on Layers.</p><p>Nobody writes that search query by hand. Everything that touches ranking ends up as a config, and a compiler builds the single SQL statement from it. TIN is one of those blocks. The compiler decides where it sits.</p><p>Here’s a trimmed ranking configuration with illustrative weights and four features: semantic similarity, product-title keyword relevance, revenue, and inventory.</p><pre>{<br>  &quot;name&quot;: &quot;default&quot;,<br>  &quot;version&quot;: 1,<br>  &quot;features&quot;: [<br>    { &quot;feature&quot;: &quot;text_similarity&quot;, &quot;weight&quot;: 0.25 },<br>    { &quot;feature&quot;: &quot;keyword:title&quot;, &quot;weight&quot;: 0.03, &quot;normalize&quot;: &quot;minmax&quot; },<br>    { &quot;feature&quot;: &quot;query_metric:revenue&quot;, &quot;weight&quot;: 0.15, &quot;fallback&quot;: 0.0 },<br>    { &quot;feature&quot;: &quot;variants.inventory_quantity&quot;, &quot;weight&quot;: 0.03,<br>      &quot;transform&quot;: &quot;log1p&quot;, &quot;normalize&quot;: &quot;minmax&quot;, &quot;fallback&quot;: 0.0 }<br>  ],<br>  &quot;options&quot;: { &quot;normalization&quot;: &quot;minmax&quot;, &quot;combiner&quot;: &quot;linear&quot; }<br>}</pre><p>The compiler turns each feature into a column, then turns the weights, transforms, and normalization into one linear expression. fallback becomes the coalesce default, log1p becomes ln(1 + …), and minmax divides by the spread across the candidate set.</p><pre>-- compiled output, trimmed<br>ranking_features as materialized (<br>  select ns.product_id,<br>    coalesce(sum(ns.norm_score * ns.weight) filter (where ns.type = &#39;text&#39;), 0) as f_0,<br>    coalesce(min(kw_title.kw_0), 0)          as f_1,<br>    coalesce(min(qpm.revenue_score), 0)      as f_2,<br>    coalesce(min(frv.inventory_quantity), 0) as f_3<br>  from normalized_scores ns<br>  left join kw_title on kw_title.product_id = ns.product_id<br>  left join qpm      on qpm.product_id      = ns.product_id<br>  left join frv      on frv.product_id      = ns.product_id<br>  group by ns.product_id<br>),<br>ranking_scores as materialized (<br>  select rf.product_id,<br>      0.25 * coalesce((rf.f_0 - fs.min_0) / nullif(fs.max_0 - fs.min_0, 0), 0)<br>    + 0.03 * coalesce((rf.f_1 - fs.min_1) / nullif(fs.max_1 - fs.min_1, 0), 0)<br>    + 0.15 * rf.f_2<br>    + 0.03 * ln(1 + greatest(coalesce((rf.f_3 - fs.min_3) / nullif(fs.max_3 - fs.min_3, 0), 0), 0))<br>    as ranking_score<br>  from ranking_features rf<br>  cross join feature_stats fs<br>)</pre><p>Real configs run to dozens of features, and every one of them compiles into the same statement.</p><ul><li><strong>Semantic:</strong> text and image embedding similarity, for the query and each expansion. Shaped with per-type normalization and expansion blending.</li><li><strong>Keyword:</strong> relevance per field (titles, collections, option codes, metafields, tags), scored by TIN. Shaped with a weight per field and lower weights for expansions.</li><li><strong>Behavioral:</strong> click-through, add-to-cart, purchase, and revenue rates over varying windows. Shaped with window weighting and fallbacks for products with no history.</li><li><strong>Catalog:</strong> inventory and how recently a product was published. Shaped with log transforms and exponential recency decay.</li><li><strong>Merchandising:</strong> brand-defined metrics, applied as capped relevance boosts on top of the blend.</li><li><strong>Intent:</strong> detected product type, applied as recall-tier shifts and boosts, capped by how much of the top results already match.</li></ul><p>Around the features sit recall tiers, relevance cuts, variant matching, and the final sort. Each of those is configurable too, which is where the thirteen billion combinations come from. A brand can rank search and category pages differently, lean on revenue for one surface and recency for another, or turn keyword relevance on for a single metafield, without anyone touching SQL. Keyword is one family in that table, which is exactly why it needed to be a block and not an engine.</p><h3>Why other engines didn’t fit</h3><p>Layers spent almost five years as a skunkworks project inside Proton before going GA in Q4 2025. In 2020 and 2021 we tried MySQL, Postgres, Solr, and Elastic. In 2022 we settled on Postgres for our data, with keyword search on Meilisearch. Replicating from our database to search indexes in six US regions lagged, and shoppers in different regions saw different results. Sometimes one region’s index would update mid-session, reshuffle everything, and products would vanish.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9IXOnZHuQa1ng050_4-CxA.png" /></figure><p>I’ve worked with almost every open-source and commercial search engine out there. I’ve run Elastic clusters chewing through terabytes of records, and I helped Google bring Vertex AI Search for Retail to the first Shopify merchant. Every one of them was purpose-built, and the purpose never lined up with ours. Ranking was basic. You got structured data or unstructured data and nothing in between. Most of them started as document search and had product catalogs bolted on later. Each thought it was the destination. We needed something to build on.</p><p>We tried going semantic-only on pgvector, with a homegrown Go engine for query parsing and rewriting. That got us to GA, but by spring 2026 the Go engine clearly wouldn’t scale. We ported the ranking system to SQL, with keyword reranking on top of vector search, and learned the harder lesson: ignoring keyword retrieval was a mistake. The brands we signed had shoppers who still typed one or two words, not sentences.</p><p>In early 2026, halfway through removing the Go engine, I talked to Sam at PlanetScale about custom Postgres extensions, our desire to build something ourselves, and he mentioned pgrx. We stayed the current course anyway, with some of the nastiest SQL I’ve written. The reranker kept failing in niche ranking cases. We needed a replacement for native text search and a keyword channel that could sit alongside vectors within the same statement.</p><p>We prototyped with ParadeDB and Tiger’s text search extension.</p><ul><li><strong>ParadeDB</strong>, we felt, wouldn’t be flexible enough for our disjointed schema without significant changes, and it wasn’t available where our data lives.</li><li><strong>Tiger</strong> came closest to a general-purpose text search extension that fit our schemas, but it wasn’t available where our data lives either.</li></ul><p>In late spring, I asked Sam whether custom pgrx extensions, or Tiger specifically, were coming to their platform. If not, the plan was to spend Q3 and Q4, plus part of our in-progress round, building a keyword engine into Postgres ourselves. The reply came back almost immediately: “We’re working on something cool, do you wanna see?”</p><h3>Meeting TIN</h3><p>That’s how I met the engineers behind TIN. We walked them through the constraints above. Then they opened a terminal and started querying the full Reddit dataset, over a terabyte, on a local machine. Unindexed, it was slow. BM25 didn’t impress me. Everyone has BM25. The retrieval and scoring flexibility did: “foo in the first 10% of the document,” or “bar and baz within ten words of each other.”</p><p>That’s what we’d been half-solving in our query and intent engines, and mostly solving with a SQL keyword ranker built on Postgres text search functions, with no index in sight.</p><p>Then they built an index and ran a top-k query for “beer and cheese” against the same data. Around 100 ms, highlighted documents and all.</p><p>On that call we shared the day’s slowest search. The EXPLAIN ANALYZE output ran 594 lines, and the query took 732 ms to return a page of products and its facets.</p><pre>Limit  (actual time=701.027..701.174 rows=7 loops=1)<br>  ...<br>Planning Time: 26.287 ms<br>Execution Time: 705.745 ms</pre><p>The biggest sink was ranking features, mostly keyword scoring: about 185 ms joining and aggregating scores across every searchable field to produce features for 200 products.</p><pre>-&gt;  GroupAggregate  (actual time=278.545..463.542 rows=200 loops=1)<br>      -&gt;  Merge Left Join  (actual time=277.518..297.087 rows=348 loops=1)<br>            ...<br>            -&gt;  Seq Scan on search_vrnts_optns_&lt;tenant&gt; rkw  (actual time=0.017..1.418 rows=8029 loops=1)</pre><p>We had tried keyword indexes. They made it worse. Our candidates come from a CTE, and a GIN scan can’t intersect with that, so Postgres read the whole table and filtered afterward. GIN also only helps with matching: ts_rank_cd still has to read every matching row&#39;s tsvector to score it. We dropped keyword indexes entirely. The keyword cost had nowhere to go without an index built for the job.</p><h3>The first block: keyword scoring</h3><p>In the DSL, keyword relevance is one kind of feature. A config says which fields to score and how much each one weighs: collection titles, product titles, the variant options a brand marks searchable, description metafields, and tags. Each field is scored against the shopper’s query and each expansion, with expansions weighted lower. How it gets computed is the compiler’s problem.</p><p>Before TIN, every field got three Postgres scores, each taking the best of the query and its expansion: ts_rank_cd over a tsvector built on the fly, plus strict_word_similarity and similarity from pg_trgm. That&#39;s 27 keyword features per product, all computed per row at query time.</p><p>Putting TIN under that feature meant changing what the compiler emits: a ==&gt; match and tin.score() instead of three functions. Every existing brand configuration kept working. Nothing to migrate on their side. The candidate set stayed the vector set, so every downstream stage still started from the same products. It just got its keyword features from an index. Here&#39;s the product-title feature for &quot;diamond chain necklace&quot; plus one expansion, &quot;diamond link necklace.&quot;</p><pre>-- before, trimmed<br>&quot;_rkw_2_vals_0&quot; as materialized (<br>  select vals.kw_value,<br>    greatest(ts_rank_cd(vals.kw_tsv_english, tsq.tsq_0) * 1,<br>             ts_rank_cd(vals.kw_tsv_english, tsq.tsq_1) * 0.8) as kw_0,<br>    greatest(strict_word_similarity(&#39;diamond chain necklace&#39;, vals.kw_value) * 1,<br>             strict_word_similarity(&#39;diamond link necklace&#39;, vals.kw_value) * 0.8) as kw_1,<br>    greatest(similarity(vals.kw_value, &#39;diamond chain necklace&#39;) * 1,<br>             similarity(vals.kw_value, &#39;diamond link necklace&#39;) * 0.8) as kw_2<br>  from (<br>    select dv.kw_value, to_tsvector(&#39;english&#39;, dv.kw_value) as kw_tsv_english<br>    from (<br>      select distinct coalesce(rkw.title, &#39;&#39;) as kw_value<br>      from searchable_products_&lt;tenant&gt; as rkw<br>      where rkw.id in (select product_id from normalized_scores)<br>    ) as dv offset 0<br>  ) as vals<br>  cross join (<br>    select plainto_tsquery(&#39;english&#39;, &#39;diamond chain necklace&#39;) as tsq_0,<br>           plainto_tsquery(&#39;english&#39;, &#39;diamond link necklace&#39;)  as tsq_1<br>    offset 0<br>  ) as tsq<br>),<br>&quot;_rkw_2&quot; as (<br>  select rkw.id as product_id,<br>    max(dv_0.kw_0) as kw_0, max(dv_0.kw_1) as kw_1, max(dv_0.kw_2) as kw_2<br>  from searchable_products_&lt;tenant&gt; as rkw<br>  inner join &quot;_rkw_2_vals_0&quot; as dv_0 on dv_0.kw_value = coalesce(rkw.title, &#39;&#39;)<br>  where rkw.id in (select product_id from normalized_scores)<br>  group by rkw.id<br>)</pre><pre>-- after<br>&quot;_rkw_2&quot; as materialized (<br>  select m.product_id,<br>    max(m.score) as kw_0,<br>    0.0::double precision as kw_1,<br>    0.0::double precision as kw_2<br>  from (<br>    select rkw.id as product_id, tin.score(rkw.ctid) * 1.0 as score<br>    from searchable_products_&lt;tenant&gt; as rkw<br>    where rkw.title ==&gt; &#39;diamond chain necklace&#39;<br>      and rkw.id in (select product_id from normalized_scores)<br>    union all<br>    select rkw.id as product_id, tin.score(rkw.ctid) * 0.8 as score<br>    from searchable_products_&lt;tenant&gt; as rkw<br>    where rkw.title ==&gt; &#39;diamond link necklace&#39;<br>      and rkw.id in (select product_id from normalized_scores)<br>  ) as m<br>  group by m.product_id<br>)</pre><p>The CTE’s output shape is unchanged: one row per product, with kw_0, kw_1, and kw_2. The two trigram columns compile to constant zeros, so they drop out of the blend once min-max normalization runs, and nothing downstream had to be rewritten. The planner turns each match into a TIN Text Search Scan, and in some branches TIN folds our candidate-ID restriction into the same pass.</p><p>Recall tiers compare raw keyword scores against cutoffs we tuned for ts_rank_cd and trigram similarity. BM25 sits on a different scale, so those cutoffs move with the score. That is compiler work, not a brand migration.</p><h3>Catalogs where every product looks alike</h3><p>While we were previewing TIN, semantic search hit a new problem: keyword stuffing. Brands were AI-generating product descriptions, and the fluff buried what the product actually was. PlanetScale then shipped dense-term elision: high-frequency terms drop out of the BM25 score. Products can still match those words. The words just stop counting toward relevance. Density comes from each catalog’s own data. Nobody maintains a stop list.</p><p>Just as important, it happens at query time, as an option. We turn it on where it helps and leave it off where it doesn’t (note: we have not turned it off to date).</p><p>“Diamond chain necklace” is the case that sold us.</p><p>In that catalog, diamond is not the product. Nearly every product can be bought in diamond or in another stone. That choice is a variant option, and we apply it in a later stage, after a product has been retrieved. Using “diamond” to rank products is the wrong job. It does not tell a chain necklace apart from a chain bracelet. It tells you which stone the shopper wants once they have a product.</p><p>Our old scorer was ts_rank_cd plus trigram similarity. Neither cares how common a term is, so &quot;diamond&quot; counted in full. It dominated, and diamond chain bracelets kept showing up in the top 10, sometimes the top 25. With TIN, &quot;diamond&quot; is elided from the score on that catalog. &quot;Chain&quot; and &quot;necklace&quot; decide the order. The stone option is handled after that.</p><p>IDF would have shrunk “diamond.” TIN drops a term that dense from the score entirely, so it never walks that postings list. A word matters for this stage of ranking only if it tells products apart. Stone does not, on this catalog. It would be in a general store where few products can be set with a diamond, and their TIN keeps it. If a brand’s assortment shifts, the scoring follows.</p><p>The engines we used before shipped opinions: a fixed stop list, or a self-learning box. TIN gives us a default computed from the catalog, an option we control at query time, and one more block we compose with the rest of the ranking.</p><h3>TinQL: everyone does BM25</h3><p>Everyone does BM25. Hardly anyone gives you control over it. What sold me in that first demo wasn’t the score, it was the query language around it: position (“in the first 10% of the document”), proximity (“within ten words of each other”), and matching on one set of terms while scoring on another, all in a single scan.</p><p>That last one is the expansion block. Match broadly on “diamond link necklace,” score on what the shopper typed. The compiler emits one scan per query variant today. Collapsing those into a single scan is the same feature, composed differently, not a new system.</p><p>The bigger opportunity is everything we still do with regex. A fair amount of our query handling runs through generated regex patterns today, and our research says much of it maps directly onto TinQL. That moves the logic out of application code and into the same index scan that does the scoring.</p><p>Here’s what that looks like. For “blouse,” one TinQL expression layers an exact match, a fuzzy match that forgives a typo, and prefix and suffix wildcards, each with its own weight:</p><pre>OR(blouse^10, blouse~1, (OR(REGEX(.*blouse), REGEX(blouse.*)))^0.3)</pre><p>For “curly hair products,” it adds proximity. All three words score highest. Next come the words appearing close together, in any order, at two strengths. Then a fuzzy match on at least two of the three words, and finally the wildcards:</p><pre>OR(<br>  (AND(curly, hair, products))^10,<br>  (SPAN(MAXGAPS(3, UNORDERED(...))))^5,<br>  (SPAN(MAXGAPS(3, UNORDERED(...))))^2,<br>  ATLEAST(2, curly~1, hair~1, products~1),<br>  (OR(REGEX(...)))^0.3<br>)</pre><p>Exact matches, proximity, typo tolerance, partial words, and how much each one counts, all in one expression and one index scan. Today that logic is spread across regex in our query handling and functions in our SQL.</p><h3>What the scoring block changed</h3><p>On a real storefront search, a short query plus one expansion, moving keyword scoring to TIN took the query from about 264 ms to about 51 ms. Roughly 5× faster end to end, with the SQL around it unchanged.</p><ul><li><strong>Total (planning + execution):</strong> 264.3 ms → 50.8 ms</li><li><strong>Execution:</strong> 255.8 ms → 39.1 ms</li><li><strong>Keyword scoring stage:</strong> ~227 ms → ~14 ms</li><li><strong>Vector retrieval (unchanged):</strong> ~18 ms → ~18 ms</li><li><strong>Planning:</strong> 8.6 ms → 11.7 ms</li></ul><p>Almost all of that came from the keyword stage, which went from about 227 ms to about 14 ms. It used to be 88% of execution time. Now vector retrieval is the bigger cost again. Planning went up about 3 ms. I’ll trade 3 ms for 200.</p><p>Here’s one field from that search, the description metafields. Before, scoring 175 distinct descriptions meant building a tsvector for each one at query time and running three scoring functions on it: about 40 ms for one field.</p><pre>CTE _rkw_3_vals_0<br>  -&gt;  Nested Loop  (actual time=2.537..39.619 rows=175 loops=1)<br>        -&gt;  Subquery Scan on dv_2  (actual time=2.194..9.695 rows=175 loops=1)<br>              -&gt;  Unique  (actual time=2.143..2.307 rows=175 loops=1)<br>                    -&gt;  Sort  (actual time=2.140..2.175 rows=743 loops=1)</pre><p>After, the whole field takes about 2 ms. The TIN scan itself takes 0.055 ms and touches 14 pages. TIN decided “diamond” is a dense term in this catalog and elided it from the score.</p><pre>CTE _rkw_3<br>  -&gt;  GroupAggregate  (actual time=1.948..1.956 rows=0 loops=1)<br>        ...<br>        -&gt;  Custom Scan (Text Search Scan) on search_vrnts_mflds_&lt;tenant&gt; rkw_4  (actual time=0.043..0.055 rows=2 loops=1)<br>              Index: search_vrnts_mflds_tin_search_value_idx<br>              Query: AND(diamond, chain, necklace)<br>              Scoring: dense-term elision<br>              Elided Terms: diamond<br>              Page Touches:<br>                Total: 14, ~0.1 MiB</pre><p>Everything around it stayed the same: vector retrieval, the candidate filter, behavioral and inventory features, intent tiers, and the weighted blend. We blend normalized scores instead of using rank fusion, so changing how one feature gets computed doesn’t mess with the merchandising signals on top.</p><h3>The next block: hybrid retrieval</h3><p>Scoring was the first place TIN fit, because the candidate set did not have to change. Hybrid retrieval is the same block used a second way: TIN finds products, vector search finds products, and the compiler merges them before ranking.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*7Nka7isg3jWhOgprnpPzLg.png" /></figure><p>That merge is the work. It is still one statement, still the same DSL.</p><ul><li><strong>The candidate set becomes a union.</strong> Products only TIN finds have no vector distance, so the compiler decides what a missing semantic score means before any feature is computed.</li><li><strong>Normalization is over the merged set.</strong> Every feature is min-max normalized across the candidates. A wider set changes every product’s normalized values, including products that were already there.</li><li><strong>Thresholds follow the new distributions.</strong> Recall tiers, intent caps, and the relevance cut were tuned when vectors defined the set. They get retuned against the merged set so a brand that never touched its config keeps behaving.</li><li><strong>Eligibility covers both channels.</strong> Channel, region, publish, and B2B rules already live in our roaring-bitmap availability system. A keyword channel searching the catalog has to respect the same rules. We apply that bitmap as a post-filter today, the same as with the other Postgres search extensions we tried, which has been performant on the catalogs we run, and we are working through pushing it earlier on larger sets.</li><li><strong>Configurations stay configs.</strong> At thirteen billion combinations, the check is real search traffic, not a hand review.</li></ul><p>Exact title and brand matches already come back from vector search. The embedding model is good at that. They were not always first. TIN’s tokenizing function is how we pull those matches forward, since we translate queries to English and that model typically stems them.</p><h3>Building on it</h3><p>For five years we kept looking for a search engine. Every one we tried wanted to own the whole problem: the index, the ranking, the opinions. What we actually needed was a piece we could put exactly where the problem was, inside a query we already controlled.</p><p>TIN is that piece. It went in under one feature without a single brand config changing. It handles catalogs where every product shares a word. Its query language covers logic we’ve been holding together with regex. And hybrid retrieval is the same block, placed a second time. The ranking DSL doesn’t know which one it’s looking at, and that’s the point.</p><p>There is far more to come about our experience, thoughts on the performance, and latency comparisons. Until then, thanks to the team at PlanetScale for building the thing we kept trying to build ourselves ❤️.</p><p><a href="https://gist.github.com/0x15f/b1aca7bc0cacb63c03c055601c0f6f19">If you want to read more about our planner/builder, we’ve dumped some scrubbed internal documentation to a gist.</a></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=df5cd9997197" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/how-planetscale-tin-became-the-foundation-for-layers-search-df5cd9997197">How PlanetScale TIN Became the Foundation for Layers Search</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Three years, four rewrites, and a Roaring bitmap]]></title>
            <link>https://layers.engineering/three-years-four-rewrites-and-a-roaring-bitmap-00991321f30b?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/00991321f30b</guid>
            <category><![CDATA[shopify]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[filtering-faceting]]></category>
            <category><![CDATA[roaring-bitmap]]></category>
            <category><![CDATA[ecommerce-web-development]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Fri, 31 Jul 2026 16:40:05 GMT</pubDate>
            <atom:updated>2026-07-31T16:40:05.702Z</atom:updated>
            <content:encoded><![CDATA[<p>The count next to that filter on a category page that you glance at for half a second before scrolling away is far harder to maintain than you’d expect. Natively on Shopify, when a collection reaches 5,000 products, the filtering functionality is turned off; Shopify recommends that merchants divide the collection into smaller collections to restore the functionality. All the merchants we work with eventually come across this threshold, with some going beyond 50,000 products in a single collection. It’s not possible to split a collection when it’s labeled “All Women’s Tops”; you can’t inform a customer that they can’t filter by size anymore because the page <em>has too many products</em>.</p><p>The count is not a minor feature for us; it’s a selling point. That’s the reason why facets have been the biggest engineering investment at Layers since 2024-having involved thousands of hours, a frivolous amount of money over three years, three rewrites, and Francis’ sanity at points, all for the sake of a UI component we’ve found that the majority of shoppers do not use. We just completed the fourth rewrite, which has reduced one merchant’s facet storage from <strong>4.7 GB → 13 MB</strong>. This reduction is not from a new compression algorithm; it’s because we realized the same small number of facts had been stored thousands of times. This is how we got to this point and what we’re rolling out now.</p><p>A facet panel must have a count alongside each value, not only for the ones the shopper has chosen but also for those they have not yet selected, when all those values are scoped to a certain collection or search query. It has to be collection-scoped, disjunctive, and incremental all at the same time, and pre-aggregation is a necessity. The issue then becomes what to pre-aggregate and where to do it.</p><h3>The two designs we outgrew</h3><p>We’ve been through this twice already, briefly:</p><p><strong>The trigger-maintained counters (2024) involved a</strong> per-merchant table listing (collection, facet, value, count), the counts being kept entirely by triggers in Postgres that executed dynamic plpgsql code each time a product was written. It functioned long enough to be deployed, but it failed in the manner in which this kind of design always fails: since increment-based counters have no way to verify themselves against a true reference value, a missed or duplicate trigger causes the count to become corrupted silently and permanently. Also, there was no means of answering a disjunctive count from a simple number; to do that you need the actual set of members for it, not just the running total. Regardless of how well the triggers performed, a counts-only approach was a dead end.</p><p><strong>Materialized views (2024–2026).</strong> One MV per merchant per facet, with an actual product-ID array as the member set, unlocking filtered counts for the first time. This one didn’t drift; it just got expensive. MVs refresh by full rebuild, so refresh cost scales with catalog size, not with how much actually changed. Fine at ten merchants. Ruinous once a single merchant’s catalog got large. At peak facetable attribute count, we had almost two thousand materialized views all scoped by merchant.</p><p>The two cases mentioned above deserve their own posts, while what I’d like to focus on in the remainder of this one is the design we’re currently running, the reason it began to show flaws, and the rewrite that we’ve created and tested as a replacement.</p><h3>Where we are today: incremental array rollups</h3><p>In production today, we use a rollup table partitioned by merchant; there is one row for each (collection, facet, value), and in this row an `int8[]` containing the raw Shopify product IDs is stored as the set of members, together with a `member_count` that is updated atomically. Changes are stored in a buffer and are then processed by debounced jobs specific to (merchant, facet), with a checkpointed and resumable reconcile serving as a fallback when something goes wrong.</p><p>Within each (collection, facet, value) bucket, the member set itself is sharded, split across N rows keyed by `product_id % modulus` rather than kept as one array per bucket. The reason is concurrency, not size. A popular facet value like “In Stock: true” on a large collection can be touched by hundreds of product writes a minute. If that value lived in a single row, every one of those writes would serialize against the same row lock, and the write that loses the race pays for a full array rewrite that it didn’t even need. Splitting the member set across shard rows spreads that contention across N lock targets instead of one, so writes to the same facet value can happen concurrently as long as they land on different shards.</p><p>It works, but it’s a mitigation, not a fix. Write amplification per shard is still proportional to that shard’s member set size; sharding lowers the chances that two writes conflict, but it doesn’t change the fact that adding one ID to the member set requires rewriting the entire array. It’s a cost I did not truly appreciate until we began experimenting with bitmaps: modulo sharding scatters consecutive product IDs by construction, which is exactly the access pattern that defeats Roaring’s run-length compression later on. The sharding scheme was solving gen 3’s concurrency but quietly working against gen 4 before we even designed gen 4.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*9LR1HWB_Mn5oHch0QAwzqQ.gif" /><figcaption><em>Modulo sharding solves this generation’s contention problem while quietly building the next generation’s compression problem.</em></figcaption></figure><p>The base design held up fine. What didn’t was three specific ways it broke down in production, and they’re the reason we started looking for something structurally different instead of patching this one again.</p><ul><li><strong>Removals break incremental maintenance.</strong> Additions bump `updated_at`; deletions don’t. A staleness gate meant to skip redundant reconciles ended up skipping the ones cleaning up deleted products; one merchant accumulated ~148,000 zombie memberships before anyone noticed. Fix: every mutation site enqueues its own removal event; repair sweeps are the safety net, not the mechanism.</li><li><strong>A repair job that failed silently.</strong> A whole-facet set-diff blew its timeout and got SIGKILL’d on a 41-million-membership facet; never even marked failed. Fixed by chunking the diff per collection with checkpointing.</li><li><strong>The real tell.</strong> Drain jobs were 42% of all queue executions; every product write fans out into one write per collection, per facet, per value it touches.</li></ul><h3>The number that reframed the problem</h3><p>We knew that we were storing a lot of duplicate data, but the scale of duplication was an afterthought. One merchant had 190,522 rollup rows built from only 906 distinct facet values across roughly 7,650 collections. That’s 210x duplication of the same handful of values, copied once per collection. Another merchant had 1.74 million rows from 6,219 values (279x). The collection dimension <em>was</em> the table. We’d built an architecture around storing the same value-membership information once per collection, when the actual information content was “which products have this value” and “which products are in this collection.” Two much smaller sets, intersected on demand.</p><p>There was already a clue in the schema: a global “all products with this value, merchant-wide” sidecar row which had been included for a different reason. It proved to be the solution, so we should keep it; remove the 210 copies and treat collections as sets in their own right. Instead of storing the counts, we should compute each one by taking the intersection.</p><h3>The rewrite: Roaring bitmaps and the intersection model</h3><p>This is what we built and measured on a branch seeded with production-scale data, not yet serving live reads.</p><h3>Why we had to ask our database provider for this</h3><p>Very few, if any, managed Postgres providers let you arbitrarily install extensions; they have a vetting process and tend to refuse support unless a large portion of their user base requires it.</p><p>We’d actually run into this before. Back in 2024, while we were still on RDS, we looked into getting <a href="https://github.com/ChenHuajun/pg_roaringbitmap"><strong>pg_roaringbitmap</strong></a> added at all and found a long-standing open ticket for it; RDS didn’t have enough demand from other customers to prioritize building and maintaining it. We moved our production Postgres to PlanetScale in 2025, and the difference in how fast they move and how much care goes into what they ship has been genuinely impressive. So in June, once we had a concrete design that needed the 64-bit type, we sent them a formal request: add pg_roaringbitmap at a version that includes roaringbitmap64. The core of the argument was the write-amplification problem itself, illustrated as directly as we could:</p><pre>-- Today: Postgres arrays are immutable. Adding one product ID<br>-- rewrites the entire array value and its TOAST chunks.<br>UPDATE facet_rollups<br>SET member_set = member_set || :productId<br>WHERE bucket = :bucket<br>  AND NOT (member_set @&gt; ARRAY[:productId]::int8[]);<br><br>-- With pg_roaringbitmap: the bit flips in place.<br>UPDATE facet_rollups<br>SET members = rb_or(members, rb_build(ARRAY[:productId]))<br>WHERE bucket = :bucket;<br><br>SELECT rb_cardinality(members)<br>FROM facet_rollups WHERE bucket = :bucket;              -- facet count<br><br>SELECT rb_and_cardinality(members, :filterSet)<br>FROM facet_rollups WHERE bucket = :bucket;               -- filtered count</pre><p>For the first statement, the cost is O(set size) whenever there is a single-product write; the whole member array, together with its TOAST chunks, has to be rewritten to add the ID. Sharding does reduce this cost but does not eliminate it, which is precisely the 42%-of-queue drain load mentioned in the previous section. As for the second statement, it is a bounded in-place bit flip regardless of how big the set becomes.</p><p>In the following weeks, we began using the 64-bit version in local containers to begin building generation 4 of our faceting system. PlanetScale began rolling it out across their fleet, and we got access on July 29th.</p><h3>The model</h3><p>Two tables. One roaringbitmap64 per facet value, merchant-wide (facet_vbm). One per collection, keyed by the Shopify collection ID rather than its handle (colt_bm). Every count becomes bitmap algebra instead of a stored, incrementally-maintained number:</p><pre>-- Browse listing: count of a value within a collection<br>SELECT rb64_and_cardinality(value_bm, collection_bm)<br>FROM facet_vbm, colt_bm<br>WHERE facet_vbm.value_id = $1 AND colt_bm.collection_id = $2;<br><br>-- Search-filtered count: intersect against an eligible-IDs bitmap<br>-- built from the search candidate set<br>WITH scope AS MATERIALIZED (<br>  SELECT rb64_build_agg(product_id) AS scope_bm<br>  FROM search_candidates<br>  WHERE query_id = $1<br>)<br>SELECT fv.value_id, rb64_and_cardinality(fv.member_set, scope.scope_bm) AS count<br>FROM facet_vbm fv, scope<br>WHERE fv.facet_id = $2;<br><br>-- Disjunctive count: union the *other* selected values in this facet,<br>-- then intersect with the collection — dedup falls out of the algebra<br>WITH selected AS MATERIALIZED (<br>  SELECT rb64_or_agg(member_set) AS selected_bm<br>  FROM facet_vbm<br>  WHERE value_id = ANY($1)<br>)<br>SELECT rb64_and_cardinality(selected.selected_bm, collection_bm)<br>FROM selected, colt_bm<br>WHERE colt_bm.collection_id = $2;</pre><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ti7nbIVSgm-7NqyYhEw28w.gif" /><figcaption><em>Every facet count reduces to one AND across two bitmaps — the value’s members and the collection’s members. No per-collection rollup rows, no </em><em>COUNT DISTINCT over unnested arrays, and dedup falls out of the algebra rather than being enforced by a query.</em></figcaption></figure><p>For the first time, the functions for browsing and searching follow the same code path since in both cases the value’s bitmap is intersected with a scope bitmap, the scope being either a collection or a search result set; disjunctive counts combine the selected values with an AND operation against the collection and deduplication is a result of the algebra rather than requiring a COUNT(DISTINCT …) applied to unnested arrays.</p><p>The wins are structural, not just “bitmaps are smaller than arrays,” though they are that too:</p><ul><li>Collection membership changes never touch a facet row again; one bitmap update, done. The collection-leave drift class from the removals war story above cannot exist in this model, because there’s no per-collection facet row left to drift.</li><li>Write volume drops by roughly the average number of collections a product belongs to. That 42%-of-queue drain load has somewhere real to go.</li><li>member_count gets written as rb64_cardinality(member_set) in the same statement that updates the set. Counter-vs-set drift is eliminated by construction, closing the loop all the way back to the first trigger-counter design.</li></ul><h3>The measured details</h3><p>There is no sharding and no use of GIN indexes. The prior design of sharding member sets by product ID modulo and spreading write load, which scatters consecutive IDs, totally defeats Roaring’s compression. Bitmap rows are found by primary key on a B-tree instead. There’s no GIN needed because there’s nothing left to index.</p><p>We found that TOAST needed real tuning. Roaring bitmap data doesn’t compress; pglz tries and bails on essentially every bitmap. We set STORAGE EXTERNAL to skip that futile attempt on every write, and raised toast_tuple_target to keep the typical 2–8 KB bitmap inline (most value bitmaps average around 61 bytes). fillfactor = 75 keeps drain UPDATEs doing HOT updates on the otherwise-unindexed bitmap column.</p><p>Unfortunately, rb64_runoptimize is a no-op on our data, 1.0x on every large bitmap we tested. Most merchants have heavily scattered Shopify IDs that never form the runs that optimization exploits.</p><p>One thing that surprised me is that three different structures of the same query with identical output have <em>wildly</em> different latency on Postgres 17.</p><pre>-- (1) bare column reference: re-detoasted on every row of the AND<br>SELECT rb64_and_cardinality(facet_vbm.member_set, s.scope_bm) ...<br>-- 285ms, 286K buffer hits<br><br>-- (2) non-materialized CTE: inlined and re-executed per row<br>WITH scope AS (SELECT rb64_build_agg(id) AS scope_bm FROM eligible)<br>SELECT rb64_and_cardinality(facet_vbm.member_set, scope.scope_bm) ...<br>-- 43,399ms<br><br>-- (3) materialized CTE: computed once, referenced as a value<br>WITH scope AS MATERIALIZED (SELECT rb64_build_agg(id) AS scope_bm FROM eligible)<br>SELECT rb64_and_cardinality(facet_vbm.member_set, scope.scope_bm) ...<br>-- 84ms, 968 buffer hits</pre><p>84ms, 285ms, or <strong>43 seconds</strong>, depending on a keyword the planner is allowed to silently ignore in the middle spelling. All three return correct rows, which is exactly why this is dangerous; a functional test passes on all of them. We ended up writing an EXPLAIN-shape regression test specifically to catch this, since nothing else catches a 500x latency cliff hiding behind identical output.</p><p>Filter semi-joins showed the same shape of surprise: unnesting the bitmap into an array and hash-joining against eligible IDs ran in 5.5ms, versus 18.5ms for a per-row rb64_contains probe and 22.7ms for the legacy array’s @&gt; containment check.</p><p>One more example from seeding: collection bitmaps need to be built as membership intersected with the live catalog, not raw membership rows. Raw membership carried forward stale product IDs long after deletion; on one merchant it doubled the stale-ID count, on another it produced a single bitmap over a megabyte for what should have been a small collection.</p><h3>What the numbers say</h3><p>Measured on a staging database seeded with production-identical data, and running on the same hardware with the same resources as production:</p><iframe src="https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fdatawrapper.dwcdn.net%2FohHCd%2F1%2F&amp;display_name=Datawrapper&amp;url=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fdatawrapper.dwcdn.net%2FohHCd%2F1%2F&amp;image=https%3A%2F%2Fblue-sea-697d.quartiers047.workers.dev%3A443%2Fhttps%2Fdatawrapper.dwcdn.net%2FohHCd%2Fplain-s.png%3Fv%3D1&amp;type=text%2Fhtml&amp;schema=dwcdn" width="600" height="399" frameborder="0" scrolling="no"><a href="https://medium.com/media/a9739024a9135738abdd4ef784f17c25/href">https://medium.com/media/a9739024a9135738abdd4ef784f17c25/href</a></iframe><p>When I was first running the migration to scaffold the new tables and backfill, I expected it to take a while; our array rollups took about 6 minutes to build the first time, and the bitmaps took 45 seconds for the entire production database. Then when I diffed the freshly-seeded bitmaps against the live catalog, they matched exactly, and I found the current array rollups had already begun to drift. The design that finally stopped drifting is the one where drift became structurally impossible, not just something we got better at detecting.</p><h3>What’s next</h3><p>We’re still running performance testing before rolling this out to production, merchant by merchant, the same gradual approach we use for every rollup migration.</p><p>Three rewrites taught us that the failure mode to actually fear isn’t slow; it’s silently wrong. A facet count that quietly drifts until a merchant notices their newest arrivals aren’t showing up under a filter that should include them. Generation 4 didn’t happen because we got clever about compression. It happened because we kept being honest about what actually broke, generation after generation, until we landed on a model where that class of breakage isn’t cheaper to detect; it’s not possible in the first place.</p><p>None of this would have been possible without PlanetScale. We asked for a fairly niche extension with very few upvotes in June; their extensions team heard our case, saw our pain, and got it live across their fleet before the end of July. Big shout-out and thanks to Chris and Patrick ❤.</p><p>Four rewrites in three years, for a number most shoppers never consciously register. I think it was worth it and I’m so glad we don’t have to do a fifth.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*tDnPJ5HfbdAmGNPCAwYavA.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=00991321f30b" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/three-years-four-rewrites-and-a-roaring-bitmap-00991321f30b">Three years, four rewrites, and a Roaring bitmap</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Tearing Down Shopify’s Product Network Tracking Script]]></title>
            <link>https://layers.engineering/tearing-down-shopifys-product-network-tracking-script-ef7a4eb7f843?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/ef7a4eb7f843</guid>
            <category><![CDATA[shopify]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Fri, 05 Jun 2026 14:03:45 GMT</pubDate>
            <atom:updated>2026-06-05T14:03:45.795Z</atom:updated>
            <content:encoded><![CDATA[<p>I was debugging an issue on a customer store when I noticed a click/impression tracking script served from Shopify’s CDN that I’d never seen before. It’s part of the Product Network, Shopify’s ad platform for surfacing products across other storefronts. I went down the rabbit hole and came out with a list of things we were doing worse.</p><h3>What the Script Does</h3><p><strong>Remote product links carry attribution in the URL itself.</strong></p><p>Product Network ads look like normal product links, but the URL follows a specific pattern:</p><pre>/products/{seo-handle}-{encoded-payload}-{signature}-remote</pre><p>The payload is base64-decoded client-side into version, product ID, advertiser shop ID, ad response ID, and timestamp. The signature is parsed but not browser-validated; that’s presumably done server-side. Clean design: attribution travels with the link; no secondary lookup required.</p><p><strong>Two analytics layers share one observation pass.</strong></p><p>Products are bucketed into Remote, Collective-Static, and Organic origin types. Remote products emit ad-specific telemetry (rendered, impression, click, duplicate detection) plus standard product impression and click events. Organic products only get the latter. Ad attribution and storefront behavior analytics run through the same pipeline, but downstream consumers don’t have to understand ad state unless they need it.</p><p><strong>ShopifyAnalytics.meta first, fetch second.</strong></p><p>For organic products, instead of fetching /products/{handle}.js to resolve a product handle to an ID, the script first reads window.ShopifyAnalytics.meta.product and window.ShopifyAnalytics.meta.products. Shopify has already put the data on the page. Product pages have meta.product. Collection pages have meta.products. The script uses it directly and only falls back to a fetch if nothing’s there. The detail that made me look at our own code.</p><p><strong>Performance guards run before observation, not during it.</strong></p><p>A few things worth noting:</p><ul><li>Elements with a bounding box under 10x10px are filtered out before the IntersectionObserver sees them: hidden template nodes, zero-size utility links, and offscreen duplicates are gone.</li><li>Product links are deduplicated by normalized URL before observation starts. There is one entry per unique product URL. Multiple links pointing to the same product get a single impression ID assigned upfront.</li><li>A hard MAX_PROCESSED_LINKS cap prevents pathological storefronts from creating unbounded work.</li><li>The MutationObserver only rescans when candidate-related attributes change or product nodes are inserted. It does not rescan on every mutation.</li></ul><p><strong>rendered and impression are separate events.</strong></p><p>The script tracks DOM presence and viewport exposure independently. Shopify can distinguish what loaded from what was actually seen. This is useful for ad quality measurement and not something most tracking scripts do.</p><h3>What We Found in Our Own Code</h3><p>A few things we fixed after reading this:</p><p><strong>Repeated array scans occurred on every event.</strong> Resolving which product a DOM element belonged to ran Array.find() against the full result set on every impression, hover, and click. We indexed results by handle, product ID, and request key on first access and cached the maps.</p><p><strong>No element context caching existed.</strong> DOM parsing and state matching ran redundantly across impression, hover, and touch flows for the same element. We added a WeakMap cache keyed by element, cleared on each observer refresh so dynamic content stays fresh.</p><p><strong>No minimum size filter existed.</strong> We weren’t filtering out zero-size or sub-10px elements before observing. We added the same getBoundingClientRect() check. This required updating JSDOM tests since default JSDOM boxes are zero-sized.</p><p><strong>Observer set was not deduplicated.</strong> Product cards in Shopify themes typically include multiple elements that point to the same product: an image link, a title link, and a quick-add button. We were observing all of them. Now we build a deduplicated candidate set by normalized key (href, data-product-url, data-product-id) before handing anything to IntersectionObserver.</p><p><strong>MutationObserver was firing on irrelevant mutations.</strong> Shopify storefronts mutate constantly: drawers, app embeds, inventory badges, quantity inputs. We were rescanning on all of it. The observer now checks whether any added node contains a product candidate or whether a candidate-related attribute changed before scheduling a refresh.</p><p><strong>Product view fetching occurred when the data was already on the page using t</strong>he same ShopifyAnalytics.meta pattern. We were fetching /products/{handle}.js as the default on product and collection pages. It’s now the fallback.</p><p>The script isn’t doing anything novel. IntersectionObserver for impressions, MutationObserver for dynamic content, and deduplication before observation are all established patterns. What’s notable is the consistency. Every optimization follows from the same principle: reduce work before it starts rather than handling it after.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ef7a4eb7f843" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/tearing-down-shopifys-product-network-tracking-script-ef7a4eb7f843">Tearing Down Shopify’s Product Network Tracking Script</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Stop Making `/cart.js` Calls]]></title>
            <link>https://layers.engineering/stop-making-cart-js-calls-9d332231985f?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/9d332231985f</guid>
            <category><![CDATA[javascript]]></category>
            <category><![CDATA[shopify-apps]]></category>
            <category><![CDATA[analytics]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Mon, 04 May 2026 16:38:34 GMT</pubDate>
            <atom:updated>2026-05-04T16:42:53.471Z</atom:updated>
            <content:encoded><![CDATA[<p><em>Most Shopify tracking scripts send additional cart requests to determine cart contents. Ours does not. This article explains our approach and why common alternatives are less effective.</em></p><p>To observe this, open the network tab on a Shopify store using a recommendations or analytics app, add an item to the cart, and review the resulting network activity.</p><p>You’ll see the storefront’s /cart/add.js. Then, a beat later, you’ll see a /cart.js fired by the app. Sometimes two. Sometimes wrapped in a polling loop. The app is asking Shopify, “Okay, what’s in the cart now?” — a question the storefront’s own response already answered.</p><p>This pattern is widespread. Hundreds of the largest Shopify Apps and most session recording tools observe the add event and then issue a follow-up request to reconstruct the cart state. While functional, this approach is unnecessary, generates excess traffic, and makes your script the originator of cart requests rather than a passive observer.</p><p>Before detailing our solution, it is important to understand why most apps adopt this approach and the limitations of the alternatives.</p><h3>How Most Apps Do It (And Why It Doesn’t Scale)</h3><p>There are three common patterns for cart tracking in third-party Shopify apps. All three share the same flaw: they treat the cart as something to be queried, rather than something to be observed.</p><h3>Pattern 1: Polling</h3><p>The simplest approach. Set an interval, fetch `/cart.js` repeatedly, and diff the result.</p><pre>let previousCart = null;<br><br>setInterval(async () =&gt; {<br>  const response = await fetch(&#39;/cart.js&#39;);<br>  const cart = await response.json();<br><br>  if (previousCart &amp;&amp; cart.item_count !== previousCart.item_count) {<br>    handleCartChange(previousCart, cart);<br>  }<br><br>  previousCart = cart;<br>}, 2000);</pre><p>This method works across all Shopify themes, requires no knowledge of storefront cart request implementation, and is simple to deploy. As a result, many apps use this approach by default.</p><p>Back in early 2025 I called this out publicly on <a href="https://x.com/0x15f/status/1882460585654366704">Gorgias</a> and <a href="https://x.com/0x15f/status/1892952651404312929">Alia</a> — both used to run polling intervals of 500ms to 2 seconds on storefronts across their install base. <em>Both companies have since adopted the patching pattern we cover or use Shopify Web Pixels.</em></p><p>The problems:</p><ul><li><strong>It hammers Shopify’s Storefront API rate limits.</strong> Shopify enforces a bucket-based rate limit on /cart.js calls. A 500ms polling interval generates 7,200 requests per hour per active session. On a busy storefront with hundreds of concurrent sessions, apps polling this aggressively can exhaust the rate limit bucket — and when that happens, the storefront’s <em>own</em> cart requests start getting 429’d. Shoppers hit “add to cart” and nothing happens. The app caused the outage.</li><li><strong>It’s laggy by design.</strong> Cart changes are only visible after the next tick, which means your add_to_cart event fires up to 2 seconds late.</li><li><strong>Diffing is fragile.</strong> Item count alone misses quantity changes on existing line items. Full deep-diffing cart objects means you’re now maintaining that logic indefinitely.</li><li><strong>It doesn’t give you the add context.</strong> You know the cart changed, but you’ve lost the original request, the product handle, the source surface, and any properties the storefront attached. Attribution at this point is a guess.</li></ul><h3>Pattern 2: PerformanceObserver</h3><p>A more sophisticated approach. The <a href="https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver">PerformanceObserver API</a> can observe completed network requests via PerformanceResourceTiming entries, which gives you URL, timing, and response size — without patching anything.</p><pre>const observer = new PerformanceObserver((list) =&gt; {<br>  for (const entry of list.getEntries()) {<br>    if (entry.initiatorType === &#39;fetch&#39; &amp;&amp; entry.name.includes(&#39;/cart/add&#39;)) {<br>      // We know a cart add happened — but that&#39;s all we know<br>      handleCartAdd(entry);<br>    }<br>  }<br>});<br><br>observer.observe({ type: &#39;resource&#39;, buffered: true });</pre><p>This method is non-invasive and does not modify fetch or XHR. Some analytics vendors use it as a ‘safe’ observation layer, considering it less risky than modifying prototypes.</p><p>The problems:</p><ul><li><strong>You get timing metadata, not request or response bodies.</strong> PerformanceResourceTiming tells you a fetch to /cart/add.js completed in 340ms. It doesn’t tell you what was added, which variant, how much, or what the cart looks like now.</li><li><strong>So you still have to make a follow-up /cart.js call</strong> to find out what changed. The observation gives you a trigger, not the data.</li><li><strong>You can’t enrich the request.</strong> There’s no hook into the outbound payload; there is no way to inject a session ID or attribution token before the request leaves the browser.</li><li><strong>PerformanceObserver has gaps.</strong> Entries can be dropped under memory pressure. Buffering behavior varies by browser. It’s a telemetry API, not a reliable interception layer.</li></ul><h3>Pattern 3: Custom Event Listening</h3><p>Some themes and apps dispatch custom DOM events when cart state changes — cart:updated, theme:cart:added, and similar. Listening to these is elegant when it works:</p><pre>document.addEventListener(&#39;cart:updated&#39;, async (event) =&gt; {<br>  // Some themes pass cart data in event.detail<br>  const cart = event.detail?.cart ?? await fetch(&#39;/cart.js&#39;).then(r =&gt; r.json());<br>  handleCartUpdate(cart);<br>});</pre><p>The problems:</p><ul><li><strong>This is entirely theme-dependent.</strong> Dawn fires different events than Impulse. Custom themes fire none. There’s no standard.</li><li><strong>You’re at the mercy of the theme developer’s event naming and payload shape.</strong> If they change it, your tracking breaks silently.</li><li><strong>It still usually requires a follow-up fetch</strong> because most themes don’t include full cart data in the event payload.</li><li><strong>You still can’t touch the outbound request.</strong> No request enrichment, no line-item attribution.</li></ul><h3>The Right Approach: Patch the Primitives</h3><p>All three patterns share a common flaw: they detect that an event occurred and then request the data separately. The original cart request, which contains all necessary information, is not utilized.</p><p>The solution is to intercept cart activity at the network layer. Layers’ cart tracking is based on patching the browser’s network primitives to observe existing cart traffic without generating additional requests<strong>.</strong></p><h3>Patching fetch and XHR</h3><p>The interceptor patches both network primitives on initialization:</p><pre>const originalFetch = window.fetch;<br><br>window.fetch = function (input, init) {<br>  const url = resolveUrl(input);<br><br>  if (isCartRoute(url) || isLayersRoute(url)) {<br>    return handleFetchIntercept(originalFetch, input, init, url);<br>  }<br><br>  return originalFetch.apply(this, arguments);<br>};</pre><pre>const originalOpen = XMLHttpRequest.prototype.open;<br>const originalSend = XMLHttpRequest.prototype.send;<br><br>XMLHttpRequest.prototype.open = function (method, url, ...rest) {<br>  this._layersUrl = resolveUrl(url);<br>  this._layersMethod = method;<br>  return originalOpen.apply(this, [method, url, ...rest]);<br>};<br><br>XMLHttpRequest.prototype.send = function (body) {<br>  if (isCartRoute(this._layersUrl)) {<br>    return handleXhrIntercept(this, body, originalSend);<br>  }<br>  return originalSend.apply(this, arguments);<br>};</pre><p>The patch is deliberately narrow. isCartRoute matches a specific allowlist:</p><pre>function isCartRoute(url) {<br>  if (!url) return false;<br>  const path = new URL(url, location.origin).pathname;<br>  return [<br>    &#39;/cart/add&#39;, &#39;/cart/add.js&#39;,<br>    &#39;/cart/change&#39;, &#39;/cart/change.js&#39;,<br>    &#39;/cart/update&#39;, &#39;/cart/update.js&#39;,<br>    &#39;/cart.js&#39;,<br>  ].some(route =&gt; path.endsWith(route));<br>}</pre><p>All other requests pass through unchanged. The script acts as a listener on a defined set of routes, not as a proxy.</p><h3>The Part Most Apps Get Wrong: Payload Safety</h3><p>Before getting into what we actually inject, it’s worth being explicit about something: <strong>patching fetch and XHR on a live Shopify storefront is dangerous if you don’t handle every possible request payload type.</strong> You are sitting in the critical path of every cart interaction in the store. If your enrichment code throws on an unexpected body format, the cart breaks. Not for you, for the shopper.</p><p>In early 2025, an app deployed a similar network patch across its portfolio of Shopify brands without fully accounting for the payload variants Shopify storefronts can send. The patch assumed JSON bodies. When it encountered a FormData request, which older themes and some native Shopify UI components still use, the enrichment code threw, the modified request was never sent, and cart adds silently failed. This rolled out to their entire install base before it was caught.</p><p>That’s the failure mode. Here’s what you actually have to handle.</p><h3>The Shopify cart payload matrix</h3><p>Shopify storefront cart requests can arrive in four distinct body formats depending on the theme, the Shopify UI component (Cart API, Section Rendering API, native Liquid forms), and whether the request was constructed by theme JS or a third-party app:</p><ul><li><strong>JSON string</strong> → Modern themes, Ajax cart APIs, most app-generated requests</li><li><strong>FormData</strong> → Native Liquid &lt;form&gt; submits, older themes, some Shopify UI components</li><li><strong>URLSearchParams</strong> → Some older Ajax patterns, theme kit tooling</li><li><strong>null / undefined</strong> → GET /cart.js reads, some edge cases</li></ul><p>The enrichment code must handle all four formats and, critically, <strong>pass through any unrecognized formats without error.</strong></p><pre>function enrichRequestBody(body, sessionId, attributionToken, url) {<br>  try {<br>    // FormData — mutate in place, it&#39;s passed by reference<br>    if (body instanceof FormData) {<br>      body.set(&#39;attributes[_layers_session_id]&#39;, sessionId);<br>      if (isAddRoute(url) &amp;&amp; attributionToken) {<br>        body.set(&#39;properties[_layers_attribution]&#39;, attributionToken);<br>      }<br>      return body;<br>    }<br><br>    // URLSearchParams — same pattern as FormData<br>    if (body instanceof URLSearchParams) {<br>      body.set(&#39;attributes[_layers_session_id]&#39;, sessionId);<br>      if (isAddRoute(url) &amp;&amp; attributionToken) {<br>        body.set(&#39;properties[_layers_attribution]&#39;, attributionToken);<br>      }<br>      return body;<br>    }<br><br>    // JSON string — parse, enrich, re-serialize<br>    if (typeof body === &#39;string&#39; &amp;&amp; body.trim().startsWith(&#39;{&#39;)) {<br>      const parsed = JSON.parse(body);<br>      parsed.attributes = {<br>        ...parsed.attributes,<br>        _layers_session_id: sessionId,<br>      };<br>      if (isAddRoute(url) &amp;&amp; attributionToken &amp;&amp; parsed.items) {<br>        parsed.items = parsed.items.map(item =&gt; ({<br>          ...item,<br>          properties: { ...item.properties, _layers_attribution: attributionToken },<br>        }));<br>      } else if (isAddRoute(url) &amp;&amp; attributionToken &amp;&amp; parsed.id) {<br>        // Single-item add shorthand<br>        parsed.properties = { ...parsed.properties, _layers_attribution: attributionToken };<br>      }<br>      return JSON.stringify(parsed);<br>    }<br><br>    // null, undefined, ReadableStream, Blob, ArrayBuffer, or anything else<br>    // — return completely untouched, no matter what<br>    return body;<br><br>  } catch (err) {<br>    // Enrichment failed — return the original body unmodified<br>    // The cart request must succeed even if we can&#39;t annotate it<br>    logger.debug(&#39;Cart body enrichment failed, passing through original&#39;, err);<br>    return body;<br>  }<br>}</pre><p>Three things to notice:</p><p><strong>The try/catch block encompasses the entire function, not just individual branches.</strong> If any error occurs, such as a malformed JSON body or unexpected FormData structure, the original body is returned unmodified. The cart request continues, ensuring that annotation is optional and cart functionality is preserved for the shopper.</p><p><strong>Unknown body types are returned as-is. </strong>Formats such as ReadableStream, Blob, ArrayBuffer, or any future types from Shopify are not introspected, and no errors are thrown.</p><p><strong>The JSON handling logic supports both single-item and array-item add formats.</strong> Shopify’s cart add API accepts both { id, quantity } and { items: […] } structures. Supporting only one format results in missed cart additions.</p><h3>XHR body handling</h3><p>XHR send() receives the same payload variety, but without the init wrapper — the body is passed directly:</p><pre>XMLHttpRequest.prototype.send = function (body) {<br>  if (!isCartRoute(this._layersUrl)) {<br>    return originalSend.apply(this, arguments);<br>  }<br><br>  let enrichedBody = body;<br>  try {<br>    enrichedBody = enrichRequestBody(body, getSessionId(), resolveAttribution(), this._layersUrl);<br>  } catch (err) {<br>    // Enrichment failed entirely — fire original body<br>    logger.debug(&#39;XHR enrichment failed&#39;, err);<br>    enrichedBody = body;<br>  }<br><br>  return originalSend.call(this, enrichedBody);<br>};</pre><p>Same principle: if enrichment fails at any level, originalSend fires with the original body. We never swallow the request itself.</p><h3>Test against all four formats before shipping</h3><p>If you are developing a similar solution, the following test matrix should be completed before deploying to production:</p><pre>// 1. JSON string — modern themes<br>fetch(&#39;/cart/add.js&#39;, {<br>  method: &#39;POST&#39;,<br>  headers: { &#39;Content-Type&#39;: &#39;application/json&#39; },<br>  body: JSON.stringify({ id: variantId, quantity: 1 }),<br>});<br><br>// 2. FormData — Liquid form submits<br>const fd = new FormData();<br>fd.append(&#39;id&#39;, variantId);<br>fd.append(&#39;quantity&#39;, 1);<br>fetch(&#39;/cart/add.js&#39;, { method: &#39;POST&#39;, body: fd });<br><br>// 3. URLSearchParams<br>const params = new URLSearchParams({ id: variantId, quantity: 1 });<br>fetch(&#39;/cart/add.js&#39;, { method: &#39;POST&#39;, body: params });<br><br>// 4. XHR with JSON<br>const xhr = new XMLHttpRequest();<br>xhr.open(&#39;POST&#39;, &#39;/cart/add.js&#39;);<br>xhr.setRequestHeader(&#39;Content-Type&#39;, &#39;application/json&#39;);<br>xhr.send(JSON.stringify({ id: variantId, quantity: 1 }));</pre><p>All four formats should add items to the cart successfully, regardless of enrichment success. Cart functionality must be preserved; annotation is optional.</p><h3>Request-Side: Enriching What’s Already Being Sent</h3><p>When the storefront fires a matching cart request, we modify the outbound payload before it leaves the browser. There are two things we inject: a session ID on all cart requests, and a line-item attribution token on add flows only.</p><h3>Session stitching</h3><p>Every cart payload format gets a _layers_session_id attribute injected at the cart level:</p><pre>function injectSessionAttribute(body, sessionId) {<br>  if (body instanceof FormData || body instanceof URLSearchParams) {<br>    body.set(&#39;attributes[_layers_session_id]&#39;, sessionId);<br>    return body;<br>  }<br><br>  if (typeof body === &#39;string&#39;) {<br>    try {<br>      const parsed = JSON.parse(body);<br>      parsed.attributes = {<br>        ...parsed.attributes,<br>        _layers_session_id: sessionId,<br>      };<br>      return JSON.stringify(parsed);<br>    } catch {<br>      return body;<br>    }<br>  }<br><br>  return body;<br>}</pre><p>This provides downstream systems with a stable identifier for associating cart events with a browser session, eliminating the need for additional requests.</p><h3>Line-item attribution (add flows only)</h3><p>For /cart/add requests specifically, we also stamp a _layers_attribution token at the line-item level:</p><pre>function injectItemAttribution(item, attributionToken) {<br>  return {<br>    ...item,<br>    properties: {<br>      ...item.properties,<br>      _layers_attribution: attributionToken,<br>    },<br>  };<br>}</pre><p><strong>This process is not applied to /cart/update or /cart/change requests. </strong>Attribution is only added during add flows, as mutation flows do not provide meaningful attribution context. Enrichment is applied only where justified.</p><p>Attribution itself is resolved conservatively:</p><pre>function resolveAttribution(item) {<br>  // 1. Already on the item — leave it alone<br>  if (item.properties?._layers_attribution) {<br>    return item.properties._layers_attribution;<br>  }<br><br>  // 2. Current PDP context matches<br>  const pdpContext = getCurrentPdpContext();<br>  if (pdpContext &amp;&amp; pdpContext.variantId === item.id) {<br>    return pdpContext.attributionToken;<br>  }<br><br>  // 3. Last valid product interaction<br>  const lastInteraction = getLastProductInteraction();<br>  if (lastInteraction?.isValid() &amp;&amp; lastInteraction.variantId === item.id) {<br>    return lastInteraction.attributionToken;<br>  }<br><br>  // 4. Skip rather than guess<br>  return null;<br>}</pre><p>If product identity cannot be reliably resolved, the function returns null and attribution is omitted. Incorrect attribution is considered more detrimental than missing data.</p><h3>Response-Side: Learning from What the Storefront Already Received</h3><p>This is where the ‘stop making cart calls’ principle provides the most significant benefit.</p><p>Rather than issuing a /cart.js request after a cart mutation, the script listens to the response already generated by the storefront’s request.</p><h3>The fetch interception pattern</h3><p>The key implementation detail here is how we handle the response without disrupting the merchant’s promise chain:</p><pre>function handleFetchIntercept(originalFetch, input, init, url) {<br>  // Modify the outbound request<br>  const modifiedInit = enrichRequest(init, url);<br><br>  // Fire the original fetch — return the promise immediately<br>  const fetchPromise = originalFetch.call(window, input, modifiedInit);<br><br>  // Process the response in a detached chain<br>  // Errors here cannot propagate to the merchant&#39;s await<br>  fetchPromise<br>    .then(response =&gt; response.clone())<br>    .then(cloned =&gt; cloned.json())<br>    .then(data =&gt; processCartResponse(data, url))<br>    .catch(err =&gt; {<br>      // Log internally, never throw<br>      logger.debug(&#39;Cart response processing failed&#39;, err);<br>    });<br><br>  // Return the original promise unmodified<br>  return fetchPromise;<br>}</pre><p>The response is cloned before reading to avoid interfering with the storefront’s .then() handler. All processing occurs in a detached chain, ensuring that parsing failures do not result in unhandled rejections in the merchant’s error tools.</p><h3>Add-to-cart responses</h3><p>When a /cart/add succeeds, we use the response body to finalize the add_to_cart event:</p><pre>function processAddResponse(responseData, originalRequestBody) {<br>  try {<br>    const items = responseData.items ?? [responseData];<br><br>    for (const item of items) {<br>      const product = resolveProduct(item.product_id, item.variant_id);<br>      const value = item.final_price ? item.final_price / 100 : null;<br><br>      emit(&#39;add_to_cart&#39;, {<br>        product_id: item.product_id,<br>        variant_id: item.variant_id,<br>        quantity: item.quantity,<br>        value,<br>        attribution: item.properties?._layers_attribution ?? null,<br>      });<br>    }<br>  } catch {<br>    // Fall back to request payload — event fires either way<br>    processAddFromRequest(originalRequestBody);<br>  }<br>}</pre><p>If response parsing fails, the script falls back to the request data. The event is triggered in either case.</p><h3>Cart state from mutation responses</h3><p>GET /cart.js, /cart/update, and /cart/change responses update an in-memory productsInCart map:</p><pre>function syncCartState(responseData) {<br>  if (!responseData?.items) return;<br><br>  productsInCart.clear();<br><br>  for (const item of responseData.items) {<br>    productsInCart.set(item.variant_id, {<br>      productId: item.product_id,<br>      variantId: item.variant_id,<br>      quantity: item.quantity,<br>      handle: item.handle,<br>    });<br>  }<br>}</pre><p>This context is then used to exclude products in Layers block requests, filtering out items already in the cart, previously shown, or currently viewed. All information is obtained from existing storefront responses without additional requests.</p><h3>Why Stack Trace Visibility Matters</h3><p>A significant drawback of naively wrapping fetch is that the script can dominate merchant debugging sessions. If a developer investigates a cart error and the wrapper uses async/await throughout, the script appears first in stack traces.</p><p>The initial implementation looked like this:</p><pre>// Before — our wrapper was in the call stack<br>window.fetch = async function (input, init) {<br>  const modifiedInit = await enrichRequest(init);<br>  const response = await originalFetch(input, modifiedInit);<br>  await processResponse(response.clone());<br>  return response;<br>};</pre><p>Any error in processResponse could propagate, causing the script’s frame to appear in every stack trace involving a cart fetch. Merchant debugging tools would then flag the script.</p><p>The solution is to return the original promise immediately and perform all processing in a detached chain:</p><pre>// After — we&#39;re not in the call stack for the merchant&#39;s await<br>window.fetch = function (input, init) {<br>  const modifiedInit = enrichRequestSync(init);<br>  const promise = originalFetch.call(window, input, modifiedInit);<br><br>  // Detached — errors here are ours, not the merchant&#39;s<br>  promise.then(r =&gt; r.clone()).then(r =&gt; r.json())<br>    .then(processResponse)<br>    .catch(logger.debug);<br><br>  return promise;<br>};</pre><p>The merchant’s await fetch(…) now resolves as it would with native fetch. All processing occurs in a parallel chain, ensuring it does not interfere with the merchant’s code.</p><h3>The Rules</h3><p>Every decision above follows from a small set of constraints:</p><ol><li><strong>Don’t make extra cart calls to observe the cart.</strong> The storefront already made the call.</li><li><strong>Enrich in place, don’t replay.</strong> Modify the outbound payload and listen for the inbound response.</li><li><strong>Patch only known routes.</strong> Don’t become a general-purpose network proxy.</li><li><strong>Add the minimum metadata.</strong> Session ID and attribution token, nothing else.</li><li><strong>Never guess attribution.</strong> Skip rather than stamp the wrong product.</li><li><strong>Treat add and mutation flows differently.</strong> Line-item attribution belongs only on adds.</li><li><strong>Keep response processing detached.</strong> Our failures can’t break merchant cart flows.</li><li><strong>Stay out of the stack trace.</strong> Developers debugging cart issues shouldn’t be reading our code.</li></ol><h3>The Broader Point</h3><p>While observing network primitives is a well-established pattern, few Shopify apps apply it to cart tracking. Most continue to listen for events and then re-fetch to reconstruct state.</p><p>The optimal<strong> </strong>cart tracking script understands cart activity without generating additional cart requests. It observes rather than duplicates, enriches rather than replays, and remains adjacent to the merchant’s stack rather than interfering with it.</p><p>If a script issues cart requests to determine cart state, it likely already has the necessary information but is not observing the correct source.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*5WHGrdB6DTQpKVvQK2bfew.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=9d332231985f" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/stop-making-cart-js-calls-9d332231985f">Stop Making `/cart.js` Calls</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How We Evaluate Search Quality at Scale With LLM Judging and IR Metrics]]></title>
            <link>https://layers.engineering/how-we-evaluate-search-quality-at-scale-with-llm-judging-and-ir-metrics-f0497c7c98a6?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/f0497c7c98a6</guid>
            <category><![CDATA[search-engines]]></category>
            <category><![CDATA[machine-learning]]></category>
            <category><![CDATA[ecommerce]]></category>
            <category><![CDATA[shopify]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Thu, 09 Apr 2026 18:17:34 GMT</pubDate>
            <atom:updated>2026-04-09T18:17:34.050Z</atom:updated>
            <content:encoded><![CDATA[<h3>How We Evaluate Search Quality at Commerce Scale</h3><p>Search is easy to ship, but hard to know when you’ve broken it.</p><p>You can change a ranking model, deploy it, and have no idea whether it improved or worsened. Conversion rate takes days to move. Click-through rate is noisy. And asking someone to manually review results for hundreds of queries is the kind of thing that happens once, never gets maintained, and tells you nothing about what changed between this week and last.</p><p>When we started building Layers, we knew we needed a rigorous way to measure search quality that could keep pace with our rapid iteration on ranking models. What we built is an end-to-end evaluation loop that combines LLM judging, classical IR metrics, A/B experimentation, and our own analytics query language to close the feedback cycle between a model change and a reliable signal about whether it actually helped.</p><h3>Why E-commerce Search Is Hard to Evaluate</h3><p>Most search evaluation literature assumes you have relevance labels, which are human annotations that say “this result is good for this query.” At scale, that’s expensive and slow. User signals like clicks and purchases are the natural proxy, but they’re lagged, sparse for tail queries, and confounded by position bias.</p><p>E-commerce search has a few additional complications.</p><p><strong>The catalog is the constraint.</strong> A query for “navy blue midi dress” might have zero relevant results, not because the search is broken, but because the merchant doesn’t carry it. A judge who doesn’t understand the catalog will score that as a failure. It’s not.</p><p><strong>Intent is messy.</strong> A query like “gold” on a jewelry site means something completely different than “gold” on a workwear site. Branded terms, attribute-heavy queries, negation (“no leather”), and ambiguous intent all behave differently and need to be evaluated differently.</p><p><strong>Head queries aren’t the whole story.</strong> The top 10 queries for any given store might drive 40% of search sessions, but the tail is enormous. A model optimized for head queries at the expense of tail quality is a common failure mode that aggregate metrics hide completely.</p><p>These constraints shaped every decision we made about how to build our evaluation system.</p><h3>The Evaluation Pipeline</h3><p>When I trigger an evaluation run, the system replays real user queries against the live search engine and has an LLM judge score the results. Here’s how that actually works.</p><h3>Sourcing Queries</h3><p>Queries come from one of two places. The first is real traffic: we sample the most frequent search terms from our analytics data over a configurable date window, ranging from 1 to 90 days, with a cap of 1,000 queries per run. The second is seeded sets, which are curated query lists we’ve pre-loaded for specific evaluation scenarios, such as testing attribute-heavy queries or branded searches in isolation.</p><h3>Classifying Queries Before Judging</h3><p>Before we send anything to the judge, every query gets classified along two axes.</p><p>The first is the volume tier, determined locally using frequency ratios relative to the top query in the set. Head queries sit at 10% or more of the maximum frequency. Torso queries range from 1% to 10%. Tail queries are everything below 1%.</p><p>The second is the semantic type, determined by an LLM in batches. Each query is labeled as branded or generic, along with any applicable modifier flags: negative intent, attribute-heavy, ambiguous intent, or synonym-heavy.</p><p>This classification matters because a ranking model can be excellent on head-branded queries and quietly terrible on tail attribute-heavy ones. Aggregate scores hide that. Bucket breakdowns reveal it.</p><h3>Running the Search + LLM Judge</h3><p>Each query runs through the actual search pipeline, the same code path a real user hits, and then the top 20 results get sent to an LLM judge running on AWS Bedrock.</p><p>The judge is multimodal. It pulls both text metadata (title, description, product type, tags) and product images from Shopify CDN thumbnails. Product imagery carries a signal that text doesn’t. A query for “casual” means something different visually than it does semantically, and a result that looks off-brand wildly is a worse result than a text-only judge would recognize.</p><p>The judge also receives catalog context: a description of the store, its product types, and its brands. This is what lets it distinguish “bad results” from “query outside this catalog.”</p><p>The judge scores each result set across six dimensions, all on a 0 to 100 scale. Relevance measures whether the results match the query topic. Intent measures whether the results match what the user intended to do. Attribute measures whether constraints like color, size, or material are respected. Brand measures whether branded queries return the correct brand. Negative measures whether there are clearly wrong results in the top positions. Diversity measures the appropriateness of the variety in the result set.</p><p>In addition to those six scores, the judge assigns per-product relevance grades on a 0–3 scale. Those grades are what feed the IR metrics.</p><h3>IR Metrics</h3><p>From the per-product grades, we compute four standard information retrieval metrics.</p><p>NDCG@10, or Normalized Discounted Cumulative Gain, is the primary one I watch. It measures not just whether relevant results appear, but whether they appear near the top, applying a logarithmic discount for lower positions. Ranking a great product third instead of first is a measurable loss, not a rounding error.</p><p>MRR, or Mean Reciprocal Rank, tells us how far down the list a user has to scroll to find the first genuinely relevant result. Recall@10 tells us how many of the relevant products in the catalog actually made it into the top 10. Estimated CTR@10 is a position-weighted click probability estimate based on known click curves.</p><h3>Finalization and Report</h3><p>Once all query jobs are complete, the system aggregates everything into a report. That includes average scores across all six judge dimensions, breakdowns by bucket (head, torso, tail, branded, generic, and modifier type), a histogram of score distributions, and the top and bottom-scoring queries. The bottom list is the most useful one.</p><p>There’s also an LLM-generated summary of strengths, weaknesses, and areas to investigate. That summary isn’t a replacement for reading the data. It’s a starting point, useful for surfacing patterns that aren’t obvious from aggregate numbers, like “tail attribute-heavy queries are underperforming because the model underweights exact keyword matches relative to semantic similarity.”</p><h3>How the Ranking Model Is Defined</h3><p>To understand what the evaluation is measuring, it helps to understand what it’s evaluating against.</p><p>Ranking models at Layers are defined as versioned JSON files. Each file declares a set of features, along with their weights, transforms, and normalization options. A simplified excerpt looks like this:</p><pre>{<br>  &quot;name&quot;: &quot;default&quot;,<br>  &quot;version&quot;: 1,<br>  &quot;features&quot;: [<br>    { &quot;feature&quot;: &quot;text_similarity&quot;, &quot;weight&quot;: 0.18 },<br>    { &quot;feature&quot;: &quot;image_similarity&quot;, &quot;weight&quot;: 0.17 },<br>    { &quot;feature&quot;: &quot;keyword:title:bm25&quot;, &quot;weight&quot;: 0.1, &quot;normalize&quot;: &quot;minmax&quot; },<br>    { &quot;feature&quot;: &quot;product_metric:views_7d&quot;, &quot;weight&quot;: 0.05, &quot;transform&quot;: &quot;sigmoid&quot; },<br>    { &quot;feature&quot;: &quot;published_at&quot;, &quot;weight&quot;: 0.1, &quot;transform&quot;: &quot;decay_days&quot; }<br>  ],<br>  &quot;options&quot;: { &quot;normalization&quot;: &quot;minmax&quot;, &quot;combiner&quot;: &quot;linear&quot;, &quot;scoreFilterStd&quot;: 3.0 }<br>}</pre><p>Features can include embeddings (text or image similarity), keyword signals, behavioral signals such as views, sales, and purchase rate across various time windows, or product properties such as recency. A model compiler takes these definitions and produces the final hybrid score.</p><p>Models compose into pipelines. A pipeline specifies the candidate generation strategy, which ranking model to apply, and an optional second-pass rerank model. That separation matters because candidate generation and ranking are distinct failure modes with different fixes.</p><h3>A/B Experimentation</h3><p>Changing a feature weight and running an evaluation tells you how the model performs against an LLM judge. That’s necessary but not sufficient. You also need to know what happens with real users.</p><p>Experiments at Layers are defined as JSON files alongside the models. Each experiment specifies a baseline model, a candidate model, a traffic split percentage, and which stores its active on. The assignment is deterministic based on the session ID, so a user sees consistent results across their session, and we can reproduce exactly which model served any given search request.</p><p>Every search request is logged with the experiment ID and experiment group fields, which means the evaluation and analytics systems are unified. You can measure LLM judge scores by experiment group and business outcomes by experiment group using the same query language.</p><h3>LayersQL: Closing the Loop with Business Metrics</h3><p>LLM judge scores and IR metrics tell you about search quality. Business metrics tell you about business impact. We built LayersQL, our own analytics query language derived from ShopifyQL, to make it easy to query search data without digging into the underlying schema.</p><p>A typical experiment analysis looks like this:</p><pre>FROM search_text<br>SHOW SUM(total_sales), SUM(quantity_purchased), AVG(click_rate)<br>GROUP BY experiment_group<br>SINCE -30dFROM search_text</pre><p>The syntax will feel familiar to anyone who has used ShopifyQL. Experiment ID and experiment group are first-class dimensions in every dataset, so any analytics query can be segmented by experiment arm without any extra work.</p><h3>How It All Fits Together</h3><p>The full loop looks like this. We define a ranking model in JSON and create an experiment that routes some percentage of traffic to it. The experiment assigner deterministically splits sessions between baseline and candidate, logging the assignment on every search request. We run an auto-evaluation, sourcing real queries from our analytics data, classifying them, executing the search pipeline, and having the LLM judge-score the results with full catalog context. The evaluation report provides NDCG@10, MRR, and six judge dimensions, broken down by query bucket. We then run a LayersQL query to check whether the candidate group is seeing different business outcomes in terms of revenue, units, and click rate. If both signals point in the same direction, we proceed with the experiment. If they diverge, we investigate why.</p><p>One feature worth calling out is the “reuse” evaluation mode. Instead of re-fetching queries, it re-evaluates the same query set from a previous run against a new model configuration. That makes comparison clean: same queries, different ranking, comparable scores. The comparison report compares them query-by-query, counting improvements, regressions, and unchanged queries based on score-delta thresholds.</p><h3>What This Has Changed</h3><p>Before we had this system, ranking model changes were essentially done in the dark. You’d make a change, watch the conversion rate for a week, and try to infer whether it helped or was just noise. The iteration loop was slow, and the signal was unreliable.</p><p>Now we can run an evaluation, get NDCG and judge dimension scores broken down by query type, compare them against a baseline, and have a reasonably confident signal before we touch live traffic. The experiments layer means we can ship to a traffic split first and graduate only when both the evaluation and business signals agree.</p><p>The multimodal judge has been the biggest surprise in practice. There are product results that look fine on paper, right category, matching keywords, that the judge correctly flags as wrong because the product image is obviously misaligned with what the query was asking for. Text-only evaluation would miss that entirely.</p><p>The bucket breakdowns have been the second-most valuable part. Tail query performance is invisible in aggregate metrics. Breaking results out by head, torso, and tail has surfaced model behaviors we would have shipped without noticing, including one case where a weight change that improved NDCG@10 overall was quietly degrading tail-branded queries by 12 points.</p><p>Search evaluation is one of those infrastructure investments that doesn’t feel urgent until you’ve made a bad deployment and spent a week figuring out why conversion dropped. We’re glad we built it early.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZUgj2ARKYfGa4prFaUFM3A.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=f0497c7c98a6" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/how-we-evaluate-search-quality-at-scale-with-llm-judging-and-ir-metrics-f0497c7c98a6">How We Evaluate Search Quality at Scale With LLM Judging and IR Metrics</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Hidden Failure Modes of Laravel + PgBouncer + PlanetScale]]></title>
            <link>https://layers.engineering/the-hidden-failure-modes-of-laravel-pgbouncer-planetscale-da894cdbc608?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/da894cdbc608</guid>
            <category><![CDATA[pgbouncer]]></category>
            <category><![CDATA[laravel]]></category>
            <category><![CDATA[postgresql]]></category>
            <category><![CDATA[planet-scale]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Tue, 10 Mar 2026 17:23:23 GMT</pubDate>
            <atom:updated>2026-03-10T17:23:23.868Z</atom:updated>
            <content:encoded><![CDATA[<h3>Production-Ready Laravel &amp; Postgres: Scaling, Failover, and Connection Resilience</h3><p>PlanetScale Postgres offers a managed, branch-native database layer for your Postgres nodes. This enables branch-based deployments, global replication, and connection multiplexing. However, it introduces failure modes not found in standard Laravel and Postgres environments. This article outlines how to address three key production issues:</p><ol><li><strong>PgBouncer binding incompatibilities: </strong>Boolean values and prepared statements behave differently when routed through a connection pooler.</li><li><strong>Replica lag and read/write routing: </strong>Retrying a failed read on the same replica that encountered an error is usually ineffective.</li><li><strong>PlanetScale-specific lost connection signals:</strong> Laravel’s default detector does not recognize several of these errors.</li></ol><p>The following code has been tested in production on a heavy OLTP workload.</p><h3>Architecture Overview</h3><pre>Laravel app<br>    │<br>    ├── write connection  ──► PgBouncer ──► PlanetScale primary branch<br>    │<br>    └── read connection   ──► PgBouncer ──► PlanetScale replica</pre><p>PlanetScale places PgBouncer in front of each branch. Your Laravel application connects to PgBouncer endpoints rather than directly to Postgres. By default, PgBouncer uses <strong>transaction pooling mode</strong> on PlanetScale, which means:</p><ul><li>Server-side prepared statements are <strong>not</strong> supported because each transaction receives a different backend connection.</li><li>Emulated prepared statements (PDO-level) must be used instead.</li><li>Type binding semantics change because PHP’s true and false values cannot be reliably passed as PDO::PARAM_BOOL. The pooler may route the bind call and the execute call to different backend connections.</li></ul><h3>Part 1: Fixing Boolean and Type Bindings for PgBouncer</h3><p>Laravel’s default PostgresConnection uses PDO::PARAM_BOOL when binding PHP booleans. With PgBouncer transaction pooling, this is inconsistent because the backend receiving the bind may not execute the query.</p><p>To resolve this, convert booleans to strings at the PHP layer before they reach PDO, and bind them as PDO::PARAM_STR.</p><pre>/**<br> * Prepare the bindings for the query.<br> *<br> * Casts booleans to strings (&#39;true&#39;/&#39;false&#39;) for PgBouncer compatibility.<br> */<br>public function prepareBindings(array $bindings)<br>{<br>    $grammar = $this-&gt;getQueryGrammar();<br><br>    foreach ($bindings as $key =&gt; $value) {<br>        if ($value instanceof DateTimeInterface) {<br>            $bindings[$key] = $value-&gt;format($grammar-&gt;getDateFormat());<br>        } elseif (is_bool($value)) {<br>            $bindings[$key] = $value ? &#39;true&#39; : &#39;false&#39;;  // &lt;-- key fix<br>        } elseif (is_array($value)) {<br>            $bindings[$key] = json_encode($value);<br>        } elseif ($value instanceof \BackedEnum) {<br>            $bindings[$key] = $value-&gt;value;<br>        } elseif ($value instanceof \UnitEnum) {<br>            $bindings[$key] = $value-&gt;name;<br>        } elseif (is_object($value) &amp;&amp; method_exists($value, &#39;__toString&#39;)) {<br>            $bindings[$key] = (string) $value;<br>        } elseif (is_object($value)) {<br>            $bindings[$key] = json_encode($value);<br>        }<br>    }<br><br>    return $bindings;<br>}</pre><p>prepareBindings runs before bindValues. By the time values reach bindValues, each boolean is already the string ‘true’ or ‘false’, so the match expression defaults to PDO::PARAM_STR:</p><pre>public function bindValues($statement, $bindings)<br>{<br>    foreach ($bindings as $key =&gt; $value) {<br>        $statement-&gt;bindValue(<br>            is_string($key) ? $key : $key + 1,<br>            $value,<br>            match (true) {<br>                is_int($value)      =&gt; PDO::PARAM_INT,<br>                is_resource($value) =&gt; PDO::PARAM_LOB,<br>                $value === null     =&gt; PDO::PARAM_NULL,<br>                default             =&gt; PDO::PARAM_STR,  // booleans land here as strings<br>            },<br>        );<br>    }<br>}</pre><p>A few other binding edge cases are handled above:</p><ul><li><strong>Arrays</strong> are JSON-encoded, which Postgres accepts for jsonb columns and array casts in Eloquent.</li><li><strong>BackedEnum and UnitEnum</strong> values are unwrapped to their scalar forms to avoid errors related to object-to-string conversion.</li><li><strong>Objects that implement __toString</strong> are cast accordingly. Objects without this method are JSON-encoded as a fallback.</li></ul><h3>Enabling Emulated Prepared Statements</h3><p>In config/database.php, set PDO::ATTR_EMULATE_PREPARES on the PlanetScale connection:</p><pre>&#39;pgsql&#39; =&gt; [<br>    &#39;driver&#39;   =&gt; &#39;pgsql&#39;,<br>    &#39;host&#39;     =&gt; env(&#39;DB_HOST&#39;),<br>    &#39;port&#39;     =&gt; env(&#39;DB_PORT&#39;, &#39;5432&#39;),<br>    &#39;database&#39; =&gt; env(&#39;DB_DATABASE&#39;),<br>    &#39;username&#39; =&gt; env(&#39;DB_USERNAME&#39;),<br>    &#39;password&#39; =&gt; env(&#39;DB_PASSWORD&#39;),<br>    &#39;options&#39;  =&gt; [<br>        PDO::ATTR_EMULATE_PREPARES =&gt; true,<br>    ],<br>],</pre><p>This configuration shifts prepared statement handling to PHP and PDO instead of the Postgres server. This is required for PgBouncer transaction pooling.</p><h3>Part 2: PlanetScale-Specific Lost Connection Detection</h3><p>Laravel includes LostConnectionDetector, which handles a set of known Postgres and MySQL error strings. PlanetScale’s proxy layer introduces additional failure messages that Laravel does not recognize by default:</p><ul><li><strong>no primary available for branch</strong> — PlanetScale primary failover in progress</li><li><strong>no replica available for branch</strong> — Replica restarting or unhealthy</li><li><strong>no running members available for branch</strong> — All nodes for branch are unavailable</li><li><strong>failed to connect to upstream</strong> — PgBouncer can&#39;t reach the Postgres backend</li><li><strong>failed to send startup message</strong> — Startup handshake failure during reconnect</li><li><strong>failed to read startup message</strong> — Same, on the read side</li><li><strong>canceling statement due to conflict with recovery</strong> — Replica recovery conflict (hot standby)</li></ul><p>The last message is especially important for replica workloads. It is a standard Postgres error that occurs when a read query conflicts with WAL replay on a hot standby replica, which is common during high write throughput.</p><p>Extending LostConnectionDetector:</p><pre>namespace App\Database;<br><br>use Illuminate\Database\LostConnectionDetector as BaseLostConnectionDetector;<br>use Illuminate\Support\Str;<br>use Throwable;<br><br>class LostConnectionDetector extends BaseLostConnectionDetector<br>{<br>    public function causedByLostConnection(Throwable $e): bool<br>    {<br>        if (parent::causedByLostConnection($e)) {<br>            return true;<br>        }<br><br>        $message = $e-&gt;getMessage();<br><br>        return Str::contains($message, [<br>            &#39;no primary available for branch&#39;,<br>            &#39;no replica available for branch&#39;,<br>            &#39;no running members available for branch&#39;,<br>            &#39;failed to connect to upstream&#39;,<br>            &#39;failed to send startup message&#39;,<br>            &#39;failed to read startup message&#39;,<br>            &#39;canceling statement due to conflict with recovery&#39;,<br>        ]);<br>    }<br>}</pre><p>Register this in a service provider:</p><pre>use App\Database\LostConnectionDetector;<br>use Illuminate\Database\Connection;<br><br>public function boot(): void<br>{<br>    Connection::resolveDetectorUsing(fn () =&gt; new LostConnectionDetector());<br>}</pre><h3>Part 3: Smarter Retry Logic for Replica Failures</h3><p>Laravel’s default retry behavior on a lost connection is straightforward: reconnect and rerun the query on the same connection type. For a read that fails due to a conflict with recovery, this results in retrying on the same replica, which will likely fail again because the replica is still recovering.</p><p>The correct behavior is to fall back to the primary for that retry:</p><pre>protected function tryAgainIfCausedByLostConnection(<br>    QueryException $e,<br>    $query,<br>    $bindings,<br>    Closure $callback<br>) {<br>    if (! $this-&gt;causedByLostConnection($e-&gt;getPrevious())) {<br>        throw $e;<br>    }<br><br>    $this-&gt;reconnect();<br><br>    // If this was a read query and we&#39;re in a request context,<br>    // retry on the primary instead of the replica.<br>    if (<br>        $this-&gt;latestReadWriteTypeUsed() !== &#39;read&#39;<br>        || ! $this-&gt;shouldForcePrimaryFallbackForLostReadRetry()<br>    ) {<br>        return $this-&gt;runQueryCallback($query, $bindings, $callback);<br>    }<br><br>    $readOnWriteConnection = $this-&gt;readOnWriteConnection;<br>    $this-&gt;useWriteConnectionWhenReading(true);<br><br>    try {<br>        return $this-&gt;runQueryCallback($query, $bindings, $callback);<br>    } finally {<br>        $this-&gt;useWriteConnectionWhenReading($readOnWriteConnection);<br>    }<br>}</pre><p>The finally block is essential because it restores the original readOnWriteConnection flag, regardless of whether the retry throws an exception. Without this, a failed retry would route all subsequent reads through the primary for the remainder of the request.</p><h4>Why shouldForcePrimaryFallbackForLostReadRetry()?</h4><pre>protected function shouldForcePrimaryFallbackForLostReadRetry(): bool<br>{<br>    return request() !== null;<br>}</pre><p>This guard is necessary because request() returns null in non-HTTP contexts such as queue workers, console commands, and scheduled jobs. In these cases, the request lifecycle does not manage connection state in the same way, and primary fallback may not be needed. Queue workers often use dedicated connection configurations.</p><p>For HTTP requests, this returns true, enabling the primary fallback on read retries.</p><h3>Part 4: Wiring It Together</h3><p>The custom connection class extends PostgresEnhancedConnection from <a href="https://github.com/tpetry/laravel-postgresql-enhanced">tpetry/laravel-postgresql-enhanced</a>, which adds Postgres-native features such as RETURNING, array operations, and full-text search helpers. All overrides integrate cleanly:</p><pre>namespace App\Database;<br><br>use Closure;<br>use DateTimeInterface;<br>use Illuminate\Database\QueryException;<br>use PDO;<br>use Tpetry\PostgresqlEnhanced\PostgresEnhancedConnection;<br><br>class PostgresPGBouncerExtension extends PostgresEnhancedConnection<br>{<br>    // tryAgainIfCausedByLostConnection — retry on primary for failed reads<br>    // bindValues — PDO::PARAM_STR for booleans<br>    // prepareBindings — stringify booleans, JSON-encode arrays/objects<br>}</pre><p>Register the custom connection driver in AppServiceProvider:</p><pre>use App\Database\PostgresPGBouncerExtension;<br>use Illuminate\Database\Connection;<br><br>public function register(): void<br>{<br>    Connection::resolveConnectionUsing(<br>        &#39;pgsql&#39;,<br>        fn ($connection, $database, $prefix, $config) =&gt;<br>            new PostgresPGBouncerExtension($connection, $database, $prefix, $config)<br>    );<br>}</pre><h3>Part 5: Replica Lag and Sticky Sessions</h3><p>Even with primary fallback on retry, replica lag presents a more subtle issue: a recently committed write may not be immediately visible on the replica. Laravel provides useWriteConnectionWhenReading() to force reads through the primary for the duration of a request, but you must determine when to use it.</p><p>A common approach is to track whether the current request has performed a write operation to the database:</p><pre>// In a middleware or base controller<br>DB::afterSave(function () {<br>    DB::connection()-&gt;useWriteConnectionWhenReading(true);<br>});</pre><p>Alternatively, you can explicitly wrap write-then-read operations:</p><pre>DB::transaction(function () use ($order) {<br>    $order-&gt;save();<br>    // Any reads within this closure will use the write connection automatically,<br>    // since Laravel routes all queries inside a transaction to the primary.<br>});<br><br>// Back on the replica after the transaction closes.<br>$freshOrder = Order::find($order-&gt;id);</pre><p>Inside a transaction, Laravel routes all queries to the primary. The lag issue arises <em>after</em> the transaction closes, when the next read returns to the replica. If you need immediately committed data in the same request, call useWriteConnectionWhenReading(true) after the transaction.</p><h3>Summary</h3><ul><li><strong>PgBouncer breaks </strong><strong>PDO::PARAM_BOOL bindings</strong> — Stringify booleans in prepareBindings, bind as PARAM_STR</li><li><strong>PlanetScale proxy errors not recognized as lost connections</strong> — Extend LostConnectionDetector with PlanetScale-specific strings</li><li><strong>Read retry after replica recovery conflict fails again</strong> — Override tryAgainIfCausedByLostConnection to use primary on first retry</li><li><strong>Post-write reads hit stale replica</strong> — Use useWriteConnectionWhenReading(true) after writes in latency-sensitive paths</li><li><strong>Queue/console context shouldn’t force primary fallback</strong> — Guard with request() !== null</li></ul><p>The required changes are minimal: two classes and a service provider registration. Without them, PlanetScale Postgres in transaction-pooling mode will produce intermittent failures that are difficult to reproduce locally and are not visible in standard Postgres logs.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=da894cdbc608" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/the-hidden-failure-modes-of-laravel-pgbouncer-planetscale-da894cdbc608">The Hidden Failure Modes of Laravel + PgBouncer + PlanetScale</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[We Built a Fake Shopify GraphQL Server. It Caught a Bug That Would Have Broken Production.]]></title>
            <link>https://layers.engineering/we-built-a-fake-shopify-graphql-server-it-caught-a-bug-that-would-have-broken-production-e6585bc60f49?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/e6585bc60f49</guid>
            <category><![CDATA[laravel]]></category>
            <category><![CDATA[shopify-development]]></category>
            <category><![CDATA[tdd]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Tue, 10 Mar 2026 14:27:48 GMT</pubDate>
            <atom:updated>2026-03-10T14:27:48.210Z</atom:updated>
            <content:encoded><![CDATA[<h3>How a Test Caught a 6,250-Point Shopify API Regression Before It Hit Production</h3><p>At Layers, we regularly use the Shopify Admin GraphQL API to sync products, manage webhooks, poll bulk operations, and introspect metafields. To ensure reliable integration testing, we required a solution more robust than basic HTTP mocking. We built a fake Shopify GraphQL server that validates queries against the actual schema, calculates API cost, simulates stateful behavior across requests, and runs tests using the same query documents as production.</p><h3>Why HTTP Mocking Wasn’t Enough</h3><p>Our application is built on <a href="https://laravel.com/">Laravel</a>, a PHP framework with an integrated HTTP client and a testing utility, Http::fake(). This utility intercepts outgoing HTTP requests during tests and returns predefined responses, eliminating the need for real network calls. The standard approach is as follows:</p><pre>Http::fake([&#39;*.myshopify.com/*&#39; =&gt; Http::response($fixture)]);</pre><p>While effective for basic use cases, this approach has several significant limitations for Shopify integrations:</p><p><strong>It does not validate queries.</strong> A fixture returns any provided JSON, regardless of the validity of the GraphQL query. Errors such as renamed fields, broken fragments, or invalid types may go undetected until production or, at best, staging.</p><p><strong>It cannot enforce API cost.</strong> Shopify rejects queries exceeding 1,000 points, but a fixture does not account for cost and will return a successful response even for queries that would be throttled or rejected in production.</p><p><strong>It cannot model stateful sequences.</strong> Bulk operations progress through states (CREATED to RUNNING to COMPLETED) across multiple polls. Webhooks created in one request need to be listable in the next. Fixtures do not compose; each Http::fake() call is independent.</p><p><strong>It does not scale across a growing test suite.</strong> As the number of Shopify-related jobs, services, and edge cases increases, maintaining per-test fixture strings becomes a maintenance burden. Changes to query structure require updating every affected fixture.</p><h3>The Query Library: bootstrap/graphql/</h3><p>Our approach is based on organizing and managing query documents, rather than focusing solely on the fake server.</p><p>All Shopify GraphQL queries and mutations are stored in bootstrap/graphql/ as standalone .graphql files. These are the actual documents used by production API calls, not separate copies for tests. Each has a paired response JSON maintained on the test side:</p><pre>bootstrap/graphql/<br>├── GetProduct.graphql<br>├── BulkOperationRunQuery.graphql<br>├── WebhookSubscriptionCreate.graphql<br>├── MetaobjectFieldsFragment.graphql<br>└── ...<br><br>tests/fixtures/graphql/<br>├── GetProductResponse.json<br>├── BulkOperationRunQueryResponse.json<br>├── WebhookSubscriptionCreateResponse.json<br>└── ...</pre><p>.graphql files are part of the application and used for real API calls. .json files are used only in tests and contain realistic response structures, including GIDs, status values, and nested data that match Shopify’s responses. The fake server pairs these files by filename convention during initialization.</p><h3>Schema Validation at Test Boot</h3><p>We align all components with the Shopify Admin API schema, which is automatically downloaded from the Shopify introspection endpoint at the version specified in the environment:</p><pre>SHOPIFY_API_VERSION=2026-01</pre><pre>php artisan shopify:sync-schema</pre><p>(php artisan is Laravel’s CLI tool for running application commands — similar to rake in Rails or manage.py in Django.)</p><p>This pulls the current schema into tests/fixtures/shopify-admin-schema.graphql. When the test suite starts, the fake server loads every .graphql file from bootstrap/graphql/ and validates each one against the schema using DocumentValidator::validate() before any tests run. A schema mismatch, such as a deprecated field, renamed type, or changed enum value, causes the entire suite to fail immediately at boot, with a clear validation error indicating the problematic document.</p><p>As a result, updating SHOPIFY_API_VERSION in CI immediately identifies queries that require updates. This approach makes potential regressions explicit and prevents them from being discovered in production.</p><h3>Architecture: Three Classes</h3><p>With the query library as the data layer, the fake server is implemented using three dedicated classes.</p><h3>ShopifyGraphqlFake: The Interceptor</h3><p>This class intercepts all HTTP requests to *.myshopify.com/admin/api/*/graphql.json using Laravel’s Http::fake() and processes them as a real GraphQL API would. Upon initialization, it loads the schema and builds a <a href="https://github.com/webonyx/graphql-php">webonyx/graphql-php</a> Schema object.</p><p>Each incoming query during a test is parsed into an AST using GraphQL\Language\Parser::parse() and matched against the validated library. The response is shaped through a recursive shapeResponse() process that filters the paired JSON to include only the fields requested by the query, mirroring real GraphQL executor behavior. The result includes a populated extensions.cost block.</p><p>For rare cases where a test requires behavior not covered by the library, override() allows a test to register a custom resolver for a specific operation. Overrides take priority over library matches and are cleared between tests. In practice, they are used for scenarios such as testing pagination cursor logic, simulating deleted resources, or asserting on specific query arguments:</p><pre>$capturedFirst = null;<br><br>$this-&gt;shopify()-&gt;override(&#39;query&#39;, &#39;product&#39;, function ($ctx) use (&amp;$capturedFirst) {<br>    $capturedFirst = $ctx-&gt;variables[&#39;first&#39;] ?? null;<br>    return [/* minimal valid response */];<br>});<br><br>$job-&gt;handle(app(ShopifySync::class), app(ProductService::class));<br><br>// Assert SyncProductVariants uses smaller page size when metafields are enabled<br>expect($capturedFirst)-&gt;toBe(10);</pre><p>Error simulation is fully supported. Methods such as simulateAccessDenied(), simulateThrottle(), simulateFieldUnavailable(), and simulateEmptyResponse() enable testing of error-handling paths without additional library entries.</p><h3>GraphqlCostCalculator: Shopify’s Cost Model in PHP</h3><p>This class implements Shopify’s documented cost calculation rules:</p><ul><li><strong>Scalar / Enum</strong> — 0 points</li><li><strong>Object</strong> — 1 point</li><li><strong>Connection</strong> — multiplied by first/last argument (default: 10 if neither is present but edges/nodes are selected)</li><li><strong>Mutation</strong> — base cost of 10</li><li><strong>Max per query</strong> — 1,000 points</li></ul><p>The calculator traverses the AST recursively using calculateSelectionSetCost(), resolving variables, inline fragments, and named fragments. Connection sizes are multiplied as they nest. For example, a connection with first: 10 containing another with first: 25 results in 250 points for that subtree, not 35.</p><p>Each response includes the calculated cost in extensions.cost, matching the exact response structure of the Shopify API:</p><pre>{<br>  &quot;extensions&quot;: {<br>    &quot;cost&quot;: {<br>      &quot;requestedQueryCost&quot;: 52,<br>      &quot;actualQueryCost&quot;: 52,<br>      &quot;throttleStatus&quot;: {<br>        &quot;maximumAvailable&quot;: 2000,<br>        &quot;currentlyAvailable&quot;: 1948,<br>        &quot;restoreRate&quot;: 100<br>      }<br>    }<br>  }<br>}</pre><h3>ResolverContext: Clean Interface for Resolvers</h3><p>A context object is passed to every resolver, providing access to the operation type, field name, arguments, variables, selection set, fragments, and per-test state. It supports dot-notation reads and writes through $ctx-&gt;setState() and $ctx-&gt;getState().</p><p>This stateful design enables testing of bulk operation polling without requiring sleep calls or actual asynchronous execution:</p><pre>$this-&gt;shopify()-&gt;setBulkOperationStatusSequence(<br>    $bulkOpId,<br>    [&#39;RUNNING&#39;, &#39;RUNNING&#39;, &#39;COMPLETED&#39;]<br>);</pre><p>Each successive poll to node(id: $bulkOpId) advances through the defined sequence. When the status reaches COMPLETED, the resolver automatically populates the download URL. This approach provides a realistic multi-request lifecycle for the job under test and requires only a single setup cal.l.</p><h3>Cost Enforcement and the Leaky Bucket</h3><p>We use two mechanisms to handle rate limiting in tests.</p><p>setEnforceMaxQueryCost(true) causes any query exceeding 1,000 points to return a MAX_COST_EXCEEDED error, mirroring the real Shopify API:</p><pre>$this-&gt;shopify()-&gt;setEnforceMaxQueryCost(true);<br><br>expect(fn () =&gt; $this-&gt;shopifySync-&gt;executeGraphQlQuery(<br>    store: $this-&gt;store,<br>    query: $expensiveQuery,<br>))-&gt;toThrow(QueryException::class, &#39;MAX_COST_EXCEEDED&#39;);</pre><p>Leaky bucket simulation tracks cumulative bucket state throughout a test, deducting points per query and restoring them over time. This enables testing of retry and backoff logic under realistic throttling conditions without real API calls:</p><pre>$this-&gt;shopify()-&gt;setSimulateLeakyBucket(true);<br>$this-&gt;shopify()-&gt;configureLeakyBucket(<br>    maxPoints: 1000,<br>    restoreRate: 50,<br>    currentPoints: 0  // exhausted — next query throttles<br>);<br><br>expect(fn () =&gt; $this-&gt;shopifySync-&gt;executeGraphQlQuery(<br>    store: $this-&gt;store,<br>    query: &#39;query { productsCount { count } }&#39;,<br>))-&gt;toThrow(RateLimitException::class);</pre><h3>The Regression It Caught</h3><p>The production sync metafields job uses MetaobjectFieldsFragment.graphql, one of the documents in bootstrap/graphql/. At some point during development, a references(first: 25) connection was added inside the fragment to resolve nested metaobject references. The query structure became:</p><pre>metafields(first: 10) {<br>  references(first: 25) {<br>    ... on Metaobject {<br>      fields {<br>        references(first: 25) { ... }<br>      }<br>    }<br>  }<br>}</pre><p>The cost calculation for this structure:10 (metafields)</p><pre>10 (metafields)<br>  × 25 (references)<br>    × 25 (fields.references)<br>= 6,250 points</pre><p>The limit is 1,000. Every invocation of this job against a real Shopify store would have been rejected.</p><p>Because MetaobjectFieldsFragment.graphql is a production document loaded and validated at test boot, the cost calculator detected this issue as soon as the fragment changed. The ShopifyGraphqlFake Query Cost Regression Tests describe block now permanently verifies both sides of the fix:</p><pre>describe(&#39;ShopifyGraphqlFake Query Cost Regression Tests&#39;, function () {<br>    /**<br>     * Regression: MetaobjectFieldsFragment with nested references(first: 25)<br>     * exceeded the 1000 point limit in SyncProductMetafields.<br>     *<br>     * Cost: metafields(first: 10) × references(first: 25) × fields.references(first: 25)<br>     *     = 10 × 25 × 25 = 6,250 points (limit is 1,000)<br>     *<br>     * Fixed in commit e5ce83dd by removing nested references from the fragment.<br>     */<br>    test(&#39;old MetaobjectFieldsFragment with nested references exceeds 1000 points&#39;, function () {<br>        $this-&gt;shopify()-&gt;setEnforceMaxQueryCost(true);<br><br>        expect(fn () =&gt; $this-&gt;shopifySync-&gt;executeGraphQlQuery(<br>            store: $this-&gt;store,<br>            query: $oldQueryWithNestedReferences,<br>            variables: [&#39;id&#39; =&gt; &#39;gid://shopify/Product/123&#39;, &#39;first&#39; =&gt; 10, &#39;keys&#39; =&gt; []],<br>        ))-&gt;toThrow(QueryException::class, &#39;MAX_COST_EXCEEDED&#39;);<br>    });<br><br>    test(&#39;fixed MetaobjectFieldsFragment without nested references stays under 1000 points&#39;, function () {<br>        $cost = $this-&gt;shopify()-&gt;calculateQueryCost($fixedQuery, [<br>            &#39;id&#39; =&gt; &#39;gid://shopify/Product/123&#39;,<br>            &#39;first&#39; =&gt; 10,<br>            &#39;keys&#39; =&gt; [],<br>        ]);<br><br>        expect($cost[&#39;exceedsLimit&#39;])-&gt;toBeFalse();<br>        expect($cost[&#39;requestedCost&#39;])-&gt;toBeLessThanOrEqual(1000);<br>    });<br>});</pre><p>The first test fails if deeply nested connections are reintroduced into the fragment. The second test confirms that the fix remains effective. Both tests run on every CI push against the current schema version.</p><h3>What the Test Suite Actually Looks Like</h3><p>With the query library in place, most feature tests that interact with Shopify require no fixture setup. The library manages the response structure, allowing tests to focus on the behavior being verified:</p><pre>test(&#39;bulk operation status sequence progresses on each poll&#39;, function () {<br>    $bulkOpId = &#39;gid://shopify/BulkOperation/test123&#39;;<br>    $this-&gt;shopify()-&gt;setBulkOperationStatusSequence(<br>        $bulkOpId,<br>        [&#39;RUNNING&#39;, &#39;RUNNING&#39;, &#39;COMPLETED&#39;]<br>    );<br><br>    // First poll<br>    $response = $this-&gt;shopifySync-&gt;executeGraphQlQuery(<br>        store: $this-&gt;store,<br>        query: &#39;query bulkop($id: ID!) {<br>            node(id: $id) { ... on BulkOperation { status url } }<br>        }&#39;,<br>        variables: [&#39;id&#39; =&gt; $bulkOpId],<br>    );<br>    expect($response[&#39;data&#39;][&#39;node&#39;][&#39;status&#39;])-&gt;toBe(&#39;RUNNING&#39;);<br>    expect($response[&#39;data&#39;][&#39;node&#39;][&#39;url&#39;])-&gt;toBeNull();<br><br>    // Third poll — COMPLETED with download URL populated automatically<br>    // ...<br>});</pre><p>Error handling paths are similarly straightforward:</p><pre>test(&#39;simulateThrottle returns throttle error&#39;, function () {<br>    $this-&gt;shopify()-&gt;simulateThrottle();<br><br>    expect(fn () =&gt; $this-&gt;shopifySync-&gt;executeGraphQlQuery(<br>        store: $this-&gt;store,<br>        query: &#39;query { productsCount { count } }&#39;,<br>    ))-&gt;toThrow(RateLimitException::class);<br>});</pre><p>The fake server is instantiated in TestCase::setUp() , a base class lifecycle hook that runs before each test, and is available throughout the suite via $this-&gt;shopify(). There is no need for per-test HTTP setup, fixture strings in test bodies, or coupling between test logic and response structure.</p><h3>Tradeoffs</h3><p>This approach is not without cost. The paired response JSONs in tests/fixtures/graphql/ require maintenance. When a production query adds a field, the response JSON must also be updated. Boot-time validation makes failures explicit rather than silent, but manual updates are still required.</p><p>The cost model reimplements Shopify’s documented rules. It accurately detects relevant regressions such as nested connections, large first arguments, and mutation base costs, but does not capture every detail of Shopify’s internal cost engine. It is a high-confidence approximation rather than an exact match.</p><p>For our team, these tradeoffs are worthwhile. The alternative is maintaining a growing collection of per-test Http::fake() fixture strings that pass regardless of query validity or cost, does not scale and fails to catch critical issues.</p><p>Should we open-source and maintain these test fixtures?</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*PJMGL_iH4OhhwZBHa0Z9Ww.png" /></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e6585bc60f49" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/we-built-a-fake-shopify-graphql-server-it-caught-a-bug-that-would-have-broken-production-e6585bc60f49">We Built a Fake Shopify GraphQL Server. It Caught a Bug That Would Have Broken Production.</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Multiple PgBouncers to Isolate Application and Worker Connections]]></title>
            <link>https://layers.engineering/using-multiple-pgbouncers-to-isolate-application-and-worker-connections-107e4acd531c?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/107e4acd531c</guid>
            <category><![CDATA[planet-scale]]></category>
            <category><![CDATA[postgresql]]></category>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Fri, 06 Mar 2026 17:38:24 GMT</pubDate>
            <atom:updated>2026-03-06T17:38:24.247Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*XSRAi5J_fNsZWlb7Dl0kfQ.png" /></figure><p>On February 27, we had a rough afternoon. Multiple waves of automated traffic hit our platform while several merchants were already in busy windows. We’d been mitigating it since mid-morning, but at 1:51 PM, the combined bot load and real customer traffic caused a 13-minute service interruption that affected a few tenants; our first broad service interruption in over two years.</p><p>During the postmortem, we traced a single chain of failure. First, a surge in background job traffic triggered a rush of worker activity. Next, the shared connection pool between our web app and workers became fully saturated by the workers. As a result, app requests were blocked, queued, and ultimately timed out for customers. The database itself handled the load, but the shared pool was the point of contention.</p><p>Lesson learned: separate your pools. That’s the single biggest takeaway from this incident. The fix was giving each consumer their own pool. Here’s how we did it.</p><h3>Our setup</h3><p>We run Layers on PlanetScale Postgres on an M-640 metal cluster in AWS us-east-1, with six replicas. It’s a high-throughput setup built for the query volumes that come with serving multiple large Shopify storefronts. That translates to hundreds of thousands of search queries, bulk-collection membership writes, and concurrent materialized-view refreshes.</p><p>Previously, both the app and workers connected through PlanetScale’s local PgBouncer on port 6432 as a single shared pool. At the time, we set the pool size to 300 max client connections, which was more than enough for routine activity and left a healthy buffer for spikes on busy days. In our setup, a single worker process maintains a client connection; each worker can process multiple jobs or requests, allowing us to comfortably scale to 295 workers (reserving a few connections for monitoring). This setup worked under normal conditions but became a bottleneck under intense pressure.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*KjsfsgRAP01VFxI_" /><figcaption>The problematic prior architecture, in which both the web app and queue workers share a single PgBouncer pool pinned to Replica 1, leaves five other replicas unused. Writes route traffic through a separate local PgBouncer to the primary, while all read traffic, regardless of source, is routed through a single node.</figcaption></figure><h3>Why a shared pool breaks under load</h3><p>PlanetScale runs PgBouncer in transaction pooling mode, which keeps a fixed number of server-side connections open to Postgres and multiplexes client connections across them. It’s efficient and scales well for OLTP workloads. But when one consumer saturates the pool, everything else waits.</p><p>Web requests are short and transactional, allowing connections to be released quickly. Queue workers hold connections longer; tasks like materialized view refreshes take seconds, and catalog sync jobs fire large writes in sequence. Workers also spike during high-traffic periods.</p><p>When workers surged on February 27, they filled the pool. The database itself was healthy the entire time. The bottleneck was entirely at the pooling layer.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*chdNYrJE5X8W6BUw" /><figcaption>A sequence diagram of the failure mode: queue workers exhaust the shared PgBouncer pool with catalog sync, facet refresh, and embedding jobs; when bot traffic triggers a surge in web app connections, requests queue and time out, surfacing 500 errors to customers at 1:51 PM, while the underlying database remained healthy throughout.</figcaption></figure><h3>The fix: one bouncer per consumer</h3><p>PlanetScale lets you provision dedicated PgBouncer instances separately from the local default. Each one has its own isolated pool of server-side connections, its own name, and its own credentials. You can create as many as you need. While alternative mitigations like statement queuing or increasing the pool size were options, we chose multiple PgBouncers because they offered true isolation and avoided potential side effects like increased queuing delays or contention within a single shared pool. This approach gave us clearer operational boundaries and more predictable behavior during surges.</p><p>We created two:</p><ul><li>app-bouncer for all web application traffic</li><li>worker-bouncer for all queue worker traffic</li></ul><p>Both target our replica fleet. The connection format is straightforward: append the bouncer name to your database username (postgres.{branchId}|app-bouncer) and connect on port 6432. PlanetScale handles the rest.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*Ls5yDEdRM2h8M_D-" /><figcaption>The resolved setup, showing the web app and queue workers routing through separate dedicated PgBouncer pools (app-bouncer and worker-bouncer) on port 6432, with reads distributed across six replicas and writes going directly to the primary on port 5432.</figcaption></figure><p>With this in place, a worker surge saturating the worker-bouncer has no effect on the app-bouncer. The pools are separate. Workers and app traffic can spike independently without interfering with each other.</p><p>If you want to validate this isolation in your own environment, try running a simple load script or chaos test: simulate a heavy worker spike while tracking latency and error rates on app requests. This lets you verify that worker activity does not impact the web pool under stress, and gives your team confidence in the setup before peak traffic hits.</p><p>Because both bouncers target the full replica fleet, read queries are distributed across all six replica nodes rather than being pinned to a single one. This is a nice secondary benefit at our query volumes.</p><p><strong>Before and after:</strong></p><p>Previously, both the web app and queue workers shared a single local PgBouncer on port 6432 for reads, with writes going through the same pool to the primary. Now, the web app routes read through app-bouncer and workers through worker-bouncer; each is a dedicated replica pool on port 6432. Writes from workers and all migrations continue to use a direct connection on port 5432 to the primary, bypassing PgBouncer entirely, as they always should.</p><h3>The failover behavior</h3><p>Dedicated PgBouncers handle failover events more reliably than the local default. After February 27, this proved especially important to us.</p><p>With the local PgBouncer, a failover drops all client connections. Your application must detect these disconnects and attempt to reconnect, which can increase outage time during an incident.</p><p>With dedicated PgBouncers, failovers are handled using PgBouncer’s PAUSE command. During a failover, incoming transactions are queued inside the dedicated bouncer while the database transitions. Once Postgres comes back online, transactions automatically resume, eliminating dropped client connections and minimizing service disruption. From the application’s view, there’s a brief increase in latency but no connection loss.</p><p>For a cluster with six replicas where maintenance events aren’t rare, that behavior matters.</p><h3>In short</h3><p>If your app and queue workers share a connection pool, you have a coupling that works in normal conditions but becomes a liability under load. That’s a bad time to discover it. Take a moment today to audit your own pool setup before a bottleneck catches you by surprise.</p><p>Dedicated PgBouncers on PlanetScale provide isolated pools for each consumer. This lets them operate independently under stress, preventing one from impacting the other.</p><p>I wish we’d done this before February 27.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=107e4acd531c" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/using-multiple-pgbouncers-to-isolate-application-and-worker-connections-107e4acd531c">Using Multiple PgBouncers to Isolate Application and Worker Connections</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Signal-driven health monitoring for HNSW indices w/ pgvector]]></title>
            <link>https://layers.engineering/signal-driven-health-monitoring-for-hnsw-indices-w-pgvector-ba35d9a6e575?source=rss-28ec4c580b52------2</link>
            <guid isPermaLink="false">https://medium.com/p/ba35d9a6e575</guid>
            <dc:creator><![CDATA[Jake Casto]]></dc:creator>
            <pubDate>Mon, 08 Dec 2025 18:50:32 GMT</pubDate>
            <atom:updated>2025-12-08T18:58:51.407Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*t9ECYkRknLJEt8WZOlL_2Q.png" /></figure><p>Vector search in PostgreSQL has come a long way, and pgvector makes it simple: insert embeddings, create an HNSW index, and run ANN queries. At least for a while.</p><p>As our system scaled across stores, regions, and product volumes, something became clear: <strong>HNSW is not “fire and forget.”</strong> Indexes age. They bloat. They fragment. They silently lose recall. When they degrade, search results degrade too. Sometimes this happens gradually, sometimes catastrophically.</p><p>For months, we assumed our approach was fine: rebuild indexes every night at 1 AM, after product imports, and after ingesting new embeddings.</p><p>It felt <em>safe</em>. It was <strong>absolutely not</strong>.</p><h3>When Scheduled Rebuilds Turned Into a Problem</h3><p>Our first attempt at maintaining HNSW indexes was a classic ops mistake: periodic work instead of condition-based work. Every night, every tenant, every index. No intelligence. Just execution.</p><p>And here’s what happened:</p><ul><li><strong>We rebuilt indexes that had zero new writes</strong> — massive waste of compute + IO</li><li><strong>Large tenants saw daily index decay</strong> — recall dropped, latency increased</li><li><strong>Crash mid-rebuild left an index invalid but still present</strong> — PostgreSQL stopped using it entirely</li><li><strong>Replicas diverged from primaries</strong> — queries returned different ANN results per region</li></ul><p>The worst case was the INVALID scenario. A configuration change required a Postgres restart. This restart happened during a CONCURRENT index creation. In Postgres, when a CONCURRENT index is being built, it is first added to the system catalog in an INVALID state. The system must then complete two full table scans to build and validate the index. If Postgres is restarted before these scans complete, the index remains present but stuck in an invalid or corrupted state. The result: an index that looks present but is unusable, and no one notices until search quality degrades or anomalies are detected.</p><p>That incident is what forced a rethink.</p><p>We didn’t need <strong>scheduled rebuilding. W</strong>e needed <strong>index health monitoring</strong> and to rebuild only when necessary.</p><h3>What Does “Healthy” Mean for an HNSW Index?</h3><p>B-trees give you clear signals: bloat, dead tuples, page churn. HNSW is structure-heavy. It is a layered, navigable small-world graph. Its quality depends on:</p><ul><li>how many vectors have been reinserted or replaced</li><li>how many nodes reference deleted or obsolete embeddings</li><li>how well graph layers remain navigable under memory pressure</li><li>whether the index still fits inside the buffer cache or spills to disk</li></ul><p>There is no single metric like <em>fragmentation percent</em>.</p><p>So we built one.</p><p>We created a <strong>three-metric model that produces a single health score</strong> for every HNSW index:</p><ul><li><strong>Dead tuple ratio</strong> — Signals vector churn + unpruned graph nodes</li><li><strong>Cache hit ratio </strong>— Indicates memory residency vs IO-bound index walks</li><li><strong>Bytes per vector (bloat heuristic) — </strong>Detects graph overgrowth + wasted structure</li></ul><p>If an index inflates, loses cache locality, or accumulates dead references, search degrades. Not instantly but inevitably.</p><p>And critically, <em>PostgreSQL already exposes everything you need to measure this.</em></p><h3>Metric 1: Dead Tuple Ratio</h3><p>Dead tuples happen when embeddings are rewritten, deleted, or replaced. In B-trees, that’s annoying. In HNSW, dead nodes remain in the graph like abandoned intersections. Search hops still cross them. They increase hop count. They widen the beam width. They reduce recall.</p><p>Even without deep internals, PostgreSQL gives a usable surface:</p><pre>dead_tuple_ratio = n_dead_tup / n_live_tup</pre><p>If that ratio climbs toward 10–15%, rebuilding pays measurable returns. Dead tuples are a silent killer. We use the following query in production.</p><pre>SELECT<br>    relname AS table_name,<br>    n_dead_tup,<br>    n_live_tup,<br>    ROUND((n_dead_tup / GREATEST(n_live_tup, 1)::float) * 100, 2) AS dead_tuple_ratio_pct<br>FROM pg_stat_all_tables<br>WHERE relname = &#39;your_embedding_table_name&#39;;</pre><h3>Metric 2: Cache Hit Ratio</h3><p>This one surprised us.</p><p>We always assumed index performance dropped because the index itself degraded.</p><p>But very often, performance dropped because <strong>the index no longer fit in memory</strong>.</p><p>When HNSW pages are evicted from shared_buffers, traversal becomes disk-bound. ANN is fast only when the graph fits in RAM. Once it doesn’t, vacuum and reindex are not just cleanup. They restore performance.</p><pre>SELECT<br>    indexrelid::regclass AS index_name,<br>    idx_blks_hit,<br>    idx_blks_read,<br>    ROUND((idx_blks_hit * 100.0 / GREATEST(idx_blks_hit + idx_blks_read, 1)), 2) AS cache_hit_ratio_pct<br>FROM pg_statio_user_indexes<br>WHERE indexrelid::regclass::text LIKE &#39;%hnsw%&#39;;</pre><p>We treat anything below 90% hit ratio as a yellow flag. Below 75% is red.</p><p>Not because slow scans happen.</p><p>Because <strong>the planner may stop using the index altogether.</strong></p><h3>Metric 3: Bytes per Vector</h3><p>This is our approximation of HNSW bloat.</p><p>We know the expected size of an embedding dimension: a 1024-float vector is about 4096 bytes of storage plus HNSW structural overhead. If observed bytes per vector drift from baseline, the index grows inefficiently.</p><pre>WITH idx AS (<br>    SELECT c.relname AS index_name, pg_relation_size(c.oid) AS index_bytes<br>    FROM pg_class c<br>    JOIN pg_index i ON i.indexrelid = c.oid<br>    WHERE c.relname LIKE &#39;%hnsw%&#39;<br>)<br>SELECT<br>    index_name,<br>    pg_size_pretty(index_bytes) AS index_size,<br>    (<br>        index_bytes / NULLIF(<br>            (SELECT reltuples FROM pg_class WHERE relname = &#39;your_embedding_table_name&#39;),<br>            0<br>        )<br>    ) AS bytes_per_vector<br>FROM idx;</pre><p><strong>Example symptom: </strong>We expect 5.0KB per vector and see 9.2KB per vector being stored. This equals bloat. Rebuild justified.</p><p>It’s not perfect, but it correlates strongly with real-world recall degradation.</p><h3>The Health Score</h3><p>Instead of <strong>binary rebuild vs no rebuild</strong>, we compute:</p><pre>score = (dead_score * 0.4) + (cache_score * 0.4) + (bloat_score * 0.2)</pre><p>Weighting rationale:</p><ul><li><strong>Dead tuples = recall degradation</strong> → heaviest signal</li><li><strong>Cache residency = latency + planner reliability</strong></li><li><strong>Bloat matters, but less across large embeddings</strong></li></ul><p>When a score dips below our threshold, rebuild is triggered <em>only for that index</em>.</p><p>We stopped rebuilding everything. We started rebuilding only what needed it.</p><h3>Like the writing? Follow <a href="https://www.uselayers.com/">product development at Layers</a></h3><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ba35d9a6e575" width="1" height="1" alt=""><hr><p><a href="https://layers.engineering/signal-driven-health-monitoring-for-hnsw-indices-w-pgvector-ba35d9a6e575">Signal-driven health monitoring for HNSW indices w/ pgvector</a> was originally published in <a href="https://layers.engineering">Engineering @ Layers</a> on Medium, where people are continuing the conversation by highlighting and responding to this story.</p>]]></content:encoded>
        </item>
    </channel>
</rss>