<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wonder-David Efe</title>
    <description>The latest articles on DEV Community by Wonder-David Efe (@wondadav).</description>
    <link>https://dev.to/wondadav</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3858366%2F4fc99971-6504-4a7f-bf5d-d68b7e284ea6.jpg</url>
      <title>DEV Community: Wonder-David Efe</title>
      <link>https://dev.to/wondadav</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wondadav"/>
    <language>en</language>
    <item>
      <title>Streaming 16 GB of data on a budget: server-side cursors and parallel workers</title>
      <dc:creator>Wonder-David Efe</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:30:00 +0000</pubDate>
      <link>https://dev.to/wondadav/streaming-16-gb-of-data-on-a-budget-server-side-cursors-and-parallel-workers-5een</link>
      <guid>https://dev.to/wondadav/streaming-16-gb-of-data-on-a-budget-server-side-cursors-and-parallel-workers-5een</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15zdcz4zf8ftaxuyta4o.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F15zdcz4zf8ftaxuyta4o.png" alt="Server-Side Cursors" width="799" height="418"&gt;&lt;/a&gt;&lt;br&gt;
A data processing pipeline that processes 100,000 rows is not the same as one that processes 10 million. At a small scale, a single-threaded Python script does the job. At tens of millions of rows, the first bottleneck you hit is time: the pipeline takes too long to finish. The obvious fix is to run the work in parallel across multiple workers, and that's where the second bottleneck shows up: memory.&lt;/p&gt;
&lt;h2&gt;
  
  
  Sequential is too slow and naive parallelizing hits memory
&lt;/h2&gt;

&lt;p&gt;A typical pipeline reads from a database, transforms each row in Python, and writes the results back. The first pass is a straight loop against that database: open one connection, &lt;code&gt;SELECT ... FROM records&lt;/code&gt;, iterate, transform each row, write it back. It works. &lt;/p&gt;

&lt;p&gt;However, at tens of millions of rows, the CPU is mostly idle and it takes days. Every iteration waits on the database or the network. Tens of millions of round-trips of "fetch a row, do a bit of Python, write back" saturate nothing except your patience.&lt;/p&gt;

&lt;p&gt;The obvious move is to parallelize. Split the work across N workers, done in roughly 1/N of the time. In Python, the database read can be written in two natural shapes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Shape A: fetch everything in the main process, distribute to workers via Pool.map
&lt;/span&gt;&lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Shape B: split by row_id range, each worker fetches its own slice
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_conn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM records WHERE record_id BETWEEN %s AND %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchall&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both shapes use the default psycopg2 cursor, and that's what makes both fail the same way. The default cursor pulls the entire result set into client memory the moment &lt;code&gt;execute()&lt;/code&gt; returns; &lt;code&gt;fetchall()&lt;/code&gt; just iterates over what's already been loaded. Shape A OOMs in the main process before any worker spins up. Shape B splits the OOM across N workers: each worker tries to hold its share of the data (still in the gigabytes for large tables), and N processes racing to eat the machine either OOM together or swap so hard that "faster than sequential" becomes slower than sequential.&lt;/p&gt;

&lt;p&gt;You've traded a time bottleneck for a memory bottleneck. Neither budget is available.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the results on the server while keeping reads parallel
&lt;/h2&gt;

&lt;p&gt;The problem isn't parallelism, it's &lt;em&gt;how&lt;/em&gt; each worker reads. The fix is for the data to be streamed: the result set stays on the Postgres server, and only &lt;code&gt;itersize&lt;/code&gt; rows are streamed to the client at a time, on demand, as it iterates. In psycopg2, you get this behavior by using a named cursor.&lt;/p&gt;

