<?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: Rami</title>
    <description>The latest articles on DEV Community by Rami (@rami8k).</description>
    <link>https://dev.to/rami8k</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%2F4124642%2F5c63cc65-6a7b-424b-90b7-a47eedec6513.png</url>
      <title>DEV Community: Rami</title>
      <link>https://dev.to/rami8k</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rami8k"/>
    <language>en</language>
    <item>
      <title>From 16 Hours to Minutes: The Hard Part of Multithreading Isn't the Threads</title>
      <dc:creator>Rami</dc:creator>
      <pubDate>Tue, 22 Sep 2026 18:19:55 +0000</pubDate>
      <link>https://dev.to/rami8k/from-16-hours-to-minutes-the-hard-part-of-multithreading-isnt-the-threads-3hip</link>
      <guid>https://dev.to/rami8k/from-16-hours-to-minutes-the-hard-part-of-multithreading-isnt-the-threads-3hip</guid>
      <description>&lt;p&gt;Multithreading is easy when every piece of work is independent.&lt;/p&gt;

&lt;p&gt;Throw the work into a queue, start a bunch of workers, and let them fight it out.&lt;/p&gt;

&lt;p&gt;Unfortunately, many real-world systems aren’t that simple.&lt;/p&gt;

&lt;p&gt;We recently looked at modernizing a legacy batch-processing system with a fairly straightforward job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read a CSV.&lt;/li&gt;
&lt;li&gt;Load some state from a database.&lt;/li&gt;
&lt;li&gt;Apply a calculation.&lt;/li&gt;
&lt;li&gt;Update the state.&lt;/li&gt;
&lt;li&gt;Store the result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The catch?&lt;/p&gt;

&lt;p&gt;A file containing a few hundred thousand rows could take &lt;strong&gt;up to 16 hours&lt;/strong&gt; to process.&lt;/p&gt;

&lt;p&gt;Our goal was to scale the architecture toward processing &lt;strong&gt;around a million operations in minutes rather than hours&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;What looked initially like a performance problem quickly became a much more interesting concurrency problem:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you massively parallelize a workload while preserving ordering for related operations?&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The giant &lt;code&gt;for&lt;/code&gt; loop
&lt;/h2&gt;

&lt;p&gt;Conceptually, the legacy system looked something like this:&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;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;csv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple. Predictable. Correct.&lt;/p&gt;

&lt;p&gt;And painfully sequential.&lt;/p&gt;

&lt;p&gt;Every row involved database access, computation, and another database operation before moving to the next row.&lt;/p&gt;

&lt;p&gt;Another complication: the system supported an all-or-nothing processing mode, so a database transaction could remain open for essentially the entire job.&lt;/p&gt;

&lt;p&gt;For a large import, that could mean hours.&lt;/p&gt;

&lt;p&gt;Before redesigning anything, though, we needed to understand where the time was actually going.&lt;/p&gt;




&lt;h2&gt;
  
  
  Measure first
&lt;/h2&gt;

&lt;p&gt;Suppose 300,000 operations take 16 hours.&lt;/p&gt;

&lt;p&gt;Some rough math gives us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16 × 60 × 60 × 1000
--------------------  ≈ 192 ms / operation
      300,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Roughly &lt;strong&gt;200 ms per operation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We then broke an operation into its major components:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB Read → Calculation → DB Write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calculation itself was relatively fast. Database reads were more expensive, and writes were particularly costly.&lt;/p&gt;

&lt;p&gt;That immediately exposed two separate opportunities:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do more calculations concurrently, and stop treating the database as part of every individual operation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first one naturally leads to multi-threading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Just add threads™
&lt;/h2&gt;

&lt;p&gt;Imagine that the total workload represents roughly 16 hours of serial work.&lt;/p&gt;

&lt;p&gt;Ignoring overhead for a moment, spreading that work across 32 cores gives us:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;16 hours / 32 ≈ 30 minutes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add more cores and, at least theoretically, we start getting into the range of minutes rather than hours.&lt;/p&gt;

&lt;p&gt;Great.&lt;/p&gt;

&lt;p&gt;Let’s use 90 workers.&lt;/p&gt;

&lt;p&gt;Problem solved.&lt;/p&gt;

&lt;p&gt;Except now we’ve broken the system.&lt;/p&gt;




&lt;h2&gt;
  
  
  The ordering problem
&lt;/h2&gt;

