<?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: Ahmad Hoseiny</title>
    <description>The latest articles on DEV Community by Ahmad Hoseiny (@ahmad_hoseiny_1dce819a3d5).</description>
    <link>https://dev.to/ahmad_hoseiny_1dce819a3d5</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%2F3416658%2Fda55ddf0-be1a-4753-a3e4-f3c147685b00.jpg</url>
      <title>DEV Community: Ahmad Hoseiny</title>
      <link>https://dev.to/ahmad_hoseiny_1dce819a3d5</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmad_hoseiny_1dce819a3d5"/>
    <language>en</language>
    <item>
      <title>MySQL as a Workflow Engine: Building an LLM Prompt Gate</title>
      <dc:creator>Ahmad Hoseiny</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:41:26 +0000</pubDate>
      <link>https://dev.to/ahmad_hoseiny_1dce819a3d5/mysql-as-a-workflow-engine-building-an-llm-prompt-gate-1f4c</link>
      <guid>https://dev.to/ahmad_hoseiny_1dce819a3d5/mysql-as-a-workflow-engine-building-an-llm-prompt-gate-1f4c</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Many production systems rely on LLM calls as an essential part of their critical paths. They can be used in evaluating support tickets, directing user queries to appropriate services, and a wide spectrum of automation tasks. Prompts, including both instructions and model settings, are central to all such LLM calls. A fairly large-scale system with enough use cases can end up with a large set of prompts.&lt;/p&gt;

&lt;p&gt;We can assume that prompts' behavior, at least to some extent, is similar to that of code or configuration in the sense that a minor change (e.g. a single word added or dropped) can cause drastic changes in the output. However, prompts are still usually saved as text or json files. Saving a change to that file, assuming authorized, means the new prompt is actually live and the next production call is using it. No testing or regression measurements are involved.&lt;/p&gt;

&lt;p&gt;This article walks through a case study in fixing that. The idea is an &lt;strong&gt;evaluation gate&lt;/strong&gt; that forces prompt updates to be checked and tested against pre-configured datasets before actually going live.&lt;/p&gt;

&lt;p&gt;The interesting part isn't the gate itself, but rather &lt;em&gt;how&lt;/em&gt; it's built. It's a multi-stage asynchronous pipeline running entirely in MySQL tables along with a few background workers.&lt;/p&gt;

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

&lt;p&gt;A system involves a large set of prompts, each utilized in a specific use case. These prompts are typically often updated not only by engineers, but also by analysts, operations, and stakeholders beyond. Every edit goes straight to production, implying that a fix for some case can silently break many others, only to be found out about when it's already too late, or at least in a reactive manner.&lt;/p&gt;

&lt;p&gt;To keep things simple, let's assume every prompt's output is a single binary decision (yes or no). Hence, we have to deal with one metric while regression testing, namely accuracy.&lt;/p&gt;

&lt;p&gt;For every prompt, at least one dataset should be maintained. The dataset is basically a list of labelled items, each with the correct expected decision the LLM should produce. Each prompt-dataset pair has its own accuracy bar. For instance, a broad regression dataset can have a 95% threshold, while a small specific dataset of known hard cases needs to stay above 80%.&lt;/p&gt;