&lt;p&gt;One knock-on: a named cursor holds an open read transaction on its connection, which means you can't commit writes on the same connection while it's open. This requires each worker to use two connections in practice, one for the streaming read and one for the batched write.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;read_conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_conn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;read_conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;readonly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;read_conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worker_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# named = server-side
&lt;/span&gt;    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;itersize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10_000&lt;/span&gt;
    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT * FROM records WHERE record_id BETWEEN %s AND %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                            &lt;span class="c1"&gt;# only itersize rows in memory
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each worker holds only a few MB of client memory at a time (itersize rows times row width, plus Python overhead), regardless of how big its slice is. The server holds the query state and streams. N workers together hold tens of MB in memory, not the full 16 GB. Parallelism is preserved. The memory problem disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Distributing the chunks: SQL ID ranges
&lt;/h2&gt;

&lt;p&gt;The way you split work across workers matters. Each worker owning a non-overlapping range of the primary key is the simplest coordination-free option. No shared queue, no producer/consumer starvation, no locks.&lt;/p&gt;

&lt;p&gt;Divide the row_id space evenly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;lo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_bounds&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;               &lt;span class="c1"&gt;# SELECT MIN(record_id), MAX(record_id)
&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt;
&lt;span class="n"&gt;jobs&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
     &lt;span class="n"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;workers&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;mp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;workers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The primary key's uniqueness means the ranges never overlap, so workers can process their slices independently without any runtime coordination. The split is done once, at startup, and never revisited. Modulo hashing (&lt;code&gt;WHERE record_id % N = worker_id&lt;/code&gt;) also works, but range splits are usually faster because they let the query use an index range scan instead of a full scan with a modulo filter.&lt;/p&gt;

&lt;p&gt;With streaming reads on the input side and range partitioning across workers, a 16 GB read-transform-write job fits comfortably on a modest machine. The write side has its own scaling story, covered in Part 3 of this series (CTAS-and-swap).&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>sql</category>
      <category>python</category>
    </item>
    <item>
      <title>Architecting lean LLM caching: how to drop a 20M-row table without losing your AI memory</title>
      <dc:creator>Wonder-David Efe</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:28:25 +0000</pubDate>
      <link>https://dev.to/wondadav/architecting-lean-llm-caching-how-to-drop-a-20m-row-table-without-losing-your-ai-memory-3g2n</link>
      <guid>https://dev.to/wondadav/architecting-lean-llm-caching-how-to-drop-a-20m-row-table-without-losing-your-ai-memory-3g2n</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftvf72u10zarqfxmna7jd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftvf72u10zarqfxmna7jd.png" alt="LLM Caching" width="799" height="418"&gt;&lt;/a&gt;Any team running an agentic pipeline on a periodically-reloaded dataset caches the output of inputs that repeat across periods to save cost. How you store that cache matters. Storing it in the same table as the data is safe, but makes you unable to wipe the data from the previous period before loading the new one. Keeping both on disk to use the cache can work, but if you're in a storage-constrained environment, you can't keep both on disk and have to find a way to remove the old one before loading the new one while still keeping the cache. The fix is to move the cache into a small, orthogonal lookup table keyed on a stable identifier, so it survives the wipe AND takes almost no disk.&lt;/p&gt;

&lt;p&gt;This article walks through that pattern: the discovery that made it possible, the two-table shape, and the one operational gotcha you'll hit if you don't watch for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The discovery: your inputs are much smaller than your rows
&lt;/h2&gt;

&lt;p&gt;The move to a lookup table starts with noticing something about your data.&lt;/p&gt;

&lt;p&gt;A pipeline can process 20 million rows every period and only pass 60,000 &lt;em&gt;distinct&lt;/em&gt; inputs to the LLM. The other 19.94 million rows are duplicates of those 60k values, spread across various dates, event types, geographies. Same key, different context.&lt;/p&gt;

&lt;p&gt;Once you see that number, the design writes itself. You don't need to store 20 million enrichment results. You need to store 60,000. The 20 million rows can each &lt;em&gt;reference&lt;/em&gt; one of those 60k answers.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Working table (&lt;code&gt;data&lt;/code&gt;)&lt;/strong&gt;: 20M rows every 2 weeks. ~16 GB. Gets wiped and reloaded when the next period's data arrives.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache table (&lt;code&gt;enrichment_cache&lt;/code&gt;)&lt;/strong&gt;: 60k rows. Two columns, the input key (whatever you feed the LLM) and the enriched output. A few MB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The compression ratio is doing the work here. A 16 GB table you can't afford to keep two copies of becomes a few-MB table you can afford to keep &lt;em&gt;forever&lt;/em&gt;. That's what makes wiping the working table safe: the cache is the durable output of every past LLM call, and it's tiny.&lt;/p&gt;