&lt;p&gt;Our CSV isn’t actually a collection of completely independent operations.&lt;/p&gt;

&lt;p&gt;Consider this simplified input:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Item-A, Action-1
Item-B, Action-1
Item-C, Action-1
Item-A, Action-2
Item-D, Action-1
Item-A, Action-3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Actions belonging to different items can run concurrently.&lt;/p&gt;

&lt;p&gt;But actions belonging to the &lt;strong&gt;same item cannot&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Action-2&lt;/code&gt; for Item-A must operate on the state produced by &lt;code&gt;Action-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;Action-3&lt;/code&gt; must operate on the state produced by &lt;code&gt;Action-2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So this is perfectly valid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Item-A: A1 → A2 → A3
Item-B: B1 → B2
Item-C: C1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while all three item chains execute concurrently.&lt;/p&gt;

&lt;p&gt;But this isn’t:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Thread 1: Item-A / Action-1
Thread 2: Item-B / Action-1
Thread 3: Item-A / Action-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thread 3 could finish before Thread 1.&lt;/p&gt;

&lt;p&gt;Now Action-2 has been calculated against the wrong state.&lt;/p&gt;

&lt;p&gt;We’ve made the application dramatically faster at producing incorrect results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parallel across keys, sequential within a key
&lt;/h2&gt;

&lt;p&gt;That observation became the fundamental rule of the design:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Operations for different keys can execute in parallel. Operations for the same key must execute sequentially.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In our case, the key was an item ID.&lt;/p&gt;

&lt;p&gt;Instead of assigning individual rows directly to arbitrary workers, we introduced a router.&lt;/p&gt;

&lt;p&gt;Conceptually:&lt;/p&gt;

&lt;p&gt;The CSV feeds a router, which distributes operations among workers 0 through N.&lt;/p&gt;

&lt;p&gt;The router reads the input sequentially.&lt;/p&gt;

&lt;p&gt;When it encounters an item, it assigns that item to a worker.&lt;/p&gt;

&lt;p&gt;All future operations for that item go to the &lt;strong&gt;same worker&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Each worker consumes its operations through a FIFO queue.&lt;/p&gt;

&lt;p&gt;So if the input contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Item-A / Action-1
Item-B / Action-1
Item-A / Action-2
Item-C / Action-1
Item-A / Action-3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the queues might become:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker 0:
Item-A / Action-1
Item-A / Action-2
Item-A / Action-3
Worker 1:
Item-B / Action-1
Worker 2:
Item-C / Action-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We’ve preserved ordering for Item-A without sacrificing concurrency between A, B, and C.&lt;/p&gt;

&lt;p&gt;This is essentially &lt;strong&gt;keyed partitioning&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hashing gets us most of the way there
&lt;/h2&gt;

&lt;p&gt;An obvious implementation is:&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;worker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;workerCount&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has a very useful property:&lt;/p&gt;

&lt;p&gt;The same item always produces the same worker.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;hash(Item-A) % N → Worker 3
hash(Item-A) % N → Worker 3
hash(Item-A) % N → Worker 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ordering becomes much easier because one worker owns the processing sequence for that item.&lt;/p&gt;

&lt;p&gt;But now another problem appears.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distribution matters.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Suppose four workers receive:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Worker&lt;/th&gt;
&lt;th&gt;Relative workload (illustrative)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Worker 0&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker 1&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker 2&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Worker 3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The other three workers can finish and go home.&lt;/p&gt;

&lt;p&gt;The entire job is still waiting for Worker 0.&lt;/p&gt;

&lt;p&gt;At that point, our execution time isn’t determined by average throughput.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;slowest partition determines it&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Load balancing is part of the algorithm
&lt;/h2&gt;

&lt;p&gt;For workloads where the keys are known during routing, another approach is to explicitly assign newly encountered keys across workers.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Item-A → Worker 0
Item-B → Worker 1
Item-C → Worker 2
Item-D → Worker 3
Item-E → Worker 0
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Store that assignment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Item-A → 0
Item-B → 1
Item-C → 2
Item-D → 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Item-A appears again, the router looks up its existing assignment and sends it back to Worker 0.&lt;/p&gt;

&lt;p&gt;This preserves our important invariant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;same key → same worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while allowing us to control the distribution more deliberately.&lt;/p&gt;