&lt;p&gt;The ultimate requirements when someone submits a prompt change are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The change is &lt;strong&gt;staged&lt;/strong&gt;, not applied.&lt;/li&gt;
&lt;li&gt;The staged prompt is run against every dataset attached to the prompt.&lt;/li&gt;
&lt;li&gt;Accuracy is computed for each dataset.&lt;/li&gt;
&lt;li&gt;The prompt's change only goes live if every dataset has its bar cleared. Otherwise, the live prompt stays intact.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Obviously, this has to be handled asynchronously, since a single prompt update submission can mean hundreds of LLM calls or even more, each one taking a couple of seconds or more. Moreover, timeouts and rate limits imposed by LLM providers could be a real issue. Work has to be asynchronous and durable to enable failure recovery. Such problems typically imply distributed message queues or fully-fledged workflow orchestrators. However, a different approach was taken here. A multi-stage pipeline living in MySQL tables and driven by background workers was implemented.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture at a glance
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhwjoux0zwqs14cd2dx6u.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%2Fhwjoux0zwqs14cd2dx6u.png" alt="Architecture: API service, MySQL, four workers, and external dataset source and LLM provider" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The API service stages submitted prompt update and halts immediately. The actual heavy lifting of moving the update through the pipeline is done through four independent background workers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Materialize&lt;/strong&gt;: Fetch and store each dataset's rows for the run, page by page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execute&lt;/strong&gt;: Claim chunks of dataset items, call the LLM, and compute and store the verdict based on the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Finalize&lt;/strong&gt;:  Close runs whose items are all scored (i.e. have a verdict), and compute their metrics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile&lt;/strong&gt;: Once all of the update's runs are done, the update is either applied or rejected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The workers never communicate with each other. MySQL, storing the state, is the only coordination medium. Each worker's job will have a detailed elaboration down the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data model
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqecmqdw693dbl61wr12u.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%2Fqecmqdw693dbl61wr12u.png" alt="Data model" width="800" height="667"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prompt&lt;/code&gt;&lt;/strong&gt; Represents the actually &lt;strong&gt;live&lt;/strong&gt; prompt that production reads. Only the gate writes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dataset&lt;/code&gt;&lt;/strong&gt; Registers a labelled dataset and where its rows live. Datasets are typically stored in some warehouse or as .csv file in a blob storage. Every dataset abides by the same structure which is a single row per item, with an &lt;code&gt;item_id&lt;/code&gt; and an &lt;code&gt;expected_decision&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prompt_dataset&lt;/code&gt;&lt;/strong&gt; Maps a dataset to a prompt, along with the &lt;code&gt;min_accuracy&lt;/code&gt; bar it must clear. Registering a mapping implies turning the gate on for a prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prompt_update&lt;/code&gt;&lt;/strong&gt; Stores a single row per prompt update submission through the API service. It holds the prompt's candidate config (aka the new prompt) and the status of the prompt update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prompt_run&lt;/code&gt;&lt;/strong&gt; Represents one evaluation of a prompt update's candidate config against one dataset. An update has one run per mapped dataset, and each run belongs to exactly one update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;prompt_run_item&lt;/code&gt;&lt;/strong&gt; Denotes a single item of a run. It holds for each item its expected decision, actual decision, and verdict (i.e. whether it's a match, mismatch, or error). &lt;code&gt;(run_id, item_id)&lt;/code&gt; is unique.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;lease_owner&lt;/code&gt; / &lt;code&gt;lease_until&lt;/code&gt; columns enable workers to coordinate and claim rows safely. More on them shortly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The implicit state machines
&lt;/h2&gt;

&lt;p&gt;No queue for each step is needed. Typically, we can add all datasets awaiting materialization to some task queue and upon processing, add the fetched items to a task queue with different execution logic. The pipeline would proceed similarly along with all the coordination required and callbacks that need to be passed downstream.&lt;/p&gt;

&lt;p&gt;In this solution, the architecture's core design is that the &lt;code&gt;status&lt;/code&gt; column forms a state machine. A worker polling rows with some state X is a consumer of queue X. No brokers or callbacks are needed to coordinate between the workers. Moving the state from X to Y is the hand off.&lt;/p&gt;

&lt;p&gt;There are three state machines, one for each level of the hierarchy:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwua3zrl9gutu0r0fo34b.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%2Fwua3zrl9gutu0r0fo34b.png" alt="State machines for updates, runs, and items" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every transition is validated:&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="n"&gt;prompt_update&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'REJECTED'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;update_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transitions become idempotent and safe under race conditions with multiple workers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workers
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The worker patterns
&lt;/h3&gt;

&lt;p&gt;Every worker is an infinite loop that runs one small step, sleeps for some interval, and repeats. Each small step incrementally moves towards processing all the pending work in the MySQL tables. Each step roughly has the same three phases:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7lf299df9bjh8i7wi4em.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%2F7lf299df9bjh8i7wi4em.png" alt="Each worker step: claim, work, checkpoint, repeat" width="800" height="189"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nothing important lives in worker memory. Assuming while a worker's process is executing, the pod crashes, the process is killed, or some failure of any sort happens, the state in the tables remains intact. The next worker picks up from the last checkpoint in the database making for a durable and failure tolerant solution. To scale, multiple worker processes can be instantiated.&lt;/p&gt;

&lt;p&gt;Now, for the hard part. Since the workers are polling a queue, at least abstractly, correct claiming logic needs to be handled to avoid having multiple workers picking up the same rows.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;SELECT … FOR UPDATE SKIP LOCKED&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The naive approach is to &lt;code&gt;SELECT&lt;/code&gt; a pending row, process it, and then &lt;code&gt;UPDATE&lt;/code&gt; it after processing completion. However, two workers can run that &lt;code&gt;SELECT&lt;/code&gt; at the same moment, see the same row, and both process it.&lt;/p&gt;