&lt;h2&gt;
  
  
  The architecture: a four-step sequence
&lt;/h2&gt;

&lt;p&gt;When the next data arrives, the pipeline runs this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Wipe the previous period's data.&lt;/strong&gt; The cache is untouched because it lives in a different table entirely, so nothing valuable is lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Load the new data.&lt;/strong&gt; New rows go into the freshly-empty &lt;code&gt;data&lt;/code&gt; table via whatever ingest mechanism you use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Propagate cache hits with SQL.&lt;/strong&gt; One statement joins &lt;code&gt;data&lt;/code&gt; against &lt;code&gt;enrichment_cache&lt;/code&gt; on the input key, copying every already-known enrichment onto the new rows. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Call the LLM for cache misses only, and write to &lt;em&gt;both&lt;/em&gt; places.&lt;/strong&gt; Query &lt;code&gt;data&lt;/code&gt; for rows still missing enrichment. Those are the genuinely-new inputs. Every LLM response gets written to the row in &lt;code&gt;data&lt;/code&gt; AND appended to &lt;code&gt;enrichment_cache&lt;/code&gt; so the input never needs another LLM call.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The SQL for step 3 is the shortest and most important:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;data&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;    &lt;span class="n"&gt;enrichment&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;enrichment_cache&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_key&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;enrichment&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While this UPDATE syntax works perfectly on smaller datasets, running a bulk in-place update across 20 million rows in production triggers a massive MVCC performance penalty. Postgres creates millions of dead tuples, causing table bloat and heavy disk I/O serialization. To bypass this bottleneck, we eventually migrated this step from an in-place UPDATE to a CTAS (Create Table As Select) and swap architecture during the initial ingestion phase. Part 3 of this series breaks down the mechanics of that zero-bloat swap pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engineering trade-offs and one gotcha
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why keep the cache in Postgres rather than an in-memory store like Redis?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any relational database already gives you durable, transactional, indexed key-value storage for free. Adding a separate in-memory cache is another moving part in the deploy, another failure mode to reason about, and another operational surface (memory limits, eviction, replication). If the cache fits comfortably in a relational table and the lookup is an exact match on a stable key, keep infrastructure count low unless a hard constraint forces you to add pieces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The indexing gotcha.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The cache table is small, so it's tempting to think the join will be trivially fast. It won't be, not at 20M rows on the working side. Without an index on &lt;code&gt;enrichment_cache.input_key&lt;/code&gt;, the join has to hash the entire cache table for each batch. That's fine on 60k rows once, but Postgres's planner may still pick a plan that costs you more than you expect at scale, and the equivalent per-row lookup inside step 4 (&lt;code&gt;SELECT ... WHERE input_key = %s&lt;/code&gt;) will be a sequential scan every time.&lt;/p&gt;

&lt;p&gt;Fix once, forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;enrichment_cache_key_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;enrichment_cache&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A B-tree index on the lookup column turns every cache read into a sub-millisecond index lookup and lets the planner use an index join for the bulk propagate in step 3. This is the one line that separates "cache works in principle" from "cache is fast enough that you notice the pipeline is done."&lt;/p&gt;