&lt;p&gt;The resulting architecture is roughly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reader&lt;/td&gt;
&lt;td&gt;Read the CSV in order.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Router&lt;/td&gt;
&lt;td&gt;Send each key to its assigned worker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FIFO queues&lt;/td&gt;
&lt;td&gt;Preserve arrival order for each worker.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workers&lt;/td&gt;
&lt;td&gt;Process their queues concurrently, one operation at a time per worker.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At this point we have ordered parallelism.&lt;/p&gt;

&lt;p&gt;But we have also created another problem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Congratulations, we just DDoS’d our own database
&lt;/h2&gt;

&lt;p&gt;Imagine 90 workers doing this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;READ
CALCULATE
WRITE
READ
CALCULATE
WRITE
READ
CALCULATE
WRITE
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;against the same database.&lt;/p&gt;

&lt;p&gt;A million operations multiplied by database reads and writes, now happening concurrently from dozens of threads.&lt;/p&gt;

&lt;p&gt;The application might scale.&lt;/p&gt;

&lt;p&gt;The database probably won’t appreciate our enthusiasm.&lt;/p&gt;

&lt;p&gt;So the next optimization was more important than adding additional threads:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Remove the database from the hot path.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Move the working state into memory
&lt;/h2&gt;

&lt;p&gt;Instead of loading an item repeatedly, we load its initial state once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DB → State Cache
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first operation works against that state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State0 + Action1 → State1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next operation works against the newly mutated in-memory state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State1 + Action2 → State2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State2 + Action3 → State3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The database is no longer the source of truth for every intermediate operation.&lt;/p&gt;

&lt;p&gt;During the job, the in-memory state is.&lt;/p&gt;

&lt;p&gt;So the worker effectively becomes:&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_or_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&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;action&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ordered_actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a million-row file represents only 300,000 unique items, we’ve potentially transformed something approaching a million item reads into roughly 300,000 initial-state reads.&lt;/p&gt;

&lt;p&gt;More importantly, we’ve eliminated the need to persist every intermediate mutation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-threading changed the transaction model
&lt;/h2&gt;

&lt;p&gt;This produced an interesting architectural side effect.&lt;/p&gt;

&lt;p&gt;Previously, supporting all-or-nothing behavior required keeping a database transaction open while the job ran.&lt;/p&gt;

&lt;p&gt;With the new design, intermediate mutations exist only in memory.&lt;/p&gt;

&lt;p&gt;Nothing has been committed yet.&lt;/p&gt;

&lt;p&gt;That means after every worker drains its queue, we can decide what to do.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strict mode&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Any operation failed?
        ↓
Discard everything.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Non-strict mode&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Some operations failed?
        ↓
Commit successful final states.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The concurrency architecture therefore solved more than a performance problem.&lt;/p&gt;

&lt;p&gt;It gave us a cleaner transaction boundary.&lt;/p&gt;

&lt;p&gt;Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEGIN TRANSACTION
   ...hours of processing...
COMMIT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;we can move toward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PROCESS EVERYTHING IN MEMORY
        ↓
FINAL DECISION
        ↓
SHORT DATABASE TRANSACTION
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s a very different relationship with the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write once
&lt;/h2&gt;

&lt;p&gt;At the end of processing, we don’t care about every intermediate version of an item.&lt;/p&gt;

&lt;p&gt;We care about its &lt;strong&gt;final state&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If an item had ten actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;State0
  ↓ Action1
State1
  ↓ Action2
State2
  ↓
 ...
  ↓ Action10
State10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;only &lt;code&gt;State10&lt;/code&gt; needs to be persisted.&lt;/p&gt;

&lt;p&gt;So instead of hundreds of thousands or millions of individual writes, workers produce final states that can be written using a bulk operation.&lt;/p&gt;

&lt;p&gt;Depending on the database and requirements, that might mean bulk copy into staging tables followed by a set-based &lt;code&gt;MERGE&lt;/code&gt;/&lt;code&gt;UPSERT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our architecture has now evolved considerably:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CSV reader&lt;/td&gt;
&lt;td&gt;Read operations in file order.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Router&lt;/td&gt;
&lt;td&gt;Assign operations by item key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FIFO queues and workers&lt;/td&gt;
&lt;td&gt;Run concurrently across keys, sequentially within each key.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State cache&lt;/td&gt;
&lt;td&gt;Hold the evolving in-memory item states.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk commit&lt;/td&gt;
&lt;td&gt;Persist final states to the database.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ol&gt;
&lt;li&gt;Read once.&lt;/li&gt;
&lt;li&gt;Partition by key.&lt;/li&gt;
&lt;li&gt;Process concurrently.&lt;/li&gt;
&lt;li&gt;Preserve order within each key.&lt;/li&gt;
&lt;li&gt;Mutate in memory.&lt;/li&gt;
&lt;li&gt;Write final state in bulk.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The important lesson isn’t “use more threads”
&lt;/h2&gt;

