<?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: Umang Aggarwal</title>
    <description>The latest articles on DEV Community by Umang Aggarwal (@umangaggarwal2).</description>
    <link>https://dev.to/umangaggarwal2</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%2F4241520%2Fd7d461a0-598f-4d4f-99d2-6168204d4173.jpg</url>
      <title>DEV Community: Umang Aggarwal</title>
      <link>https://dev.to/umangaggarwal2</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/umangaggarwal2"/>
    <language>en</language>
    <item>
      <title>Building an LLM Inference Simulator</title>
      <dc:creator>Umang Aggarwal</dc:creator>
      <pubDate>Tue, 10 Nov 2026 14:02:13 +0000</pubDate>
      <link>https://dev.to/umangaggarwal2/building-an-llm-inference-simulator-147g</link>
      <guid>https://dev.to/umangaggarwal2/building-an-llm-inference-simulator-147g</guid>
      <description>&lt;p&gt;I built a small LLM server that has no model and no GPU, but slows down under load like a real one does.&lt;/p&gt;

&lt;p&gt;Most of what we build around LLMs sits in front of the model: gateways, routers, rate limiters, streaming clients and observability dashboards. Testing those layers against real GPUs is expensive. Testing it against a mock is cheap, but a mock either answers instantly at any load or adds a fixed delay or random jitter. It never queues, never runs out of KV cache and never preempts a request halfway through. So the failure modes we need to engineer never show up.&lt;/p&gt;

&lt;p&gt;I wanted something in between: an OpenAI-compatible server that is deterministic, runs on a laptop or in CI, and whose time to first token (TTFT) and inter-token latency (ITL) respond to load like a real serving engine’s.&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%2Fg87653c9mrkmmnjtrbr4.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%2Fg87653c9mrkmmnjtrbr4.png" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I followed the rule of predicting numbers before running load tests, then measuring the diversion and fixing the fidelity issues. The source code is shared at the end of this article.&lt;/p&gt;

&lt;h4&gt;
  
  
  &lt;strong&gt;Scope: What it is, and what it isn’t&lt;/strong&gt;
&lt;/h4&gt;

&lt;p&gt;It answers one question: &lt;em&gt;how does my system behave when the model slows down?&lt;/em&gt; It does not predict how fast a particular model runs on particular GPUs. Only the real engine, or a simulator built from profiled kernels, can answer that.&lt;/p&gt;

&lt;p&gt;A simulator is only useful if you know where to stop trusting it. Therefore, I would like to clarify the fidelity before we go through the architecture.&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%2Fnz3l0b6af6dx2hf78ixq.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%2Fnz3l0b6af6dx2hf78ixq.png" width="800" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Architecture:&lt;/strong&gt; what is said vs when it is sent
&lt;/h3&gt;

&lt;p&gt;A real LLM server answers two things at once: what the model says, and when each token arrives. The simulator splits them.&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%2Febpnofq0vksm1c5y1rg8.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%2Febpnofq0vksm1c5y1rg8.png" width="720" height="279"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The generator (llm/) decides what is said.&lt;/strong&gt; It counts prompt tokens, matches the last user message against scripted responses in responses.yml, picks a variant deterministically and truncates to max_tokens. It never deals with time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The engine (&lt;/strong&gt;&lt;strong&gt;simulator/) decides when each token is sent.&lt;/strong&gt; It takes the generator's plan (prompt length, output tokens) and the request's arrival time, and yields one TokenEvent per token with its simulated time. It never deals with content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two share only&lt;/strong&gt;  &lt;strong&gt;types.py&lt;/strong&gt; , which imports nothing from the project, so neither layer can reach into the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;runtime.py is the composition root.&lt;/strong&gt; It is the only module that reads the parsed config, and it builds one generator and one engine per model at startup. A broken config stops the server before it accepts traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Engines report metrics through callbacks&lt;/strong&gt; wired in runtime.py, so the simulator never imports the metrics layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The split is what made the project tractable. The content side stayed a simple YAML lookup while the timing side grew from fixed delays to a full scheduler, without either side changing the other.&lt;/p&gt;

&lt;p&gt;The &lt;em&gt;generator&lt;/em&gt; orchestrates resulting matcher and tokenizer:&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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GenerationRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;GenerationPlan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_last_user_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Matching and selection ignore surrounding whitespace, so "hello\n" and&lt;/span&gt;
        &lt;span class="c1"&gt;# "hello" get the same answer; {last_user_message} keeps the raw text.&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;prompt_tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_tokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Checked before matching, so rejected requests cost nothing more.&lt;/span&gt;
        &lt;span class="n"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_completion_limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_tokens&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="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_matcher&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_seed&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed&lt;/span&gt;
        &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seed&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;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;choices&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;
        &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_hash_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;variables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;"last_user_message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_tokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;_render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;GenerationPlan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kt"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;finish_reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"length"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;truncated&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="s2"&gt;"stop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;variant&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;em&gt;matcher&lt;/em&gt; matches the incoming prompt with a deterministic response configuration. The response configuration follows a custom DSL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="c1"&gt;# default seed for choosing variants
&lt;/span&gt;
&lt;span class="na"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# checked top to bottom; the first match wins
&lt;/span&gt;  &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;greeting&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;exact:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"hi"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"hello"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Hello! How can I help you today?"&lt;/span&gt;

  &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;weather&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;regex:&lt;/span&gt; &lt;span class="s2"&gt;"weather in (?P&amp;lt;city&amp;gt;\\w+)"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# one is chosen per request, deterministically
&lt;/span&gt;      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"It's sunny in {city} today."&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"Expect light rain in {city}."&lt;/span&gt;

  &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;starts_with:&lt;/span&gt; &lt;span class="s2"&gt;"/echo"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"You said: {last_user_message}"&lt;/span&gt;

  &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;long-answer&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;contains:&lt;/span&gt; &lt;span class="s2"&gt;"explain"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;synthetic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s"&gt;tokens:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;# filler text of 200 to 400 tokens
&lt;/span&gt;
&lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# required: used when nothing matches
&lt;/span&gt;  &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"I'm a simulated model ({model})."&lt;/span&gt;
    &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"This is a canned response."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;em&gt;tokenizer&lt;/em&gt; is a regex implementation that enables processing the pattern:&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;_PATTERN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="s2"&gt;"""
    '(?:[sdmt]|ll|ve|re) # contractions: 's 'd 'm 't 'll 've 're
    | []?[%(cjk)s] # CJK, kana and Hangul, one character per token
    | []?(?:(?![%(cjk)s])[^\W\d_])+ # words (other letters), with an optional leading space
    | []?\d{1,3} # numbers, in groups of up to three digits
    | []?[^\s\w]+ # runs of punctuation and symbols
    | \s+(?!\S) # whitespace, leaving the last space to start the next token
    | \s+ # whitespace left over, such as a newline before a word
    | [\s\S] # anything else (e.g. underscores), so encoding is lossless
    """&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"cjk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_CJK&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;IGNORECASE&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="no"&gt;VERBOSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_PATTERN&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;finditer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are three &lt;em&gt;engines&lt;/em&gt; behind one interface:&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%2F3ajrnl2trtxg476w7k8f.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%2F3ajrnl2trtxg476w7k8f.png" width="800" height="254"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The engine is chosen per model in models.yml, so one server can serve a fixed-rate model and a batched one side by side. The configuration looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;hardware&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;h100-80gb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;hbm_gib&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
    &lt;span class="na"&gt;memory_bandwidth_gbps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
    &lt;span class="na"&gt;peak_tflops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;989&lt;/span&gt;

&lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;qwen-mock-batched&lt;/span&gt;
    &lt;span class="na"&gt;context_length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32768&lt;/span&gt;
    &lt;span class="na"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;regex&lt;/span&gt;
    &lt;span class="na"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scheduler&lt;/span&gt;
    &lt;span class="na"&gt;hardware&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;h100-80gb&lt;/span&gt;
    &lt;span class="na"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;parameters_billions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;6.74&lt;/span&gt;
      &lt;span class="na"&gt;bytes_per_parameter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="na"&gt;kv_cache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;bytes_per_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;524288&lt;/span&gt;
      &lt;span class="na"&gt;block_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;
      &lt;span class="na"&gt;gpu_memory_utilization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;
      &lt;span class="na"&gt;watermark&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;
    &lt;span class="na"&gt;scheduler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;max_batch_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8192&lt;/span&gt;
      &lt;span class="na"&gt;max_num_sequences&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;
    &lt;span class="na"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;step_overhead_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="na"&gt;efficiency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;compute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
      &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  The engine: one interface, two very different timelines
&lt;/h4&gt;

&lt;p&gt;Every engine does one job: turn a plan into a timed stream of tokens. The interface is small on purpose:&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;class&lt;/span&gt; &lt;span class="nc"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ABC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"""Turns a plan into a timed stream of token events.
    Engines never change what is said, only when."""&lt;/span&gt;

    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ClassVar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# models.yml sections a model must define to use this engine; checked at startup&lt;/span&gt;
    &lt;span class="n"&gt;required_sections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ClassVar&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;]&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="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@abstractmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GenerationPlan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arrival&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;AsyncIterator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;TokenEvent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="s2"&gt;"""Yield the plan's tokens in order; deadlines are computed from arrival."""&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="c1"&gt;# e.g. launch a background step loop&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two types that cross the boundary carry no HTTP and no config:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GenerationPlan&lt;/strong&gt; comes from the generator: prompt_tokens, the output tokens, finish_reason, and which response entry answered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TokenEvent&lt;/strong&gt; goes back to the SSE layer: index, text, sim_time (simulated seconds after arrival when the token is due) and lag (how late it was actually released).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The API layer pulls tokens with async for event in engine.run(plan, arrival) and writes a chunk for each. It doesn't know or care which engine is behind it. When a client disconnects, the API closes the iterator, and the engine's finally: block frees whatever the request held.&lt;/p&gt;