&lt;h2&gt;
  
  
  The point in one sentence
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;The cheapest LLM call is the one you don't make. The cheapest byte of disk is the one you don't store.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Move the durable, expensive knowledge (LLM answers) into a small, orthogonal lookup table. Let the big working table remain a disposable window on the current period. The wipe becomes safe, the cache stays warm, and the bill collapses.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>dataengineering</category>
      <category>agents</category>
      <category>llm</category>
    </item>
    <item>
      <title>How do I answer "what did my data look like last month" in Postgres?</title>
      <dc:creator>Wonder-David Efe</dc:creator>
      <pubDate>Thu, 09 Jul 2026 18:06:54 +0000</pubDate>
      <link>https://dev.to/wondadav/how-do-i-answer-what-did-my-data-look-like-last-month-in-postgres-h5h</link>
      <guid>https://dev.to/wondadav/how-do-i-answer-what-did-my-data-look-like-last-month-in-postgres-h5h</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F304kcbgp1ba13moilzu2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F304kcbgp1ba13moilzu2.png" alt="Data versions across timelines" width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
Sooner or later, someone asks: &lt;em&gt;"What did this customer's plan look like when they signed up last February?"&lt;/em&gt; The current row won't tell you that; it's been edited three times since. This is the classic historical/temporal data problem: how do you manage changes to dimensional data over time so you can answer &lt;em&gt;"row as of T"&lt;/em&gt; on demand. This article is about the schema pattern that answers that question in one &lt;code&gt;SELECT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There are several approaches to modeling and solving this problem, and I'll discuss some of those approaches below:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;a) Event-driven/event sourcing.&lt;/strong&gt; This has to do with when the domain naturally consists of discrete meaningful facts, e.g., sensor readings, prescriptions, stock movements, financial transactions. Here the change of records events is the natural state and the database is designed for recording these events. It is a Bad fit when the domain thinks in &lt;em&gt;records&lt;/em&gt; (customers, products, contracts) whose changes don't have natural event semantics.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;b) Versioning in place&lt;/strong&gt; (this is what SCD Type 2 refers to, if you want to look it up — &lt;a href="https://www.thoughtspot.com/data-trends/data-modeling/slowly-changing-dimensions-in-data-warehouse" rel="noopener noreferrer"&gt;SCD explainer&lt;/a&gt;). The database table holds every version of every record with &lt;code&gt;valid_from&lt;/code&gt; / &lt;code&gt;valid_to&lt;/code&gt; denoting the change time; the domain still thinks in records. Getting the records state at a certain point in time requires filtering for versions in the valid from/to window. Great when versions have domain meaning. Bad fit when the app is already built; adding versioning in place means every existing query, view, and join needs a WHERE valid_to IS NULL filter. In greenfield the choice between this and a sibling history table (SCD Type 4) is close, and comes down to whether "one table with a filter convention" or "current and history split" fits the team's mental model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;c) Snapshotting a full copy on some cadence or event.&lt;/strong&gt; Nightly snapshots into a separate table; or freeze a snapshot into a downstream entity when a business event happens (approval, invoice). This is efficient when the ask is anchored to those specific moments and storage is tight. When asking for arbitrary times ("what did this look like Tuesday at 15:37") and for records that change often, you get the values at snapshot time and miss a lot of the changes in between snapshots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;d) Slowly Changing Dimension (SCD) Type 4.&lt;/strong&gt; In SCD Type 4, you solve by creating a history table in addition to your main table. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;em&gt;The SQL below is Postgres; the pattern translates to any RDBMS that supports row-level triggers, just with different syntax for the trigger body and the point-in-time query.&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So you originally have your main table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;record_id&lt;/span&gt;  &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;-- your app's existing columns&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alongside it, you create a history table that mirrors the same shape plus a few bookkeeping columns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;history_id&lt;/span&gt;  &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_from&lt;/span&gt;  &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;valid_to&lt;/span&gt;    &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;operation&lt;/span&gt;   &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="k"&gt;INCLUDING&lt;/span&gt; &lt;span class="k"&gt;DEFAULTS&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LIKE records INCLUDING DEFAULTS&lt;/code&gt; copies every column from &lt;code&gt;records&lt;/code&gt;. The history table auto-inherits the schema. Only the currently-valid version of a record has &lt;code&gt;valid_to = NULL&lt;/code&gt;; older versions have their &lt;code&gt;valid_to&lt;/code&gt; filled in with the moment the next edit closed them out.&lt;/p&gt;