&lt;p&gt;It would be easy to summarize this optimization as:&lt;/p&gt;

&lt;p&gt;We replaced a single-threaded process with a multi-threaded one.&lt;/p&gt;

&lt;p&gt;But that misses most of the interesting engineering.&lt;/p&gt;

&lt;p&gt;Adding threads was probably the easiest part.&lt;/p&gt;

&lt;p&gt;The actual design work was figuring out &lt;strong&gt;what could safely be parallelized&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We couldn’t simply parallelize rows because rows weren’t independent.&lt;/p&gt;

&lt;p&gt;Instead, we had to discover the workload’s natural unit of serialization.&lt;/p&gt;

&lt;p&gt;In this case:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Constraint&lt;/th&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Global ordering&lt;/td&gt;
&lt;td&gt;❌ Not required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-item ordering&lt;/td&gt;
&lt;td&gt;✅ Required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-item parallelism&lt;/td&gt;
&lt;td&gt;✅ Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Once that distinction became explicit, the architecture became much clearer.&lt;/p&gt;

&lt;p&gt;The problem stopped being:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“How do we process a CSV with 90 threads?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;and became:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;“How do we partition an ordered stream into independent sequential streams?”&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That’s a much better problem to solve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Threads expose the next bottleneck
&lt;/h2&gt;

&lt;p&gt;There’s another lesson here.&lt;/p&gt;

&lt;p&gt;Performance optimization tends to move bottlenecks rather than eliminate them.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Single-threaded processing
        ↓
      BOTTLENECK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add parallelism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;90 workers
   ↓
Database
   ↓
BOTTLENECK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reduce database round trips:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;90 workers
   ↓
In-memory state
   ↓
Bulk database write
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now CPU becomes much more relevant.&lt;/p&gt;

&lt;p&gt;And that’s actually what we wanted.&lt;/p&gt;

&lt;p&gt;A CPU-heavy calculation engine is something we can scale horizontally or vertically much more predictably than thousands of tiny database transactions.&lt;/p&gt;

&lt;p&gt;The goal wasn’t simply to make everything faster.&lt;/p&gt;

&lt;p&gt;It was to &lt;strong&gt;move the workload toward resources that scale well&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Great. Now where do we get 90 cores?
&lt;/h2&gt;

&lt;p&gt;At this point, the application can actually use a lot of CPU.&lt;/p&gt;

&lt;p&gt;So the next question is:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where should this thing run?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The workload is bursty. We might have many large jobs during peak periods and none at all at other times.&lt;/p&gt;

&lt;p&gt;A few obvious AWS options come to mind.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lambda
&lt;/h3&gt;

&lt;p&gt;Lambda is attractive because it is fully on-demand.&lt;/p&gt;

&lt;p&gt;But this workload wants a &lt;strong&gt;large amount of CPU inside one execution&lt;/strong&gt;, and it may run long enough that function-runtime limits become uncomfortable.&lt;/p&gt;

&lt;p&gt;So Lambda isn’t a great fit for this particular shape of workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fargate
&lt;/h3&gt;

&lt;p&gt;Fargate gets us closer.&lt;/p&gt;

&lt;p&gt;We can package the processor as a container, run it on demand, and avoid managing servers.&lt;/p&gt;

&lt;p&gt;The problem is compute size.&lt;/p&gt;

&lt;p&gt;If our application benefits from 64, 96, or more CPUs, a single Fargate task becomes limiting. Splitting the work across multiple containers would also force us to solve distributed state and coordination.&lt;/p&gt;

&lt;p&gt;That adds complexity we don’t currently need.&lt;/p&gt;

&lt;h3&gt;
  
  
  EC2
&lt;/h3&gt;

&lt;p&gt;EC2 gives us exactly what the application wants:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;one large machine with lots of CPUs and memory.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That means all workers can live inside one process and share the same in-memory state.&lt;/p&gt;

&lt;p&gt;No distributed locks.&lt;/p&gt;

&lt;p&gt;No remote cache.&lt;/p&gt;

&lt;p&gt;No coordination between machines.&lt;/p&gt;