&lt;p&gt;Adding a &lt;code&gt;FOR UPDATE&lt;/code&gt; fixes the correctness problem. The first worker acquires an exclusive lock on the row, so no one else can take it simultaneously. However, the other workers now &lt;em&gt;wait&lt;/em&gt; for that lock. The "concurrent" workers end up having only one running at a time, practically rendering multiple workers instances useless in terms of performance.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; fixes that. It tells InnoDB: &lt;em&gt;if a row is locked by someone else, act as if it isn't there and keep looking.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fylf016qb2tnzv8jvbgxa.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%2Fylf016qb2tnzv8jvbgxa.png" alt="Two workers using SKIP LOCKED take different rows without waiting" width="800" height="283"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now, every worker gets a different row, as fast as the database can hand them out. With &lt;code&gt;ORDER BY id&lt;/code&gt;, rows are fetched in order, so the table behaves as a FIFO queue.&lt;/p&gt;

&lt;p&gt;This simple yet highly powerful clause is what turns an ordinary table into a concurrent &lt;em&gt;worker-safe&lt;/em&gt; task queue.&lt;/p&gt;

&lt;h3&gt;
  
  
  Leases: lock briefly, own for longer
&lt;/h3&gt;

&lt;p&gt;What we have established so far is that each worker acquires an exclusive lock on some rows, does the processing, writes the updates, and finally releases the lock.&lt;/p&gt;

&lt;p&gt;Now, what happens when the processing step takes too long? In our case study, the LLM call or the dataset items fetching, despite being paginated, could take some time. Holding the locks for that long hammers down the database, exhausts the connection pool, and slows all other workers or table readers down or even block them.&lt;/p&gt;

&lt;p&gt;So, we split &lt;em&gt;claiming&lt;/em&gt; from &lt;em&gt;owning&lt;/em&gt;. The row lock lives only long enough to stamp a &lt;strong&gt;lease&lt;/strong&gt; on the row, and then the transaction commits releasing the lock:&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;START&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&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;id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;   &lt;span class="n"&gt;prompt_run_item&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;verdict&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;'PENDING'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ERROR'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;attempt&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;max_attempts&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;lease_until&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;lease_until&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;-- free, or expired&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;id&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt;  &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;chunk_size&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;prompt_run_item&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;    &lt;span class="n"&gt;lease_owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;lease_until&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;INTERVAL&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;MINUTE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;attempt&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attempt&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;WHERE&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(:&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- the lock is released; the lease now protects the work&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The worker then calls the LLM with no transaction open, and writes the result back &lt;strong&gt;only if it still owns the lease&lt;/strong&gt;:&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="n"&gt;prompt_run_item&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt;    &lt;span class="n"&gt;verdict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;verdict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual_decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;decision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;lease_owner&lt;/span&gt; &lt;span class="o"&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;lease_until&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt;  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt;  &lt;span class="n"&gt;lease_owner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;me&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Similarly, the lease pattern is used in the same exact way with &lt;code&gt;prompt_run&lt;/code&gt; for materialization.&lt;/p&gt;

&lt;p&gt;Notice that a crashed worker or a killed process never releases its lease. Yet, no reaper process is required. The query above explains this since it treats an expired lease as no lease at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  The four stages
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Materialize&lt;/strong&gt;&lt;br&gt;
 - Claims a pending run with a lease.&lt;br&gt;
 - Fetches the next page of the dataset after &lt;code&gt;materialize_cursor&lt;/code&gt;.&lt;br&gt;
 - Inserts the items, advances the cursor, and releases the lease.&lt;br&gt;
 - If a worker dies mid-page, the lease expires and another worker re-reads the same page. This holds for the rest of the stages as well as highlighted in the worker patterns above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Execute&lt;/strong&gt;&lt;br&gt;
 - Claims a chunk of items from a &lt;code&gt;RUNNING&lt;/code&gt; run.&lt;br&gt;
 - Calls the LLM for each item through &lt;em&gt;the same code path production uses&lt;/em&gt;, ensuring that the evaluation closely mimics the production's actual behavior.&lt;br&gt;
 - Records &lt;code&gt;MATCH&lt;/code&gt;, &lt;code&gt;MISMATCH&lt;/code&gt;, or &lt;code&gt;ERROR&lt;/code&gt;.&lt;br&gt;
 - Failed items are retried a few times with a proper backoff before their error becomes final.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Finalize&lt;/strong&gt;&lt;br&gt;
 - A polling mechanism for pending runs to know if all their items are finished. &lt;br&gt;
 - Once a run has no unscored or leased items left, a simple &lt;code&gt;GROUP BY verdict, expected_decision, actual_decision&lt;/code&gt; yields accuracy and a confusion matrix.&lt;br&gt;
 - Stores &lt;code&gt;metrics&lt;/code&gt;, and moves the run to &lt;code&gt;DONE&lt;/code&gt;.&lt;br&gt;
 - A run is moved to &lt;code&gt;ERROR&lt;/code&gt; if at least a single item was consistently failing the retries and couldn't be scored.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reconcile, the gate&lt;/strong&gt;&lt;br&gt;
 - A polling mechanism for pending updates to know if all their runs against their datasets are finished.&lt;br&gt;
 - For each dataset, it compares every run's accuracy to the &lt;code&gt;min_accuracy&lt;/code&gt; of its mapping in &lt;code&gt;prompt_dataset&lt;/code&gt;.&lt;br&gt;
 - If bar is cleared for all datasets, it writes the candidate config into &lt;code&gt;prompt&lt;/code&gt; and moves the update to &lt;code&gt;APPLIED&lt;/code&gt;, &lt;strong&gt;in one transaction&lt;/strong&gt;.&lt;br&gt;
 - Otherwise, update is moved to &lt;code&gt;REJECTED&lt;/code&gt;, and the live config is untouched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API Service layer