&lt;p&gt;Every INSERT, UPDATE, and DELETE on &lt;code&gt;records&lt;/code&gt; needs to be mirrored into &lt;code&gt;records_history&lt;/code&gt;. A row-level trigger keeps them in sync so the app itself never has to write to two tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;records_history_trg&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'UPDATE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'DELETE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt;
           &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;record_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'DELETE'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
        &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;nextval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'records_history_history_id_seq'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
               &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="s1"&gt;'DELETE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;nextval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'records_history_history_id_seq'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
           &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TG_OP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;records_history_trg&lt;/span&gt;
&lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;records_history_trg&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On every UPDATE or DELETE the previously-current version has its &lt;code&gt;valid_to&lt;/code&gt; stamped to &lt;code&gt;now()&lt;/code&gt;. On INSERT or UPDATE a fresh row is written with &lt;code&gt;valid_from = now()&lt;/code&gt; and &lt;code&gt;valid_to = NULL&lt;/code&gt;; the new current version. On DELETE we also write a tombstone row with &lt;code&gt;valid_from = valid_to = now()&lt;/code&gt; so we can tell later that the record ceased to exist at exactly that moment.&lt;/p&gt;

&lt;p&gt;With this in place, asking "what did the table look like on date T?" is one query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;as_of&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;as_of&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;operation&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s1"&gt;'DELETE'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;record_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_from&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each record we pick the version whose validity window contains &lt;code&gt;:as_of&lt;/code&gt;, and we skip records that were deleted before that moment. The &lt;code&gt;DISTINCT ON (record_id)&lt;/code&gt; with &lt;code&gt;ORDER BY record_id, valid_from DESC&lt;/code&gt; gives you one row per record, the most recent one that was valid at &lt;code&gt;:as_of&lt;/code&gt; which is exactly what a &lt;code&gt;SELECT * FROM records&lt;/code&gt; would have returned if you'd run it at that moment.&lt;/p&gt;

&lt;p&gt;One thing to handle at install time: if rows existed before the trigger was added, they'll have no history entries yet. Backfill once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;records_history&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;valid_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;valid_to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;operation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'BOOTSTRAP'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every existing record now has a &lt;code&gt;BOOTSTRAP&lt;/code&gt; version starting at install. Point-in-time queries before that return empty (which is honest as you didn't have history yet). Anything on or after is queryable.&lt;/p&gt;

&lt;p&gt;Two things to bake into ops before you ship. &lt;strong&gt;Schema drift.&lt;/strong&gt; When you add a column to &lt;code&gt;records&lt;/code&gt;, &lt;code&gt;records_history&lt;/code&gt; doesn't gain it automatically. &lt;code&gt;LIKE ... INCLUDING DEFAULTS&lt;/code&gt; copies the schema at CREATE time, not on future changes. The trigger's &lt;code&gt;NEW.*&lt;/code&gt; copy will fail on the column-count mismatch until you &lt;code&gt;ALTER TABLE records_history ADD COLUMN&lt;/code&gt; to match. Bake that ALTER into whatever automation runs your migrations so nobody has to remember. &lt;/p&gt;

</description>
      <category>ai</category>
      <category>dataengineering</category>
      <category>sql</category>
      <category>temporalqueries</category>
    </item>
    <item>
      <title>Filesystem for AI Agents: What I Learned Building One</title>
      <dc:creator>Wonder-David Efe</dc:creator>
      <pubDate>Thu, 02 Apr 2026 22:08:48 +0000</pubDate>
      <link>https://dev.to/wondadav/filesystem-for-ai-agents-what-i-learned-building-one-pm4</link>
      <guid>https://dev.to/wondadav/filesystem-for-ai-agents-what-i-learned-building-one-pm4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn9ekylzp7fs803hyq2hk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn9ekylzp7fs803hyq2hk.png" alt="AI Systems" width="800" height="471"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Filesystem for AI Agents: What I Learned Building One
&lt;/h2&gt;