&lt;p&gt;From an application perspective, this is the simplest option.&lt;/p&gt;

&lt;h3&gt;
  
  
  But we don’t want a 96-core server sitting around
&lt;/h3&gt;

&lt;p&gt;A large EC2 instance is great while a job is running.&lt;/p&gt;

&lt;p&gt;It’s not great when there are no jobs.&lt;/p&gt;

&lt;p&gt;We could use Auto Scaling Groups, but then we’d have to manage things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;when to scale up,&lt;/li&gt;
&lt;li&gt;when to scale down,&lt;/li&gt;
&lt;li&gt;which instance size a job needs,&lt;/li&gt;
&lt;li&gt;how jobs wait for capacity,&lt;/li&gt;
&lt;li&gt;and how to handle different job sizes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small file might need 16 CPUs, and a medium one might need 32, etc.&lt;/p&gt;

&lt;p&gt;At that point, we’re starting to build our own batch scheduler.&lt;/p&gt;

&lt;p&gt;Which is exactly the problem AWS Batch already solves.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS Batch (The Solution)
&lt;/h3&gt;

&lt;p&gt;AWS Batch lets us define the compute environments we want and submit containerized jobs with different CPU and memory requirements.&lt;/p&gt;

&lt;p&gt;So the flow becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CSV arrives
    ↓
Determine job size
    ↓
Submit Batch job
    ↓
AWS provisions EC2 capacity
    ↓
Run the container
    ↓
Job finishes
    ↓
EC2 capacity goes away
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fits the workload really well.&lt;/p&gt;

&lt;p&gt;The application doesn’t need to know how to launch EC2 instances or manage scaling.&lt;/p&gt;

&lt;p&gt;It just says I need this much CPU and memory; run me.&lt;/p&gt;

&lt;p&gt;And AWS Batch handles the queueing, scheduling, provisioning, execution, and teardown.&lt;/p&gt;

&lt;p&gt;The result is exactly what we wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small job&lt;/strong&gt; → small compute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Large job&lt;/strong&gt; → large compute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No job&lt;/strong&gt; → no compute&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The multi-threaded processor stays simple, while the infrastructure scales around the workload.&lt;/p&gt;

&lt;h3&gt;
  
  
  And what does all that compute cost?
&lt;/h3&gt;

&lt;p&gt;The nice part about this model is that &lt;strong&gt;large compute doesn’t necessarily mean high cost&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;We’re not paying for a 96-core machine all day. We only pay while the job is running.&lt;/p&gt;

&lt;p&gt;For example, if a 96-core instance costs roughly &lt;strong&gt;$6/hour&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$6 / hour
÷ 6
──────────
≈ $1 for a 10-minute job
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So we can throw a lot of compute at the problem, finish quickly, and then give the machine back.&lt;/p&gt;

&lt;p&gt;And when there are no jobs? No EC2 compute; $0 cost.&lt;/p&gt;

&lt;p&gt;That’s one of the more interesting consequences of the architecture: &lt;strong&gt;using a bigger machine can actually make economic sense when you only keep it for minutes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Instead of optimizing for the cheapest server, we’re optimizing for the &lt;strong&gt;cost of completing a job&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern is much bigger than CSV processing
&lt;/h2&gt;

&lt;p&gt;None of this is particularly specific to transformations, entities, or even CSV files.&lt;/p&gt;

&lt;p&gt;The same pattern appears anywhere you have operations that are:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ordered within a key but independent across keys.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Customer → ordered account operations
Device → ordered telemetry processing
Order → ordered state transitions
Document → ordered transformations
Portfolio → ordered financial events
Entity → ordered event replay
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whenever you encounter a giant sequential loop, it’s worth asking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does the entire workload actually need to be sequential?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Often the answer is no.&lt;/p&gt;

&lt;p&gt;Only a much smaller unit requires ordering.&lt;/p&gt;

&lt;p&gt;Find that unit, partition around it, and suddenly a sequential workload can become massively parallel without sacrificing correctness.&lt;/p&gt;

&lt;p&gt;That’s where multi-threading gets interesting.&lt;/p&gt;

&lt;p&gt;Not when you create more threads.&lt;/p&gt;

&lt;p&gt;When you figure out &lt;strong&gt;where you’re allowed to use them&lt;/strong&gt;.&lt;/p&gt;

</description>
      <category>multithreading</category>
      <category>etl</category>
      <category>inmemory</category>
    </item>
  </channel>
</rss>