&lt;/h2&gt;

&lt;p&gt;From the user's side, none of this machinery is visible. The API service exposes the required operations and abstracts away the execution details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Submit a change&lt;/strong&gt; for a prompt. The prompt update and one run per mapped dataset are created in a single transaction, and the caller immediately gets a &lt;strong&gt;tracking id, not an outcome&lt;/strong&gt;. If the same prompt already has a pending submission, it's cancelled and the latest submission always wins.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the status&lt;/strong&gt; of a submission. For each dataset, the user sees the achieved accuracy against its bar and a sample of the items that failed, so a rejection becomes debuggable rather than a blind dead end.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Register a dataset&lt;/strong&gt;, and &lt;strong&gt;map it to a prompt&lt;/strong&gt; with an accuracy bar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A typical flow: an analyst submits a tweak and gets a tracking id. A few minutes later, the status shows the regression set at 96% (bar 95%) but the hard cases at 78% (bar 80%), so the change is &lt;strong&gt;rejected&lt;/strong&gt;. They look at the failing items, adjust the instruction, and resubmit. This time both bars clear, and the new config goes live automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;We built an evaluation gate for LLM prompt changes. Every edit is staged, scored against labelled datasets, and goes live only when every accuracy bar is cleared. This entire process is automated with no human approval required.&lt;/p&gt;

&lt;p&gt;The broader lesson is about orchestration. Sometimes, you don't need fancy infrastructure to model a multi-stage, failure tolerant, and durable workflow. A basic relational database, along with some of its not so basic features, and resourceful workarounds could suffice. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Further reading:&lt;/strong&gt; For another case study utilizing MySQL's powerful &lt;code&gt;FOR UPDATE SKIP LOCKED&lt;/code&gt;, check out this article:&lt;br&gt;
&lt;a href="https://shopify.engineering/scaling-inventory-reservations" rel="noopener noreferrer"&gt;Shopify scaling inventory reservations&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>systemdesign</category>
      <category>architecture</category>
      <category>llm</category>
      <category>promptengineering</category>
    </item>
    <item>
      <title>Redis and Lua scripts</title>
      <dc:creator>Ahmad Hoseiny</dc:creator>
      <pubDate>Sat, 10 Oct 2026 01:54:50 +0000</pubDate>
      <link>https://dev.to/ahmad_hoseiny_1dce819a3d5/redis-and-lua-scripts-296f</link>
      <guid>https://dev.to/ahmad_hoseiny_1dce819a3d5/redis-and-lua-scripts-296f</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%2Fw9x8nl9bdp526ug9czgu.jpg" 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%2Fw9x8nl9bdp526ug9czgu.jpg" alt="Redis Lua script" width="617" height="741"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Redis is the de facto go-to solution for use cases such as counters, rate limits, and distributed locks in systems operating under high load with strict performance requirements. Since Redis is super popular for being single-threaded, hence atomic operations and transactions, it's fairly easy and not uncommon to make the deceptive assumption that the application logic is free of race conditions.&lt;/p&gt;

&lt;p&gt;What happens when we run a &lt;code&gt;GET&lt;/code&gt; command on some key, inspect its value in the application code, and then run a &lt;code&gt;SET&lt;/code&gt; command on the same key? This is a textbook sample case for a &lt;strong&gt;read-modify-write&lt;/strong&gt; race condition. To illustrate, in the meantime between these two commands, another connection could have edited the value of that key resulting in a dirty final value that may be distinct from the expected serializable one. Let's dig into practical use cases and check out how and whether they can be handled utilizing Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Building Blocks: SET, GET, and INCR
&lt;/h2&gt;