&lt;p&gt;Engines take plain parameters, never config classes. runtime.py reads models.yml, checks required_sections, and builds each engine with numbers. That keeps every engine testable with a fake clock and a few arguments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed-rate: each request owns its timeline&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The fixed-rate engine has no shared state. A request’s whole timeline is known the moment it arrives:&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;timeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;GenerationPlan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="s2"&gt;"""Simulated seconds after arrival at which each token is due."""&lt;/span&gt;
    &lt;span class="n"&gt;ttft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_overhead&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_prefill_rate&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ttft&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_itl&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arrival&lt;/span&gt;&lt;span class="p"&gt;)&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;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sim_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&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;lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep_until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arrival&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sim_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_sleep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nc"&gt;TokenEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sim_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole engine. A thousand concurrent requests means a thousand independent coroutines, each sleeping to its own deadlines. Load changes nothing about the simulated timeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scheduler: one step loop owns the GPU&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The scheduler inverts this. No request knows its timeline in advance, because when its tokens come out depends on who else is in the batch. So run() only registers the request and waits on its own queue, and one background loop, started by start(), decides everything:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arrival&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_Stream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arrival&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_streams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;
    &lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heappush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_arrivals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arrival&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_wake&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# wake the step loop&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;_DONE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sim_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt;
            &lt;span class="n"&gt;lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_clock&lt;/span&gt;&lt;span class="p"&gt;(&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="n"&gt;arrival&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;sim_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nc"&gt;TokenEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sim_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;queue_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# free its KV blocks if the client left&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_steps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# simplified&lt;/span&gt;
    &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"-inf"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# simulated time the last step ended&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# (when idle: wait for a request, then start at its arrival, not now)&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_arrivals&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_arrivals&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;heapq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;heappop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_arrivals&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# only requests that have arrived&lt;/span&gt;
            &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompt_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;plan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# admit, batch, preempt&lt;/span&gt;
        &lt;span class="n"&gt;cost&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# roofline: max(compute, memory) + overhead&lt;/span&gt;
        &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="c1"&gt;# anchored: lateness never shifts the timeline&lt;/span&gt;
        &lt;span class="n"&gt;step_lag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep_until&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_clock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_sleep&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# an overdue loop still yields&lt;/span&gt;

        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_policy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;complete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&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;seq&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;emitted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# one token to every sequence in the batch&lt;/span&gt;
            &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_streams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generated&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="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arrival&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="p"&gt;)&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;seq&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;finished&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_streams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_nowait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_DONE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_on_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_stats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;step_lag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# metrics, via a callback&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output length goes to the policy only so it knows when to stop the sequence, like an end-of-sequence token. Admission never looks at it, because a real server can’t know it.&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%2Fq8bp9p6ezxi6ijyndfse.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%2Fq8bp9p6ezxi6ijyndfse.png" width="800" height="344"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both engines share one helper, sleep_until, and that is where the timing model lives.&lt;/p&gt;

&lt;h4&gt;
  
  
  The timing model: deadlines, not sleeps
&lt;/h4&gt;

&lt;p&gt;The obvious way to fake a token stream is sleep(ttft), then sleep(itl) per token. It has two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Overhead leaks into the metrics.&lt;/strong&gt; Parsing, validation and matching happen before the first sleep, so observed TTFT becomes overhead plus simulated TTFT, and that overhead varies by prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors accumulate.&lt;/strong&gt; Each sleep wakes slightly late. Over thousands of tokens the lateness adds up and throughput drifts below the configured rate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Instead, the middleware stamps arrival as early as possible, and the engine sleeps until absolute deadlines:&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;deadline_i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;arrival&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;ttft&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;itl&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deadline_i&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Overhead is absorbed whenever it is shorter than the simulated time to the first token, and lateness never accumulates, because every token targets its own deadline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lag is the simulator’s error bar.&lt;/strong&gt; When a deadline has already passed, the token goes out at once and the engine records lag = release time − deadline. Lag measures how faithfully the simulator carried out its own timeline, not anything about the simulated GPU. Since only the last token's lag reaches end-to-end time, late tokens catch up rather than stretch the request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time is measured in three places&lt;/strong&gt; , and each layer measures its own:&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%2Fxfm69bnas9ugk7vorpq1.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%2Fxfm69bnas9ugk7vorpq1.png" width="799" height="334"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The gap between server and client is delivery cost; the gap between simulated and server is the simulator running late. Keeping them apart is what lets every result later in this article say which one it is about.&lt;/p&gt;

&lt;h4&gt;
  
  
  The scheduler: continuous batching on one simulated GPU
&lt;/h4&gt;

&lt;p&gt;The fixed-rate engine treats every request as if it had a GPU to itself. Real servers don’t. Requests share a batch, compete for KV-cache memory, wait when it is full and get evicted when it runs out. The scheduler engine models that, following vLLM's rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;KV capacity is computed at startup&lt;/strong&gt; from the hardware profile and the model:&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%2F9df9tz8zwtyhvyqywm3j.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%2F9df9tz8zwtyhvyqywm3j.png" width="799" height="266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With 80 GiB of HBM, 90% utilisation, a 6.74B-parameter fp16 model (13.48 GB of weights) and 512 KiB of KV per token, that is 7,609 blocks of 16 tokens: about 121,700 tokens, or roughly 29 concurrent requests at 4k context.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then, every step:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Admit.&lt;/strong&gt; Waiting requests join in arrival order (preempted ones first) while their whole prompt fits, keeping 1% of blocks free as a watermark, and while fewer than max_num_sequences are running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch.&lt;/strong&gt; Each running sequence contributes one decode token, or its next prefill chunk. New prompts fill the rest of the max_batch_tokens budget. Prefill is always chunked, so a long prompt spreads over several steps while every other stream keeps decoding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grow.&lt;/strong&gt; A sequence needs a new block every 16 tokens. If none is free, the newest admitted sequence is preempted: its blocks are freed and it goes back to the front of the queue to recompute everything it had.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duration.&lt;/strong&gt; The step takes as long as its slowest resource, a roofline: max(compute, memory) + step_overhead, where compute is 2 × parameters × scheduled tokens over peak FLOPS, and memory is the weights plus every sequence's KV over memory bandwidth. Both can be scaled by calibrated efficiencies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit.&lt;/strong&gt; Sleep until the step’s absolute deadline, then release one token to every sequence whose tokens are computed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One rule I held to: &lt;strong&gt;admission never uses a plan’s real output length.&lt;/strong&gt; The simulator knows it, but a real server cannot, so it is used only to stop the sequence, like an end-of-sequence token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How KV pressure reaches the client:&lt;/strong&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%2Fgwlr8itzddbsl7opz258.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%2Fgwlr8itzddbsl7opz258.png" width="800" height="211"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the example above, one short request decodes at about 4.5 ms per token. Twenty-nine requests at 4k context read about 62 GB of KV per step on top of the weights, so ITL rises to about 25 ms for everyone. Decode is memory-bound; compute takes well under a millisecond.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Code layout.&lt;/strong&gt; The scheduler has three tiers, with dependencies pointing down only. sequence.py, kv_cache.py and cost_model.py hold resources and cost; policy.py makes the decisions; driver.py is the only code that touches time and asyncio. Because the core is pure, I can run it in virtual time, with no server and no clock, to compute exactly what a load test should produce. That turned out to be the most useful design decision in the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building a benchmark I could trust
&lt;/h3&gt;

&lt;p&gt;My first throughput estimate, 5,500–6,000 output tokens per second, came from an ad-hoc script. It was partly measuring the script. So before load-testing the scheduler I built a load generator, llmsim-bench, that works against any OpenAI-compatible server and checks itself.&lt;/p&gt;

&lt;p&gt;Three things mattered most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open loop, not just closed loop.&lt;/strong&gt; A closed loop keeps N requests in flight, sending the next only when one finishes, so a slow server is offered less load and queueing stays hidden. An open loop sends requests on a seeded Poisson schedule whether or not earlier ones have finished, like independent users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency from the scheduled arrival, not from the send.&lt;/strong&gt; If the client caps concurrency, an arrival may wait for a free slot. Measuring only from the actual send hides that wait, a trap known as coordinated omission. In one run at about 82% utilisation, TTFT p99 was 12 ms from the send and 65 ms from the scheduled arrival, because 66 requests had queued for a slot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The client checks its own health.&lt;/strong&gt; A monitor wakes every 10 ms and records how late each wake-up is, plus the client’s CPU. Above 5 ms at p99, the report warns that the client, not the server, inflated the latencies. It caught a real problem in my own tests: with the server in a thread of the test process, both shared the GIL and the client’s loop ran 10 ms late at p99.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few smaller choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It uses aiohttp, not the OpenAI SDK, which builds a typed object for every chunk.&lt;/li&gt;
&lt;li&gt;Each synthetic prompt is distinct, and exactly N tokens long, so a real server’s prefix cache cannot flatter TTFT.&lt;/li&gt;
&lt;li&gt;After warm-up it resets the server’s /metrics/summary, and after the run it reads it back. Client, server and simulated values then cover the same requests and can sit in one table.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  First limits: how far one process can go
&lt;/h4&gt;

&lt;p&gt;Before the scheduler existed, I load-tested the fixed-rate engine to find how much traffic one Python process can stream before it stops keeping time. The simulated TTFT was 7.12 ms and TPOT 4.00 ms; the question was how far the observed values drift from those.&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%2F62s8rgq65f4tptg9vul3.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%2F62s8rgq65f4tptg9vul3.png" width="685" height="659"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Up to 200 requests per second, about 13,000 output tokens per second, lag p99 stays under 4 ms. Past that, the TTFT tail grows first (p99 49 ms at 250 req/s), and by 350 req/s the median falls behind too. Late tokens then go out back to back, so TPOT reads below the configured 4 ms while end-to-end time stays within 1–4% of schedule.&lt;/p&gt;

&lt;p&gt;Two fixes got it there:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;uvloop and httptools.&lt;/strong&gt; uvicorn’s defaults are a pure-Python HTTP parser and the standard asyncio loop. Switching to uvicorn[standard] cut mean lag at 200 streams from 122 ms to 18.6 ms, and mean TTFT from 183 ms to 56 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A regex pre-filter.&lt;/strong&gt; With a realistic responses.yml, matching every prompt against every pattern collapsed the server at about 60 req/s. Matching held the GIL for about 88% of the time at 60 req/s and stalled the event loop. The fix parses each regex once at load time for the plain strings every match must contain, then skips the regex when the prompt lacks them, with a substring test. Matching a 512-token prompt fell from 10.7 ms to 0.47 ms, and the server held 300 req/s at +2.6 ms median TTFT.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The takeaway:&lt;/strong&gt; generating a token was never the bottleneck. In-process, the engine releases a token in about 0.7 µs, and tracing plus SSE serialisation bring it to about 9 µs. Delivering it over HTTP costs about 170 µs. A real GPU replica produces a few thousand to low tens of thousands of tokens per second, so one process can stand in for one replica; beyond that, run more processes, as you would run more servers.&lt;/p&gt;

&lt;h4&gt;
  
  
  Validating the scheduler: predict, then measure
&lt;/h4&gt;

&lt;p&gt;Six of seven experiments passed, most within 0.1–3% of a number written down before the run.&lt;/p&gt;

&lt;p&gt;A load test that only describes what happened can’t tell you whether the simulator is right. So each experiment checks one scheduler decision against a prediction, with four rules:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Predict before running.&lt;/strong&gt; Each prediction comes from the cost model and the scheduler’s rules: a formula when one is enough, otherwise the pure scheduler core run step by step in virtual time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One variable at a time.&lt;/strong&gt; Each test model differs from the base model in exactly one field, and a unit test checks that it does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When a prediction fails, stop.&lt;/strong&gt; Work out whether the prediction or the simulator is wrong, and never adjust a prediction to fit a result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An explanation is not proof.&lt;/strong&gt; An explanation that fits the numbers stays marked as inferred until a control experiment removes the suspected cause.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The base model is the 6.74B one from the scheduler section: 13.48 GB of weights, 512 KiB of KV per token, 3,000 GB/s and 989 TFLOPS. Every response has an exact length, so --max-tokens sets output length precisely.&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%2F5tswgug0f2ln48ttus5y.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%2F5tswgug0f2ln48ttus5y.png" width="800" height="517"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What each passing result means for someone testing a gateway:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Batching (E1).&lt;/strong&gt; Sixty-four streams get 38× the throughput of one, for only 1.56× the step time, because decode is memory-bound and the weights are read once per step for the whole batch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context (E2).&lt;/strong&gt; At the same batch size, 16× the prompt length makes every stream’s step 2.8× slower. Long-context users slow everyone down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunked prefill (E3).&lt;/strong&gt; One long prompt arriving among 16 decoding streams stalls them all for one step. Cutting the token budget from 8,192 to 2,048 lowered that stall from 112 ms to 28 ms, and served the same prompts and decode tokens in the same time. This is the trade-off behind vLLM’s max_num_batched_tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV admission (E4).&lt;/strong&gt; With a small cache and 2,000-token prompts, the KV cache, not the batch limit of 64, decides how many run: 9. TTFT becomes almost all queueing, 4.46 s of 4.57 s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preemption (E5).&lt;/strong&gt; Sixty-four requests that outgrow the cache together force about 130 preemptions. The 1% watermark cut them by 9% at no cost in time. The median ITL was 7.9 ms, but a few preempted streams froze for up to 50 s. That is exactly the case a gateway’s timeouts should be tested against.&lt;/li&gt;
&lt;/ul&gt;

&lt;h4&gt;
  
  
  What the predictions missed
&lt;/h4&gt;

&lt;p&gt;The misses taught me more than the passes. Two stood out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Fixed-length benchmarks phase-lock.&lt;/strong&gt; In E1, step times matched, but simulated TTFT grew to about 100 ms at 64 streams with nothing blocked. Every request had the same length and the loop was closed, so streams that started together finished together and sent their next requests together. About every 200 steps, one step prefilled most of the batch’s new prompts at once. The largest observed steps were 74–88% of a fully synchronised prefill at every level.&lt;/p&gt;

&lt;p&gt;A real engine under the same benchmark would phase-lock too. It suggests that fixed-length, closed-loop benchmarks, a very common setup, overstate TTFT compared with real traffic. For now this is inferred, not proven. If phase-locking is the cause, TTFT p50 should drop to about 10–15 ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. My own request handling leaked into simulated time.&lt;/strong&gt; E7 swept open-loop load from 10 to 100 req/s. Batch size and step time matched at every rate, but two results failed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TTFT was 3.5–5 ms higher than predicted at every rate.&lt;/strong&gt; The arrival is stamped before the body is parsed and the generator runs. An idle engine absorbs that time; a busy one can’t add a request to a step that is already running. So the processing time, rounded up to the next step boundary, shows up as queue time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacity was 9% lower: 74 req/s against 81.7.&lt;/strong&gt; New prompts reached the engine about three at a time instead of one per step. One 512-token prompt riding with 64 decodes is free: its 7.9 ms of compute hides under the step’s 10.6 ms of memory time. Three together make the step compute-bound at 21.8 ms. Feeding the virtual run the same arrivals in 20–30 ms bunches reproduced the real run on every measure, capacity 70–77 req/s, which confirms the bunching is enough to explain the gap.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither is a scheduling bug. Both are the cost of the HTTP process in front of the scheduler, which is exactly the part of the system I had not yet optimised.&lt;/p&gt;

&lt;h4&gt;
  
  
  Where the time goes
&lt;/h4&gt;

&lt;p&gt;With the scheduler, lag grows with batch size, not with throughput: token lag p99 is about 1.7 ms plus 0.15 ms per sequence in the batch.&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%2Fmilii48nyfv0s8dnow54.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%2Fmilii48nyfv0s8dnow54.png" width="800" height="302"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At 64 streams that is only 3,100 output tokens per second, a quarter of what the fixed-rate engine sustains within the same 5 ms bar. The cause is the shape of the work. The fixed-rate engine spreads each request’s tokens over its own timeline. A scheduler step releases one token to every sequence at the same instant, as a real engine does, and one event loop then delivers them one after another. Tokens late in that queue show it as lag.&lt;/p&gt;

&lt;p&gt;Simulated values are unaffected. The timeline is anchored, so step times and simulated TTFT and E2E are exact at every level; observed ITL p50 matched the simulated step time (16.72 vs 16.82 ms at 64 streams). Lag only adds jitter to what clients observe.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;To see where each token’s CPU goes, I profiled 64 streams with py-spy:&lt;/strong&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%2Fsi4azcwzix9iw96k9885.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%2Fsi4azcwzix9iw96k9885.png" width="686" height="762"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One lesson about the instrument itself. py-spy’s default mode pauses the process for every sample, and under this load it inflated CPU per token 1.7× and doubled the step loop’s share. I trusted the profile only after it matched two independent measurements: process CPU from /proc (89–91 µs per token) and timers around the step loop inside the server (13.4 µs).&lt;/p&gt;

&lt;p&gt;The breakdown also sets realistic expectations for the fix. Prebuilt SSE templates (about 22 → 3 µs) and lighter per-token metrics (about 15 → 7 µs) bring the total to about 60 µs.&lt;/p&gt;

&lt;p&gt;The socket write, uvloop and the task switch, about 25–30 µs together, remain. So lag p99 should roughly halve, and observed timing should stay within 5 ms up to about 40–50 streams instead of 25.&lt;/p&gt;

&lt;h3&gt;
  
  
  Known limits and what’s next
&lt;/h3&gt;

&lt;p&gt;On the same workload (512-token prompts, 64 output tokens), one process keeps observed timing within 5 ms of the simulated timeline up to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;about 200 req/s&lt;/strong&gt; with the fixed-rate engine, about 13,000 output tokens per second;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;about 25 req/s&lt;/strong&gt; with the scheduler, a batch of about 10 and 1,675 output tokens per second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Beyond that, simulated step times, batches and preemptions stay right, but observed per-token times carry the process’s own overhead, and the bench report flags it. Lag is always measured, so a result never silently drifts outside the envelope.&lt;/p&gt;

&lt;p&gt;Next, I would study:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Running an experiment&lt;/strong&gt; to confirm or rule out phase-locking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prebuilt SSE templates and lighter per-token metrics&lt;/strong&gt; , measured the same way as the breakdown above, with a test that the bytes sent stay identical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;gc.freeze() after startup.&lt;/strong&gt; One full garbage collection took 13.7 ms, the largest lag outlier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Further out:&lt;/strong&gt; prefix caching in the scheduler, tensor parallelism, several processes as several replicas, and a virtual-time mode for capacity planning without real sleeps.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Try it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker/bench.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That builds the images, starts a simulator and runs a rate sweep against it, writing JSON results to results/. Point any OpenAI client at &lt;a href="http://localhost:8080/v1" rel="noopener noreferrer"&gt;http://localhost:8080/v1&lt;/a&gt; and it works unchanged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The takeaway:&lt;/strong&gt; the scheduler’s rules were the easy part to get right; the predictions matched within a few percent. What the predictions missed was the HTTP process in front of it, the same layer real serving stacks spend so much effort on.&lt;/p&gt;

&lt;p&gt;A fun exercise in writing the number down before putting load on the system, and then finding out why it was wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Source Code &amp;amp; Full Results:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/umangaggarwal2/llm-simulator" rel="noopener noreferrer"&gt;GitHub - umangaggarwal2/llm-simulator: A deterministic fake LLM that streams tokens through OpenAI compatible API, provides custom GPU configurations and simulates metrics...&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
      <category>python</category>
    </item>
    <item>
      <title>Recipe of Near Real-Time Retrieval</title>
      <dc:creator>Umang Aggarwal</dc:creator>
      <pubDate>Sun, 13 Sep 2026 07:53:06 +0000</pubDate>
      <link>https://dev.to/umangaggarwal2/recipe-of-near-real-time-retrieval-bpa</link>
      <guid>https://dev.to/umangaggarwal2/recipe-of-near-real-time-retrieval-bpa</guid>
      <description>&lt;p&gt;In this article, we will explore a search &amp;amp; retrieval design for natural language queries, expecting data in real-time consistency with updates. We will build a write-path, segregate the read-path and a streaming pipeline in cloud. We will also look into the scalability and performance of the architecture.&lt;/p&gt;

&lt;p&gt;The complete Git repository for the benchmarking is provided at the end of this article.&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%2Ftxok9kda2amihxcynn9k.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%2Ftxok9kda2amihxcynn9k.png" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To make a retrieval mechanism over data being updated in real-time, we need the ingredients:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;em&gt;Updates&lt;/em&gt; on a database&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Real-time&lt;/em&gt; streaming, indexing and inference&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Natural queries&lt;/em&gt; expecting updated output&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everytime we make an update in our database, we will asynchronously replicate or stream the write to our search engine.&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%2Fcdn-images-1.medium.com%2Fmax%2F753%2F0%2AaKQr3ay1UzM60WR8" 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%2Fcdn-images-1.medium.com%2Fmax%2F753%2F0%2AaKQr3ay1UzM60WR8" width="753" height="85"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Building the streaming engine
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Kafka&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Kafka is a distributed append-only log that uses a publish-subscribe model to let producers write data streams and consumers can read them independently. It separates the data streams into topics, distributed partitions and consumer groups, and retains them on disk based on topic specific retention policies, which enables multiple consumers to read data simultaneously with each having their independent lag. The tolerance of lag makes Kafka the correct message bus for streaming our updates to the retrieval engine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debezium&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Any relational database first writes the updates to a log before committing the write to the storage —  &lt;strong&gt;Write Ahead Log&lt;/strong&gt;. Reading these logs and copying these writes somewhere else is called replication. &lt;em&gt;Debezium&lt;/em&gt; is a set of source connectors designed for the Kafka Connect framework. It reads the logical WAL of the database, which is designed for its own replication and PITR (point-in-time-recovery), and translates it to a Kafka readable message. This process is called &lt;strong&gt;Change Data Capture (CDC)&lt;/strong&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%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A9Xk1np-PN_uDgvHX" 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%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2A9Xk1np-PN_uDgvHX" width="1024" height="70"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Building a search engine
&lt;/h4&gt;

&lt;p&gt;&lt;strong&gt;Keyword Search&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Given a query with keywords “A”, “B” &amp;amp; “C”, we can return the data with the highest frequencies of these keywords, thus making them more relevant. This simple approach is called a &lt;em&gt;bag of words&lt;/em&gt; and the most commonly used function to calculate the score is &lt;strong&gt;Okapi BM25&lt;/strong&gt;. Querying requires an &lt;strong&gt;Inverted File Index&lt;/strong&gt; , in which keywords are mapped with a list of document IDs along with its term frequency. This index is implemented at scale by Lucene — the engine behind ElasticSearch and OpenSearch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Semantic Search&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keyword search works well when the query and the document share the same words. However, consider a query such as&lt;/p&gt;

&lt;p&gt;&lt;em&gt;“How do I reserve a seat on a flight?”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A document containing:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;“Passengers can temporarily hold an available seat before completing payment.”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;is clearly relevant to the query, even though many of the words are different.&lt;/p&gt;

&lt;p&gt;Keyword search has no inherent understanding of this relationship. We therefore need a representation of text that captures its &lt;em&gt;meaning&lt;/em&gt;, rather than just the words it contains.&lt;/p&gt;

&lt;p&gt;This is where embeddings come in.&lt;/p&gt;

&lt;p&gt;An embedding model converts a piece of text into a numerical vector reprsesentation:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;“How do I reserve a seat on a flight?”&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;↓ embedding model&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;[0.12, -0.34, 0.87, …, 0.21]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The individual numbers are not particularly meaningful to us. What matters is that text with similar meaning tends to produce vectors that are close together in the embedding space.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;“How do I reserve a seat?”&lt;br&gt;&lt;br&gt;
 ↓&lt;br&gt;&lt;br&gt;
 [0.12, -0.34, …]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;“Can I temporarily hold a flight seat?”&lt;br&gt;&lt;br&gt;
 ↓&lt;br&gt;&lt;br&gt;
 [0.10, -0.31, …]&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;These vectors can then be compared using a similarity metric such as &lt;strong&gt;cosine similarity&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This gives us a fundamentally different search mechanism:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keyword search:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;query → terms → inverted index → matching documents&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Semantic search:&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;query → embedding → vector similarity → similar documents&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vector Databases&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To power the query for vectors, we need specialised databases that operate on &lt;strong&gt;Aproximate Nearest Neighbours (ANN)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;However, at a scale of millions of vectors, the neighbour calculations may require millions of similarity calculations for every query. Just like keyword search required Inverted File Index, vector databases use specialized indexing structures. A popular approach is &lt;strong&gt;Hierarchical Navigable Small World (HNSW)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;HNSW organises vectors into multiple layers of a graph. Higher layers provide long-range connections that allow the search to quickly move towards the relevant region of the vector space, while lower layers provide increasingly fine-grained connections. This allows us to find approximate nearest neighbours without comparing the query against every vector.&lt;/p&gt;

&lt;p&gt;For our use case, we can use Qdrant as the vector database. It stores the generated embeddings and provides efficient approximate nearest-neighbour search using HNSW index.&lt;/p&gt;

&lt;p&gt;Embedding generation then becomes a separate concern, we can use an embedding model hosted by a cloud provider or run a local inference service.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Combining Keyword &amp;amp; Semantic Search&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In practice, we need to look for documents that have both &lt;em&gt;similar meaning as well as exact words&lt;/em&gt;. Therefore, we will perform both keyword and vector search in parallel and combine the results by using &lt;strong&gt;Reciprocal Rank Fusion (RRF)&lt;/strong&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%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AXnAQ3B8NyMP6R96_" 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%2Fcdn-images-1.medium.com%2Fmax%2F1024%2F0%2AXnAQ3B8NyMP6R96_" width="1024" height="147"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;RRF is a simple algorithm that combines multiple ranked lists into a single unified list. It does not require the scores produced by the different retrieval systems to compare the documents. Instead, it uses the rank of a document in each result list to calculate a combined score.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RRF(d) = Σ 1 / (k + rank(d))&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Hence, a document that appears on top of multiple lists is more likely to rank higher in the final set. We can have the detailed implementation later.&lt;/p&gt;

&lt;p&gt;For now, our &lt;em&gt;final ETL architecture&lt;/em&gt; loads data both into two separate data stores.&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%2Fcdn-images-1.medium.com%2Fmax%2F923%2F0%2AonMMsi1z4sdcKhlL" 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%2Fcdn-images-1.medium.com%2Fmax%2F923%2F0%2AonMMsi1z4sdcKhlL" width="923" height="174"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Infrastructure
&lt;/h3&gt;

&lt;p&gt;We will split the architecture between managed cloud services, preferrably over multiple regions for network latencies, and lightweight local infrastructure. This will keep the setup simple while allowing us to demonstrate a realistic CDC-driven search pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL (AWS RDS)&lt;/strong&gt; acts as the source of truth for a users dataset.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kafka + Debezium&lt;/strong&gt; run locally in Docker containers. Debezium reads PostgreSQL’s logical WAL and publishes database changes to Kafka.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amazon OpenSearch&lt;/strong&gt; is used for traditional keyword search and BM25 indexing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Qdrant Cloud&lt;/strong&gt; stores the document embeddings and provides vector similarity search.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kafka consumers&lt;/strong&gt; independently process the CDC stream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3 consumer processes will consume 3 kafka paritions&lt;/li&gt;
&lt;li&gt;we will keep parallel indexing methods for Opensearch and Qdrant indexing.&lt;/li&gt;
&lt;li&gt;we could keep separate consumers for each indexing workflow, however, it will be easier to collect benchmark reports from a single script.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  Setting up PostgreSQL for CDC
&lt;/h4&gt;

&lt;p&gt;We will use Amazon RDS for PostgreSQL and configure it to expose logical changes through the PostgreSQL WAL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Create a PostgreSQL RDS instance&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Provision a small PostgreSQL instance on AWS.&lt;/li&gt;
&lt;li&gt;Configure network access so the local CDC infrastructure can connect to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Create the application database and table&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The users table contains the fields that will eventually be indexed by both OpenSearch and Qdrant.&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;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;BIGINT&lt;/span&gt; &lt;span class="no"&gt;GENERATED&lt;/span&gt; &lt;span class="no"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="no"&gt;IDENTITY&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;name&lt;/span&gt; &lt;span class="kt"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="kt"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;company&lt;/span&gt; &lt;span class="kt"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;job_title&lt;/span&gt; &lt;span class="kt"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;location&lt;/span&gt; &lt;span class="kt"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;skills&lt;/span&gt; &lt;span class="kt"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bio&lt;/span&gt; &lt;span class="kt"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;experience&lt;/span&gt; &lt;span class="kt"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="kt"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="kc"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="kt"&gt;TIMESTAMP&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="kc"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="kc"&gt;CURRENT_TIMESTAMP&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Enable logical replication&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL needs to retain the logical changes in its WAL so that Debezium can consume them.&lt;/p&gt;

&lt;p&gt;On RDS, this involved enabling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="na"&gt;rds.logical_replication&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After applying the parameter-group change and restarting the instance, PostgreSQL should report:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="na"&gt;wal_level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logical&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Create a dedicated replication user&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We need to create a replication only user for our CDC connector:&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="no"&gt;USER&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="no"&gt;PASSWORD&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="n"&gt;rds_replication&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="no"&gt;CONNECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;appdb&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="no"&gt;USAGE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;SCHEMA&lt;/span&gt; &lt;span class="n"&gt;public&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;GRANT&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Create a publication for the table&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;PostgreSQL logical replication uses a publication to define which tables and operations are exposed through logical replication.&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="no"&gt;PUBLICATION&lt;/span&gt; &lt;span class="n"&gt;users_cdc&lt;/span&gt;
&lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This publication exposes changes to the users table, which Debezium subsequently reads from the PostgreSQL WAL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify the replication configuration&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We can now verify the PostgreSQL configuration for our CDC pipeline.&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="no"&gt;SHOW&lt;/span&gt; &lt;span class="n"&gt;wal_level&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- should return `logical`&lt;/span&gt;

&lt;span class="k"&gt;SELECT&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;pg_publication_tables&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- should contain the expected table `users`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Pre-loading Opensearch and Qdrant
&lt;/h4&gt;

&lt;p&gt;Before setting up our CDC pipeline, we will first populate the AWS Opensearch and Qdrant cloud instances.&lt;/p&gt;

&lt;p&gt;For Opensearch, we can use a &lt;strong&gt;&lt;em&gt;Logstash&lt;/em&gt;&lt;/strong&gt; pipeline as a convenient way to perform the initial bulk load directly from PostgreSQL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;jdbc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;jdbc_connection_string&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"jdbc:postgresql://&amp;lt;RDS_ENDPOINT&amp;gt;:5432/appdb"&lt;/span&gt;
    &lt;span class="n"&gt;jdbc_user&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"indexer"&lt;/span&gt;
    &lt;span class="n"&gt;jdbc_password&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;PASSWORD&amp;gt;"&lt;/span&gt;
    &lt;span class="n"&gt;jdbc_driver_library&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/path/postgresql.jar"&lt;/span&gt;
    &lt;span class="n"&gt;jdbc_driver_class&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"org.postgresql.Driver"&lt;/span&gt;

    &lt;span class="n"&gt;statement&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"
      SELECT
        id, name, email, company, job_title,
        location, skills, bio, experience,
        created_at, updated_at
      FROM users
    "&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;opensearch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;hosts&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;OPENSEARCH_ENDPOINT&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;USERNAME&amp;gt;"&lt;/span&gt;
    &lt;span class="n"&gt;password&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;PASSWORD&amp;gt;"&lt;/span&gt;
    &lt;span class="n"&gt;index&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt;

    &lt;span class="c1"&gt;# Keep the Postgres primary key as the OpenSearch document ID&lt;/span&gt;
    &lt;span class="n"&gt;document_id&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"%{id}"&lt;/span&gt;

    &lt;span class="n"&gt;ecs_compatibility&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;disabled&lt;/span&gt;
    &lt;span class="n"&gt;ssl_certificate_verification&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="n"&gt;stdout&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;codec&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;rubydebug&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can then query the Opensearch instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="n"&gt;GET&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="n"&gt;_search&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"size"&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="nl"&gt;"_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"multi_match"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"backend engineer kafka"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;"fields"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="s2"&gt;"job_title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"skills"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"bio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;"location"&lt;/span&gt;
      &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and expect a response as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"took"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;378&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"timed_out"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"_shards"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"successful"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"skipped"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"failed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8621&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nl"&gt;"relation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"eq"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"max_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.1194136&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"hits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nl"&gt;"_index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;"_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"2970"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;"_score"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;2.1194136&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nl"&gt;"_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2970&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Similarly, we can bulk-load the Qdrant instance using a Python script:&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;from&lt;/span&gt; &lt;span class="n"&gt;qdrant_client&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;QdrantClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;QDRANT_URL&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;QDRANT_API_KEY&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;cloud_inference&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;collection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"users_semantic"&lt;/span&gt;

&lt;span class="n"&gt;points&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"""
    Name: {user["name"]}
    Company: {user["company"]}
    Job title: {user["job_title"]}
    Location: {user["location"]}
    Skills: {user["skills"]}
    Bio: {user["bio"]}
    """&lt;/span&gt;

    &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PointStruct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sentence-transformers/all-MiniLM-L6-v2"&lt;/span&gt;&lt;span class="p"&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;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&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;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each Qdrant point uses the PostgreSQL id as its point ID, keeping the identity consistent across the source database, OpenSearch and Qdrant.&lt;/p&gt;

&lt;h4&gt;
  
  
  Local CDC infrastructure
&lt;/h4&gt;

&lt;p&gt;For the streaming layer, we will use docker compose to run Kafka and Debezium locally. This will make it possible to experiment with the CDC pipeline without having to operate a complete Kafka cluster at cloud.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

  &lt;span class="na"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apache/kafka:4.0.0&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;

    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"9092:9092"&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"9094:9094"&lt;/span&gt;

    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# KRaft - single node
&lt;/span&gt;      &lt;span class="na"&gt;KAFKA_NODE_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_PROCESS_ROLES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;broker,controller&lt;/span&gt;

      &lt;span class="c1"&gt;# PLAINTEXT is for other containers on the "cdc" network (e.g.
&lt;/span&gt;      &lt;span class="c1"&gt;# debezium), which resolve "kafka" via Docker's internal DNS.
&lt;/span&gt;      &lt;span class="c1"&gt;# EXTERNAL is for clients running on the host machine (e.g.
&lt;/span&gt;      &lt;span class="c1"&gt;# consumer/index_users.py via the venv), which can't resolve
&lt;/span&gt;      &lt;span class="c1"&gt;# "kafka" and need "localhost" instead.
&lt;/span&gt;      &lt;span class="na"&gt;KAFKA_LISTENERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PLAINTEXT://:9092,EXTERNAL://:9094,CONTROLLER://:9093&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_ADVERTISED_LISTENERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094&lt;/span&gt;

      &lt;span class="na"&gt;KAFKA_LISTENER_SECURITY_PROTOCOL_MAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,EXTERNAL:PLAINTEXT&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_CONTROLLER_LISTENER_NAMES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CONTROLLER&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_CONTROLLER_QUORUM_VOTERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1@kafka:9093&lt;/span&gt;

      &lt;span class="c1"&gt;# Single-node development settings
&lt;/span&gt;      &lt;span class="na"&gt;KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_TRANSACTION_STATE_LOG_MIN_ISR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

      &lt;span class="na"&gt;KAFKA_NUM_PARTITIONS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kafka-data:/var/lib/kafka/data&lt;/span&gt;

    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cdc&lt;/span&gt;

  &lt;span class="na"&gt;debezium&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;quay.io/debezium/connect:3.3&lt;/span&gt;
    &lt;span class="na"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;debezium&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;debezium&lt;/span&gt;

    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s"&gt;kafka&lt;/span&gt;

    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"8083:8083"&lt;/span&gt;

    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;BOOTSTRAP_SERVERS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kafka:9092&lt;/span&gt;

      &lt;span class="c1"&gt;# Kafka Connect worker
&lt;/span&gt;      &lt;span class="na"&gt;GROUP_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

      &lt;span class="na"&gt;CONFIG_STORAGE_TOPIC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;connect_configs&lt;/span&gt;
      &lt;span class="na"&gt;OFFSET_STORAGE_TOPIC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;connect_offsets&lt;/span&gt;
      &lt;span class="na"&gt;STATUS_STORAGE_TOPIC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;connect_status&lt;/span&gt;

      &lt;span class="na"&gt;CONFIG_STORAGE_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;OFFSET_STORAGE_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
      &lt;span class="na"&gt;STATUS_STORAGE_REPLICATION_FACTOR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;

      &lt;span class="c1"&gt;# JSON for easy inspection while learning
&lt;/span&gt;      &lt;span class="na"&gt;KEY_CONVERTER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;org.apache.kafka.connect.json.JsonConverter&lt;/span&gt;
      &lt;span class="na"&gt;VALUE_CONVERTER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;org.apache.kafka.connect.json.JsonConverter&lt;/span&gt;

      &lt;span class="na"&gt;KEY_CONVERTER_SCHEMAS_ENABLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"false"&lt;/span&gt;
      &lt;span class="na"&gt;VALUE_CONVERTER_SCHEMAS_ENABLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"false"&lt;/span&gt;

      &lt;span class="c1"&gt;# REST API
&lt;/span&gt;      &lt;span class="na"&gt;REST_PORT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8083&lt;/span&gt;
      &lt;span class="na"&gt;REST_ADVERTISED_HOST_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;debezium&lt;/span&gt;

    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cdc&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kafka-data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;cdc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bridge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can get our Kafka+Debezium cluster up by executing&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up -d
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the cluster is up and running, we will do a health check and then register our connector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Verify the container processes&lt;/span&gt;
docker compose ps

&lt;span class="c"&gt;# kafka running&lt;/span&gt;
&lt;span class="c"&gt;# debezium running&lt;/span&gt;

&lt;span class="c"&gt;# Check that Kafka is responding&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server kafka:9092 \
  --list

&lt;span class="c"&gt;# connect_configs&lt;/span&gt;
&lt;span class="c"&gt;# connect_offsets&lt;/span&gt;
&lt;span class="c"&gt;# connect_status&lt;/span&gt;
&lt;span class="c"&gt;# Later once debezium is registered we will also see - &lt;/span&gt;
&lt;span class="c"&gt;# usersdb.public.users&lt;/span&gt;
&lt;span class="c"&gt;# schemahistory.users&lt;/span&gt;

&lt;span class="c"&gt;# Verify the Kafka Connect internal topics&lt;/span&gt;
docker &lt;span class="nb"&gt;exec&lt;/span&gt; kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server kafka:9092 \
  --describe \
  --topic connect_configs

&lt;span class="c"&gt;# PartitionCount: 1&lt;/span&gt;
&lt;span class="c"&gt;# Configs: cleanup.policy=compact&lt;/span&gt;

docker &lt;span class="nb"&gt;exec&lt;/span&gt; kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server kafka:9092 \
  --describe \
  --topic connect_offsets

&lt;span class="c"&gt;# PartitionCount: &lt;/span&gt;
&lt;span class="c"&gt;# Configs: cleanup.policy=compact&lt;/span&gt;

docker &lt;span class="nb"&gt;exec&lt;/span&gt; kafka /opt/kafka/bin/kafka-topics.sh \
  --bootstrap-server kafka:9092 \
  --describe \
  --topic connect_status

&lt;span class="c"&gt;# PartitionCount: &lt;/span&gt;
&lt;span class="c"&gt;# Configs: cleanup.policy=compact&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, we can validate our Debezium instance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://localhost:8083/

curl http://localhost:8083/connectors

&lt;span class="c"&gt;# should return []&lt;/span&gt;

curl http://localhost:8083/connector-plugins

&lt;span class="c"&gt;# {&lt;/span&gt;
&lt;span class="c"&gt;# "class": "io.debezium.connector.postgresql.PostgresConnector",&lt;/span&gt;
&lt;span class="c"&gt;# "type": "source",&lt;/span&gt;
&lt;span class="c"&gt;# "version": "..."&lt;/span&gt;
&lt;span class="c"&gt;# }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is better to check for the debezium logs as well —&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker logs debezium --tail 100
docker logs -f debezium

&lt;span class="c"&gt;# We should not see - &lt;/span&gt;
&lt;span class="c"&gt;# ConfigException&lt;/span&gt;
&lt;span class="c"&gt;# Kafka Connect stopping&lt;/span&gt;
&lt;span class="c"&gt;# Uncaught exception in herder work thread&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Registering the PostgreSQL connector requires a connector configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"users-cdc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"config"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"connector.class"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"io.debezium.connector.postgresql.PostgresConnector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nl"&gt;"database.hostname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"database.port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"5432"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"database.user"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"database.password"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"database.dbname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"database.sslmode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"require"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nl"&gt;"topic.prefix"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"usersdb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nl"&gt;"plugin.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"pgoutput"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nl"&gt;"slot.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"users_cdc_slot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"publication.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"users_cdc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"publication.autocreate.mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"disabled"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nl"&gt;"table.include.list"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"public.users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nl"&gt;"snapshot.mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"no_data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nl"&gt;"tombstones.on.delete"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"false"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

    &lt;span class="nl"&gt;"schema.history.internal.kafka.topic"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"schemahistory.users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nl"&gt;"schema.history.internal.kafka.bootstrap.servers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"kafka:9092"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we can call the registration API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl -X POST \
  -H &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; \
  --data @users-cdc.json \
  http://localhost:8083/connectors

&lt;span class="c"&gt;# check the connector &lt;/span&gt;
curl http://localhost:8083/connectors/users-cdc

curl http://localhost:8083/connectors/users-cdc/status

&lt;span class="c"&gt;# {&lt;/span&gt;
&lt;span class="c"&gt;# "name": "users-cdc",&lt;/span&gt;
&lt;span class="c"&gt;# "connector": {&lt;/span&gt;
&lt;span class="c"&gt;# "state": "RUNNING"&lt;/span&gt;
&lt;span class="c"&gt;# },&lt;/span&gt;
&lt;span class="c"&gt;# "tasks": [&lt;/span&gt;
&lt;span class="c"&gt;# {&lt;/span&gt;
&lt;span class="c"&gt;# "id": 0,&lt;/span&gt;
&lt;span class="c"&gt;# "state": "RUNNING"&lt;/span&gt;
&lt;span class="c"&gt;# }&lt;/span&gt;
&lt;span class="c"&gt;# ]&lt;/span&gt;
&lt;span class="c"&gt;# }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Indexing Consumer
&lt;/h4&gt;

&lt;p&gt;The consumers read CDC events from Kafka and translate them into operations on the respective search systems.&lt;/p&gt;

&lt;p&gt;For each database change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;INSERT → create/upsert the document&lt;/li&gt;
&lt;li&gt;UPDATE → update/re-embed the document&lt;/li&gt;
&lt;li&gt;DELETE → remove the document from the indexes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This also makes the pipeline &lt;strong&gt;change-driven&lt;/strong&gt; : instead of periodically scanning the entire database, only the records that changed need to be propagated.&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;if&lt;/span&gt; &lt;span class="n"&gt;operation&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"c"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"u"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"r"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"after"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;opensearch_future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;index_opensearch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;qdrant_future&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;index_qdrant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;opensearch_future&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qdrant_future&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Opensearch we will upload the complete document:&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;document_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;opensearch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bulk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;body&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="s2"&gt;"index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s2"&gt;"_index"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"users"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s2"&gt;"_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&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;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Qdrant we will build the search text and then publish:&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;build_search_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"job_title"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"company"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"skills"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"bio"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&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;value&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;fields&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PointStruct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&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;vector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;build_search_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"sentence-transformers/all-MiniLM-L6-v2"&lt;/span&gt;&lt;span class="p"&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;payload&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The complete consumer script is shared at —&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/umangaggarwal2/real-time-retrieval/blob/master/consumer/index_users.py" rel="noopener noreferrer"&gt;real-time-retrieval/consumer/index_users.py at master · umangaggarwal2/real-time-retrieval&lt;/a&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Querying: Hybrid Search
&lt;/h4&gt;

&lt;p&gt;We are finally at the last piece of our architecture — retrieval. We will use both keyword and semantic search, i.e. retrieve from Opensearch and Qdrant and combine the results using RRF.&lt;/p&gt;

&lt;p&gt;The steps for writing a retrieval script are —&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;BM25: top 50&lt;/li&gt;
&lt;li&gt;semantic: top 50&lt;/li&gt;
&lt;li&gt;BM25 weight: 0.6&lt;/li&gt;
&lt;li&gt;semantic weight: 0.4&lt;/li&gt;
&lt;li&gt;k = 60&lt;/li&gt;
&lt;li&gt;final results: top 10&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where the RRF score is defined as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(d) =
    0.6 * 1 / (60 + bm25_rank(d)) +
    0.4 * 1 / (60 + semantic_rank(d))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RRF lets the two retrieval systems contribute based on rank rather than trying to directly compare BM25 scores with cosine similarity scores, which live on different scales.&lt;/p&gt;

&lt;p&gt;Find the complete querying script at —&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/umangaggarwal2/real-time-retrieval/blob/master/search/query.py" rel="noopener noreferrer"&gt;real-time-retrieval/search/query.py at master · umangaggarwal2/real-time-retrieval&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Benchmarking the performance
&lt;/h3&gt;

&lt;p&gt;We may have the system ready and working.&lt;/p&gt;

&lt;p&gt;But this architecture becomes much more interesting when we understand how it behaves under load — and, more importantly, how much &lt;strong&gt;“real-time”&lt;/strong&gt; we actually get.&lt;/p&gt;

&lt;p&gt;We will benchmark the write and read paths with a &lt;strong&gt;target load of 100 QPS&lt;/strong&gt; for 2–5 minutes.&lt;/p&gt;

&lt;p&gt;Peformance — defines how fast a system works for a single task or at a pre-defined benchmark scale.&lt;/p&gt;

&lt;p&gt;Hence, we will measure the latency, while keeping our benchmark 100 QPS, but for a small dataset of &lt;strong&gt;10K records.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rather than measuring only end-to-end latency, we instrument each stage of the pipeline so that we can distinguish &lt;strong&gt;database latency, CDC lag, queueing, network overhead, and downstream indexing/inference time&lt;/strong&gt;.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write → database&lt;/li&gt;
&lt;li&gt;Database WAL →CDC&lt;/li&gt;
&lt;li&gt;CDC → Kafka&lt;/li&gt;
&lt;li&gt;Kafka → Consumer&lt;/li&gt;
&lt;li&gt;Consumer → OpenSearch Indexing: including network overhead and indexing latency&lt;/li&gt;
&lt;li&gt;Consumer → Qdrant Indexing: including network overhead, embedding generation and indexing latency
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Write benchmarking&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_writer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csv_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_one_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Pick an existing target user.&lt;/span&gt;
        &lt;span class="n"&gt;target_row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csv_rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;target_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Pick another row as the source of new values.&lt;/span&gt;
        &lt;span class="n"&gt;source_row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;csv_rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;perform_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;_pg_worker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;target_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;source_row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_on_success&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;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db_latency_ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;

        &lt;span class="c1"&gt;# Convert PostgreSQL timestamp into an epoch timestamp.&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;update_time&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamp&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock&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;total_updates&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&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;db_latencies_ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db_latency_ms&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;pending&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;user_id&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="s2"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;update_time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="s2"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;all_target_ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Read benchmarking&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_benchmark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;warmup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_one_query&lt;/span&gt;&lt;span class="p"&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;query_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_on_success&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;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;query_text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result_dict&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;

        &lt;span class="n"&gt;latency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"latency"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;overlap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result_dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"overlap"&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;state_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;bm25_latencies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"bm25_ms"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;semantic_latencies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"semantic_ms"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;rrf_latencies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"rrf_ms"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;total_latencies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latency&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"total_ms"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;overlap_percentages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;overlap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"percentage"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Write Performance: P99
&lt;/h4&gt;

&lt;ol&gt;
&lt;li&gt;&lt;em&gt;PostgreSQL write: 762.62 ms&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;PostgreSQL -&amp;gt; OpenSearch: 65431.88 ms&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;PostgreSQL -&amp;gt; Qdrant: 65431.88 ms&lt;/em&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Write Latency Breakdown:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Postgres commit -&amp;gt; Debezium capture:&lt;/em&gt; &lt;strong&gt;&lt;em&gt;10906.02 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Debezium -&amp;gt; consumer receive (Kafka):&lt;/em&gt; &lt;strong&gt;&lt;em&gt;302706.23 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;OpenSearch: network overhead:&lt;/em&gt; &lt;strong&gt;&lt;em&gt;543.66 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;OpenSearch: server-side indexing:&lt;/em&gt; &lt;strong&gt;&lt;em&gt;14.00 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Qdrant: network overhead:&lt;/em&gt; &lt;strong&gt;&lt;em&gt;261.87 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Qdrant: server-side embed + HNSW (time):&lt;/em&gt; &lt;strong&gt;&lt;em&gt;192.16 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;h4&gt;
  
  
  Read Performance: P99
&lt;/h4&gt;

&lt;p&gt;&lt;em&gt;OpenSearch / BM25 latency (total round-trip, network + server):&lt;/em&gt; &lt;strong&gt;&lt;em&gt;7634.46 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Qdrant / semantic latency (total round-trip, network + server):&lt;/em&gt; &lt;strong&gt;&lt;em&gt;2107.44 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;RRF computation latency:&lt;/em&gt; &lt;strong&gt;&lt;em&gt;0.29 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;End-to-end hybrid latency:&lt;/em&gt; &lt;strong&gt;&lt;em&gt;8517.81 ms&lt;/em&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;BM25 / semantic top-50 overlap: _ **_40%&lt;/em&gt;**&lt;/p&gt;

&lt;h3&gt;
  
  
  Bottlenecks
&lt;/h3&gt;

&lt;p&gt;The benchmark gives us a few interesting observations about where the system starts to struggle under load.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Consumer throughput is the bottleneck in the write path&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We are generating around &lt;strong&gt;100 database updates per second&lt;/strong&gt; , while the indexing consumer can process only around &lt;strong&gt;1–3 updates per second per instance&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Hence, the database is able to accept the writes, but the downstream consumer is not able to process the changes at the same rate.&lt;/p&gt;

&lt;p&gt;This creates a backlog in Kafka.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. OpenSearch is the bottleneck in the read path&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The read benchmark gives us a different bottleneck.&lt;/p&gt;

&lt;p&gt;We are querying OpenSearch for BM25 results and Qdrant for semantic results and then combining both using RRF.&lt;/p&gt;

&lt;p&gt;The p99 latency for OpenSearch was: 7634.46 ms&lt;/p&gt;

&lt;p&gt;The RRF computation is negligible compared to the time spent retrieving the results. Qdrant also remains significantly healthier than OpenSearch under this workload.&lt;/p&gt;

&lt;p&gt;The OpenSearch instance starts struggling when we increase concurrent BM25 queries. This results in request timeouts and causes the achieved query throughput to fall significantly below the requested &lt;strong&gt;100 QPS&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Hence, increasing the application concurrency alone does not solve the problem. The bottleneck is the query capacity of the OpenSearch deployment.&lt;/p&gt;

&lt;p&gt;For a production system handling this level of traffic, we would need to increase the capacity of the search infrastructure rather than simply increasing the number of application threads.&lt;/p&gt;

&lt;h4&gt;
  
  
  Repository:
&lt;/h4&gt;

&lt;p&gt;&lt;a href="https://github.com/umangaggarwal2/real-time-retrieval/tree/master" rel="noopener noreferrer"&gt;https://github.com/umangaggarwal2/real-time-retrieval/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>performance</category>
      <category>search</category>
    </item>
  </channel>
</rss>