&lt;p&gt;Most agentic systems, like Claude Code, that run on laptops and servers, interact with files natively through bash. But building an agentic system that allows users to upload and work with files comes with its own limitations that make you unable to store files on the server the agent runs on, and give the agent the bash tool:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The fact that it's exposed to users anywhere — bad actors can get it to run commands that can crash the server or exploit other stuffs, so you want only file operations&lt;/li&gt;
&lt;li&gt;Even if you allow only file operations, you can't store every user's files on the server due to storage limits, so you'll have to store files in remote storage like S3 or Azure — but mounting them will make native commands like grep slow, as it has to download the full file first&lt;/li&gt;
&lt;li&gt;Even if you had unlimited storage and didn't need mounting, you still need isolation — where the agent cannot access files uploaded by another user, or by the same user in another session&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There are other solutions to these problems, but they each come with their own tradeoffs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VM/sandbox platforms (E2B, Northflank)&lt;/strong&gt; — spin up an isolated environment per conversation, which solves security and isolation. But they have cold start latency, operational overhead, and cost that compound at scale. You're managing servers again, just indirectly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 mounting (mountpoint-s3, JuiceFS, s3fs)&lt;/strong&gt; — mount remote object storage as a local filesystem. Grep and similar commands work, but inefficiently — each scan triggers sequential HTTP range requests that essentially download the whole file in chunks. Too slow for agent workloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;just-bash (Vercel Labs)&lt;/strong&gt; — a TypeScript reimplementation of bash with a pluggable filesystem backend. Closest to what I wanted, but TypeScript only. My pipeline is Python.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localsandbox (CoPlane)&lt;/strong&gt; — Python wrapper around just-bash, which would have solved the language problem. But it bridges Python to just-bash via a Deno runtime, adding a deployment dependency I didn't want in a Celery environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I ran into this problem recently while building a legal AI agentic system where users had to upload files for the agent to work with. The solution I needed had to be database-like storage that doesn't need to be spun up and down like a server, but supports native file operations that can be exposed as tools to the agent, with the agent unable to access anything outside its own scoped workspace.&lt;/p&gt;

&lt;p&gt;Then I found AgentFS — a filesystem built specifically for AI agents, backed by Turso/SQLite. It provides scoped, isolated storage per user and session, with file operations that can be wired directly as agent tools.&lt;/p&gt;

&lt;p&gt;Of the integration options — Python SDK, AgentFS + just-bash, AgentFS + FUSE — I went with the Python SDK. Unlike FUSE, which gives the agent a real mount but leaves the rest of the server exposed, the Python SDK puts you in full control. The agent can only do what you explicitly wire up as a tool. No shell escape, no arbitrary commands, no environment variable leaks. The isolation is in the design, not bolted on afterward.&lt;/p&gt;

&lt;p&gt;The trade-off is that you're responsible for the tool surface. The SDK ships with the basics — read, write, list — but search operations were missing. No grep, no find, no wc. For an agent that needs to navigate files without dumping everything into context, those aren't optional. So I built them and raised a PR to have them integrated directly into the SDK.&lt;/p&gt;

&lt;p&gt;AgentFS relies on Turso DB for hosted production use. Locally, the pattern already works — one SQLite file per user, each opened independently with full read-write access. But on a production server, you can't manage hundreds of separate database files manually. You need a single server process that can route connections to the right user's database.&lt;/p&gt;

&lt;p&gt;Turso Cloud solves part of this — it supports creating thousands of separate databases and even lets you query across them using ATTACH. But attached databases are currently read-only. You can read from multiple user databases in one session, but you can't write to them. For an agentic system where the agent needs to create, modify, and delete files in a user's scoped workspace, read-only access isn't enough.&lt;/p&gt;

&lt;p&gt;Turso has confirmed that full read-write ATTACH support is on their roadmap. On the AgentFS side, the open() call goes through a connect() function that can be pointed at a Turso-managed database instead of a local file — so the SDK integration path is straightforward once Turso ships the write support. Until then, full production multi-user AgentFS is blocked on this upstream feature.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>agents</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