&lt;p&gt;Redis commands are simple and individually safe:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SET counter 10
GET counter
INCR counter
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;INCR&lt;/code&gt; is really nice since it's atomic. Redis reads the existing value, increments it, and writes it back to the same key, all in a single indivisible process (i.e. The scenario in which the key read and write commands can be interleaved by some other connection is impossible here)&lt;/p&gt;

&lt;h2&gt;
  
  
  MULTI / EXEC transactions
&lt;/h2&gt;

&lt;p&gt;To bundle a set of operations into a single atomic transaction, Redis provides &lt;code&gt;MULTI&lt;/code&gt; / &lt;code&gt;EXEC&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MULTI
SET a 1
INCR b
EXEC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a &lt;code&gt;MULTI&lt;/code&gt; command is issued, Redis marks the connection as one entering a transaction. Following commands are not executed but rather queued. When &lt;code&gt;EXEC&lt;/code&gt; is issued, and only then, Redis executes the entire queue of commands back-to-back and sequentially. Again, no interleaving scenario is possible here.&lt;/p&gt;

&lt;p&gt;Seems problem is solved! But, is it really? Could we have lost some privilege in the process?&lt;/p&gt;

&lt;h2&gt;
  
  
  Digression: MySQL transactions
&lt;/h2&gt;

&lt;p&gt;Comparing this with a conventional relational database such as MySQL helps clarify the picture.&lt;/p&gt;

&lt;p&gt;In MySQL, you can open a transaction, run a query, actually have it executed, inspect its results, and then, proceed to the subsequent queries, all within the same atomic transaction:&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;START&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&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;balance&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&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="c1"&gt;-- application logic decides what to do next&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;balance&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&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="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This capability is possible because of the isolation levels (&lt;code&gt;READ COMMITTED&lt;/code&gt;, &lt;code&gt;REPEATABLE READ&lt;/code&gt;, &lt;code&gt;SERIALIZABLE&lt;/code&gt;, etc.) implemented along with rollback mechanisms. A transaction can be atomic, compute intermediary results, and execute concurrently along with other transactions.&lt;/p&gt;

&lt;p&gt;Redis, on the other hand, being single threaded, runs transactions in a single go from beginning to completion independently. It doesn't communicate any intermediate results with the calling server for the obvious performance purposes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use case: Rate Limiter
&lt;/h2&gt;

&lt;p&gt;A naive rate limiter handling distributed web servers could look like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET client_x:counter        -- app reads: 99
-- app checks: 99 &amp;lt; RATE_LIMIT[client_x]
INCR client_x:counter       -- counter becomes 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Multiple concurrent calls can read a value below the rate limit, and all actually pass and increment it by one, rendering the cap practically useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lua scripting
&lt;/h2&gt;

&lt;p&gt;Redis allows you to send a Lua script to be executed directly on the server as a single atomic command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- KEYS[1] = the counter key&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV[1] = the cap&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&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;return&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="k"&gt;return&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;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it as following:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;EVAL &lt;span class="s2"&gt;"&amp;lt;script_path&amp;gt;"&lt;/span&gt; 1 counter 100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, this gives us more powerful capabilities. A Turing-complete high-level script can be run on the Redis server atomically. This gives rise to a larger class of applications. Imagine implementing a standard full leaky bucket rate limiter on Redis's side.&lt;/p&gt;

&lt;p&gt;One nice side effect to consider as well is the saved network round trips. Two read and write commands were replaced with a single network call carrying a script expecting the final required response back.&lt;/p&gt;

&lt;h4&gt;
  
  
  Production tip
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Parametrize everything through &lt;code&gt;KEYS&lt;/code&gt; and &lt;code&gt;ARGV&lt;/code&gt;:&lt;/strong&gt; Avoid injecting strings directly into the script (e.g. using template engines or similar tools). Redis caches scripts on its side by the SHA1 hash of their exact source text. Variables hardcoding forces new cache entries along with script recompiling causing performance hits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Lua scripts running on Redis's server side is a powerful feature that gives rise to a large class of applications. However, it doesn't magically solve race conditions related issues. One has to be mindful of the different paths through which the data flows to be able to apply it in the appropriate scenarios.&lt;/p&gt;

</description>
      <category>redis</category>
      <category>database</category>
      <category>backenddevelopment</category>
      <category>softwareengineering</category>
    </item>
  </channel>
</rss>
