<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://jmilkiewicz.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://jmilkiewicz.github.io/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-09T16:12:29+02:00</updated><id>https://jmilkiewicz.github.io/feed.xml</id><title type="html">jmilkiewicz notes</title><subtitle>Notes on distributed workflows, Temporal and building reliable systems”.</subtitle><author><name>Jakub Milkiewicz</name></author><entry><title type="html">Same signature, different beast: what happened when our Temporal activity stopped being pure I/O</title><link href="https://jmilkiewicz.github.io/2026/10/temporal-execution-profile/" rel="alternate" type="text/html" title="Same signature, different beast: what happened when our Temporal activity stopped being pure I/O" /><published>2026-10-09T00:00:00+02:00</published><updated>2026-10-09T00:00:00+02:00</updated><id>https://jmilkiewicz.github.io/2026/10/temporal-execution-profile</id><content type="html" xml:base="https://jmilkiewicz.github.io/2026/10/temporal-execution-profile/"><![CDATA[<p>This is the third post in my Temporal series. The first was about a data contract that silently broke at a language boundary. The second was about load: a fan-out that made our cluster look frozen. This one is about something I hadn’t thought of as part of a contract at all: <strong>how an activity spends its time.</strong></p>

<p>The short version: <strong>one pod, one event loop, one translation at a time.</strong> We replaced a network call with a blocking, CPU-bound model call inside an <code class="language-plaintext highlighter-rouge">async def</code> activity. From then on, each Python worker could run only one translation at a time, no matter how many it had accepted, and the translations waiting their turn couldn’t even tell Temporal they were alive.</p>

<p>As before, the domain is anonymized, and it’s snacks again. Our app collects tasting notes from all over the world, and to show them in one feed, we translate them. The code is simplified. The settings and timeouts are the real ones.</p>

<h2 id="act-i-two-branches-that-look-the-same">Act I: two branches that look the same</h2>

<p>Translation could go one of two ways, depending on how a client was configured:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="p">(</span><span class="nx">translationParameters</span><span class="p">.</span><span class="nx">kind</span> <span class="o">===</span> <span class="dl">"</span><span class="s2">languageModel</span><span class="dl">"</span><span class="p">)</span> <span class="p">{</span>
  <span class="c1">// TypeScript activity: a direct call to an LLM</span>
  <span class="k">return</span> <span class="nx">translateTastingNoteText</span><span class="p">({</span>
    <span class="na">tastingNote</span><span class="p">:</span> <span class="p">{</span> <span class="nx">text</span> <span class="p">},</span>
    <span class="na">outputLanguages</span><span class="p">:</span> <span class="nx">translationParameters</span><span class="p">.</span><span class="nx">outputLanguages</span><span class="p">,</span>
    <span class="na">model</span><span class="p">:</span> <span class="nx">translationParameters</span><span class="p">.</span><span class="nx">languageModel</span><span class="p">,</span>
  <span class="p">});</span>
<span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
  <span class="c1">// Python activity: our own translation model</span>
  <span class="k">return</span> <span class="nx">translateWithOwnModel</span><span class="p">({</span>
    <span class="nx">text</span><span class="p">,</span>
    <span class="na">outputLanguages</span><span class="p">:</span> <span class="nx">translationParameters</span><span class="p">.</span><span class="nx">outputLanguages</span><span class="p">,</span>
    <span class="p">...(</span><span class="nx">translationParameters</span><span class="p">.</span><span class="nx">selectedTranslationModel</span> <span class="o">&amp;&amp;</span> <span class="p">{</span>
      <span class="na">selectedTranslationModel</span><span class="p">:</span> <span class="nx">translationParameters</span><span class="p">.</span><span class="nx">selectedTranslationModel</span><span class="p">,</span>
    <span class="p">}),</span>
  <span class="p">});</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Read it as the workflow sees it: same inputs, same output, two interchangeable implementations. Nothing here tells you that the first branch is an HTTPS request and the second one, most of the time, is a matrix multiplication.</p>

<p>The TypeScript branch was boring in the best way. The activity sent a request to an LLM and awaited the response. While it waited, the Node.js event loop was free to run other activities. In Grafana, activity execution time was low and so was schedule-to-start latency. The worker spent most of its life waiting on sockets.</p>

<p>At our scale, though, paying per token added up. So the company decided to translate with its own model, served by a new Python worker (one per Kubernetes pod) on a new task queue. Clients were moved to it gradually, which is why, for a while, only some of them were affected.</p>

<h2 id="act-ii-one-pod-one-lane">Act II: one pod, one lane</h2>

<p>The Python activity did more than the TypeScript one. It detected the source language with fastText, translated locally with a Marian model if the language pair was supported, and otherwise fell back to an LLM over HTTPS. While it worked, a keepalive sent a heartbeat every 3 seconds. Simplified:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">activity</span><span class="p">.</span><span class="n">defn</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">translate_text</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">args</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span>
    <span class="n">stop_heartbeat</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_start_heartbeat_keepalive</span><span class="p">()</span>  <span class="c1"># heartbeat every 3 s
</span>    <span class="k">try</span><span class="p">:</span>
        <span class="n">source</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">factory</span><span class="p">.</span><span class="n">identify_language</span><span class="p">(</span><span class="n">args</span><span class="p">[</span><span class="s">"text"</span><span class="p">])</span>  <span class="c1"># CPU
</span>        <span class="n">result</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">factory</span><span class="p">.</span><span class="n">translate</span><span class="p">(</span>                         <span class="c1"># CPU, or blocking HTTP
</span>            <span class="n">args</span><span class="p">[</span><span class="s">"text"</span><span class="p">],</span> <span class="n">source</span><span class="p">,</span> <span class="n">args</span><span class="p">[</span><span class="s">"target_language"</span><span class="p">]</span>
        <span class="p">)</span>
    <span class="k">finally</span><span class="p">:</span>
        <span class="n">stop_heartbeat</span><span class="p">()</span>
    <span class="k">return</span> <span class="p">{</span><span class="s">"output_text"</span><span class="p">:</span> <span class="n">result</span><span class="p">}</span>
</code></pre></div></div>

<p>And the worker setting that mattered:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">temporal_max_concurrent_activities</span> <span class="o">=</span> <span class="mi">50</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">async def</code> decides everything here. In the Temporal Python SDK, an <code class="language-plaintext highlighter-rouge">async def</code> activity runs on the worker’s asyncio event loop. A plain <code class="language-plaintext highlighter-rouge">def</code> activity runs on a thread pool, which you have to give the worker explicitly. The SDK README is explicit about which to prefer: threaded activities are the initial recommendation, and there is a boxed warning not to block the thread in an <code class="language-plaintext highlighter-rouge">async def</code> function.</p>

<p>The configuration also had <code class="language-plaintext highlighter-rouge">temporal_activity_max_threads = 10</code>, which suggests someone expected threads to be involved. With only <code class="language-plaintext highlighter-rouge">async def</code> activities, they weren’t: no thread pool ran any of this code, and the setting had no effect.</p>

<p>Look at the body again: there isn’t a single <code class="language-plaintext highlighter-rouge">await</code> in it. Language detection, local inference and even the LLM fallback were ordinary blocking calls. An <code class="language-plaintext highlighter-rouge">async def</code> with no <code class="language-plaintext highlighter-rouge">await</code> doesn’t give the event loop back until it returns.</p>

<p>So while one translation was running, every other translation on that pod simply stood still. The worker accepted up to 50 of them and ran them strictly one after another. <strong>A pod configured for 50 concurrent activities behaved like a pod configured for one.</strong></p>

<h2 id="act-iii-timeouts-on-a-three-minute-activity">Act III: timeouts on a three-minute activity</h2>

<p>That alone would have made translations slow. What made them fail was this, on the workflow side:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">proxyActivities</span><span class="o">&lt;</span><span class="nx">TranslationPythonActivities</span><span class="o">&gt;</span><span class="p">({</span>
  <span class="na">taskQueue</span><span class="p">:</span> <span class="nx">TASK_QUEUES</span><span class="p">.</span><span class="nx">TRANSLATION</span><span class="p">,</span>
  <span class="na">startToCloseTimeout</span><span class="p">:</span> <span class="dl">"</span><span class="s2">3 minutes</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">heartbeatTimeout</span><span class="p">:</span> <span class="dl">"</span><span class="s2">10 seconds</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">retry</span><span class="p">:</span> <span class="p">{</span> <span class="na">initialInterval</span><span class="p">:</span> <span class="dl">"</span><span class="s2">1 second</span><span class="dl">"</span><span class="p">,</span> <span class="na">maximumAttempts</span><span class="p">:</span> <span class="mi">2</span> <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Three minutes for a translation looks generous, and the activity sent a heartbeat every 3 seconds, well inside 10. So why did so many attempts die on the heartbeat timeout?</p>

<p>Because of two details that are easy to miss:</p>

<ul>
  <li><strong>The timeout clock starts when the attempt starts, and an attempt starts when the worker receives the task, not when your code gets to run.</strong> The translations waiting their turn on a pod weren’t waiting in Temporal’s queue. As far as Temporal was concerned, they had started.</li>
  <li><strong>Heartbeats need the event loop too.</strong> A translation waiting its turn hadn’t started its keepalive yet, so it sent nothing. The one being translated had a keepalive, but its heartbeats couldn’t be sent while the loop was blocked. This doesn’t depend on how the keepalive is built: in the Python SDK, every heartbeat is handed to the worker’s event loop to be sent, including one called from an activity’s thread.</li>
</ul>

<p>So a translation timed out if 10 seconds passed without a heartbeat reaching the server. For one waiting in line, that meant its turn didn’t come within 10 seconds of being accepted. With a few translations ahead of it, that was most of them.</p>

<p>It got worse from there. A translation that had already timed out while waiting still ran in full when its turn came: nothing had told the worker, and even if something had, there was no <code class="language-plaintext highlighter-rouge">await</code> at which to stop it. So part of each pod’s single lane went into results the server would reject.</p>

<p>Meanwhile, Temporal retried the timed-out attempts. All of the activity’s own errors were marked non-retryable, so <code class="language-plaintext highlighter-rouge">maximumAttempts: 2</code> came into play only for timeouts, and there were plenty of those. A retry could just as well land on another pod that was equally stuck.</p>

<p>With the TypeScript activity, none of this showed. An LLM call yields at its <code class="language-plaintext highlighter-rouge">await</code>, so many requests can wait in parallel on one event loop, and a short heartbeat timeout is harmless. The Python activity kept the same workflow call but stopped yielding, and that removed the conditions that had made the timeout harmless.</p>

<h2 id="act-iv-why-60-replicas-made-it-worse">Act IV: why 60 replicas made it worse</h2>

<p>When a queue backs up, the natural reaction is to add workers. The deployment already scaled with KEDA on the task-queue backlog:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">minReplicaCount</span><span class="pi">:</span> <span class="m">5</span>
<span class="na">maxReplicaCount</span><span class="pi">:</span> <span class="m">60</span>
<span class="na">pollingInterval</span><span class="pi">:</span> <span class="m">3</span>
<span class="na">advanced</span><span class="pi">:</span>
  <span class="na">horizontalPodAutoscalerConfig</span><span class="pi">:</span>
    <span class="na">behavior</span><span class="pi">:</span>
      <span class="na">scaleUp</span><span class="pi">:</span>
        <span class="na">stabilizationWindowSeconds</span><span class="pi">:</span> <span class="m">0</span>
        <span class="na">policies</span><span class="pi">:</span>
          <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">Percent</span>
            <span class="na">value</span><span class="pi">:</span> <span class="m">100</span>
            <span class="na">periodSeconds</span><span class="pi">:</span> <span class="m">15</span>
      <span class="na">scaleDown</span><span class="pi">:</span>
        <span class="na">stabilizationWindowSeconds</span><span class="pi">:</span> <span class="m">3600</span>
<span class="na">triggers</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">type</span><span class="pi">:</span> <span class="s">temporal</span>
    <span class="na">metadata</span><span class="pi">:</span>
      <span class="na">taskQueue</span><span class="pi">:</span> <span class="s2">"</span><span class="s">translation"</span>
      <span class="na">queueTypes</span><span class="pi">:</span> <span class="s2">"</span><span class="s">activity"</span>
      <span class="na">targetQueueSize</span><span class="pi">:</span> <span class="s2">"</span><span class="s">1"</span>
</code></pre></div></div>

<p>The backlog on the translation queue grew, and with it schedule-to-start latency, so the autoscaler did exactly what it was told. <code class="language-plaintext highlighter-rouge">targetQueueSize: "1"</code> asks for roughly one pod per waiting task, the replica count can double every 15 seconds, and the one-hour scale-down window keeps the pods once they exist. We went to the maximum quickly and stayed there.</p>

<p>Every new pod did add a lane. But it also brought the same problem with it:</p>

<ol>
  <li>The pod accepts up to 50 tasks and starts 50 clocks.</li>
  <li>It works through them one at a time, and most of them time out, some of them while still taking up the lane.</li>
  <li>Each timeout puts a retry back on the queue, alongside the new work.</li>
  <li>The backlog grows, KEDA adds pods, and we go back to step 1.</li>
</ol>

<p>Every pod also loaded its own copy of the models, so 60 replicas meant 60 copies in memory. The cluster paid for all that capacity and the queue barely moved, because much of it went into work that had already timed out. The number of pods wasn’t the problem. Each pod accepting far more work than it could start was.</p>

<h2 id="act-v-threads-and-what-they-didnt-change">Act V: threads, and what they didn’t change</h2>

<p>The first change was the obvious one: turn <code class="language-plaintext highlighter-rouge">async def</code> into <code class="language-plaintext highlighter-rouge">def</code>, so each activity runs on a thread from the pool and the event loop stays free.</p>

<p>Threads come with their own limitation. The model and tokenizer aren’t safe to use from several threads at once, so local inference sits behind a lock. The lock covers only tokenization, <code class="language-plaintext highlighter-rouge">model.generate()</code> and model loading. Language detection and the LLM fallback run outside it.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">MarianTranslator</span><span class="p">:</span>
    <span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="bp">None</span><span class="p">:</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">_lock</span> <span class="o">=</span> <span class="n">threading</span><span class="p">.</span><span class="n">RLock</span><span class="p">()</span>

    <span class="k">def</span> <span class="nf">translate</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">text</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">str</span><span class="p">:</span>
        <span class="k">with</span> <span class="bp">self</span><span class="p">.</span><span class="n">_lock</span><span class="p">:</span>
            <span class="p">...</span>  <span class="c1"># tokenize, model.generate(), decode
</span></code></pre></div></div>

<p>The second change was making sure every task the worker accepts gets a thread right away. Switching to <code class="language-plaintext highlighter-rouge">def</code> and keeping the old configuration would have meant 50 concurrent activities on 10 threads, with 40 tasks sitting in the executor’s queue. They wouldn’t have started their keepalive, so they’d time out exactly as before. The SDK logs a warning about this, but a warning is easy to miss, so I made the worker refuse to start instead:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">concurrency</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">temporal_max_concurrent_activities</span>
<span class="n">pool_size</span> <span class="o">=</span> <span class="n">settings</span><span class="p">.</span><span class="n">temporal_activity_max_threads</span>
<span class="n">has_sync_activity</span> <span class="o">=</span> <span class="nb">any</span><span class="p">(</span><span class="ow">not</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">iscoroutinefunction</span><span class="p">(</span><span class="n">fn</span><span class="p">)</span> <span class="k">for</span> <span class="n">fn</span> <span class="ow">in</span> <span class="n">activities</span><span class="p">)</span>
<span class="k">if</span> <span class="n">has_sync_activity</span> <span class="ow">and</span> <span class="n">pool_size</span> <span class="o">&lt;</span> <span class="n">concurrency</span><span class="p">:</span>
    <span class="k">raise</span> <span class="nb">ValueError</span><span class="p">(</span>
        <span class="sa">f</span><span class="s">"temporal_activity_max_threads (</span><span class="si">{</span><span class="n">pool_size</span><span class="si">}</span><span class="s">) must be &gt;= "</span>
        <span class="sa">f</span><span class="s">"temporal_max_concurrent_activities (</span><span class="si">{</span><span class="n">concurrency</span><span class="si">}</span><span class="s">) when running "</span>
        <span class="s">"synchronous activities; otherwise activities queue without a worker "</span>
        <span class="s">"thread and miss their heartbeat timeout."</span>
    <span class="p">)</span>

<span class="n">worker</span> <span class="o">=</span> <span class="n">Worker</span><span class="p">(</span>
    <span class="n">client</span><span class="p">,</span>
    <span class="n">task_queue</span><span class="o">=</span><span class="n">settings</span><span class="p">.</span><span class="n">temporal_task_queue</span><span class="p">,</span>
    <span class="n">activities</span><span class="o">=</span><span class="n">activities</span><span class="p">,</span>
    <span class="n">activity_executor</span><span class="o">=</span><span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="n">pool_size</span><span class="p">),</span>
    <span class="n">max_concurrent_activities</span><span class="o">=</span><span class="n">concurrency</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p>I set both values to 10: 10 threads and 10 concurrent activities per pod.</p>

<p>Here’s what that changed:</p>

<ul>
  <li><strong>Every accepted task starts heartbeating immediately.</strong> It has its own thread, so its keepalive runs from the first second, even while it waits for the inference lock. Waiting no longer costs a heartbeat timeout. The limit for a waiting task is now the 3-minute <code class="language-plaintext highlighter-rouge">startToCloseTimeout</code>.</li>
  <li><strong>The LLM fallback no longer waits behind inference.</strong> While one thread holds the lock, others detect languages and wait on LLM responses in parallel.</li>
  <li><strong>Each pod accepts only what it can start.</strong> 10 tasks instead of 50.</li>
</ul>

<p>And what it didn’t change: <strong>local inference still runs one at a time per pod.</strong> Threads don’t add inference capacity; they only make waiting safe. If translations needing the local model arrive faster than the pods can run them, they still queue up, now on the lock or in Temporal’s queue instead of behind a blocked event loop.</p>

<p>The keepalive has a cost worth knowing about. It proves that the thread is alive, not that the translation is making progress. If <code class="language-plaintext highlighter-rouge">model.generate()</code> ever hangs, the keepalive will keep heartbeating until the 3-minute limit. And while threads fixed the cancellation problem from Act III in general (the SDK can raise a cancellation inside an activity’s thread), it still can’t interrupt a thread stuck inside a native call like inference.</p>

<h2 id="what-i-would-do-next">What I would do next</h2>

<p>None of the following is implemented, so treat it as my reading of the problem rather than a proven fix.</p>

<ul>
  <li><strong>Size concurrency for the lock, not the thread pool.</strong> If one inference takes <em>t</em> seconds, a pod finishes at most 1/<em>t</em> local translations per second, however many threads it has. That’s Little’s law from the previous post again. Tasks waiting on one pod’s lock can’t be picked up by an idle pod, so accepting more than the lock can serve soon only moves the queue to the wrong place.</li>
  <li><strong>Split the two execution profiles.</strong> Language detection plus the LLM fallback is mostly I/O and can run with high concurrency. Local inference is CPU-bound and serialized. Two activities on two task queues, with the workflow choosing between them, would let each queue have its own worker settings and its own scaling. Today, one setting has to fit both.</li>
  <li><strong>Scale on something the pods can actually drain.</strong> Every pod adds one inference lane and one more copy of the models in memory. A target of one pod per waiting task buys mostly memory.</li>
  <li><strong>Heartbeat with progress, not just a pulse.</strong> Including how far the translation got in the heartbeat details would at least make a stuck attempt visible.</li>
</ul>

<h2 id="why-not-keep-async-def-and-offload-the-work">Why not keep <code class="language-plaintext highlighter-rouge">async def</code> and offload the work?</h2>

<p>A fair question for Python users: why not keep the activity <code class="language-plaintext highlighter-rouge">async</code> and wrap the blocking calls in <code class="language-plaintext highlighter-rouge">await asyncio.to_thread(...)</code>?</p>

<p>You can, and the event loop would stay free. But you then have two concurrency limits, <code class="language-plaintext highlighter-rouge">max_concurrent_activities</code> and the size of whichever executor you offload to, and they must agree for the same reason as above. You also have to find and wrap every blocking call, and the next one someone adds will freeze the loop again. A plain <code class="language-plaintext highlighter-rouge">def</code> activity with an explicit <code class="language-plaintext highlighter-rouge">activity_executor</code> puts both limits in one place, in the worker configuration, where the next person will look. It also matches what the SDK recommends as the default.</p>

<h2 id="takeaways">Takeaways</h2>

<ol>
  <li><strong>An activity’s execution profile is part of its contract.</strong> The workflow call and the types stayed the same. Timeouts, worker concurrency and autoscaling were set up as if it were still a network call.</li>
  <li><strong>A blocking call in an <code class="language-plaintext highlighter-rouge">async def</code> activity stops the whole worker.</strong> Not just that activity: every activity on that pod waits, whatever <code class="language-plaintext highlighter-rouge">max_concurrent_activities</code> says.</li>
  <li><strong>“Started” means handed to a worker, not running.</strong> Timers run from the moment the worker receives a task, including the time it waits inside the pod.</li>
  <li><strong>A task can only heartbeat once it is running.</strong> On a blocked event loop or without a free thread, it can’t. For synchronous activities that heartbeat from their own thread, that means the pool must have at least as many threads as concurrent activities.</li>
  <li><strong>A lock defines the real capacity.</strong> Threads make waiting safe; they don’t make serialized work parallel.</li>
  <li><strong>Replicas can’t fix a bottleneck inside each pod.</strong> With a backlog-based scaler and retries, they can turn it into a feedback loop.</li>
</ol>

<h2 id="how-it-ended">How it ended</h2>

<p>Moving the activity off the event loop, and running it with 10 threads and 10 concurrent activities per pod instead of 50, made the timeouts largely go away. It didn’t solve the underlying problem, though: for translations that need the local model, the capacity of the whole system is still the number of pods divided by the time of one inference.</p>

<p>The practical lesson: when you swap the implementation behind an activity, check its execution profile, not just its signature. Ask where it spends its time, what that does to the worker it runs on, and whether the timeouts, concurrency and scaling settings still describe it.</p>]]></content><author><name>Jakub Milkiewicz</name></author><category term="temporal" /><category term="python" /><category term="asyncio" /><category term="kubernetes" /><category term="keda" /><category term="performance" /><summary type="html"><![CDATA[How swapping an LLM translation call for a local model turned each Temporal worker into a one-lane road, why it showed up as heartbeat timeouts, and why 60 replicas made it worse.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jmilkiewicz.github.io/assets/images/og-temporal-execution-profile.png" /><media:content medium="image" url="https://jmilkiewicz.github.io/assets/images/og-temporal-execution-profile.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Accepted is not done: how a fan-out of 200,000 workflows made our Temporal cluster look frozen</title><link href="https://jmilkiewicz.github.io/2026/10/temporal-accepted-is-not-done/" rel="alternate" type="text/html" title="Accepted is not done: how a fan-out of 200,000 workflows made our Temporal cluster look frozen" /><published>2026-10-07T00:00:00+02:00</published><updated>2026-10-07T00:00:00+02:00</updated><id>https://jmilkiewicz.github.io/2026/10/temporal-accepted-is-not-done</id><content type="html" xml:base="https://jmilkiewicz.github.io/2026/10/temporal-accepted-is-not-done/"><![CDATA[<p>This is the second post in my Temporal series. The first one was about a contract that silently broke at a language boundary. This one is about scale: how a perfectly reasonable fan-out, one workflow per item, made our self-hosted Temporal cluster look frozen for hours, and how changing what we wait for fixed it.</p>

<p>The short version: <strong>a started workflow is not a finished one.</strong> Our importer waited for the first and ignored the second.</p>

<p>As before, the domain is anonymized, and it’s snacks again. Instead of the real data, we import a tasting archive: a large export from a snack-tasting app, where each tasting note can carry attachments such as photos and scanned wrappers. The code is simplified, but the shape of the problem and the numbers are real. The names are not.</p>

<h2 id="act-i-one-workflow-per-tasting-note">Act I: one workflow per tasting note</h2>

<p>The design was simple, and for small archives it worked. An import activity reads the archive and starts a <code class="language-plaintext highlighter-rouge">tastingNoteWorkflow</code> for every note. If a note has attachments, the note workflow starts a child workflow per attachment: one workflow type for photos, another for wrapper scans.</p>

<p>Each workflow type has its own task queue and its own workers. Some activities are shared, though. <code class="language-plaintext highlighter-rouge">tagFlavors</code>, for example, is called both from note workflows and from photo workflows, and all of its executions run on one dedicated activity task queue.</p>

<p>Everybody knew that one workflow per note was not the most efficient design. It was the easiest one, and while the archives were small, nobody felt the cost.</p>

<p>The importer looked roughly like this (simplified):</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">BATCH_SIZE</span> <span class="o">=</span> <span class="mi">200</span><span class="p">;</span>

<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">importTastingArchiveActivity</span><span class="p">({</span> <span class="nx">archiveId</span><span class="p">,</span> <span class="nx">archiveUrl</span> <span class="p">}:</span> <span class="nx">Args</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">resumeFrom</span> <span class="o">=</span> <span class="nx">parseHeartbeat</span><span class="p">(</span><span class="nx">activityInfo</span><span class="p">().</span><span class="nx">heartbeatDetails</span><span class="p">)?.</span><span class="nx">batchIndex</span> <span class="o">??</span> <span class="mi">0</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">notes</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">readWholeArchive</span><span class="p">(</span><span class="nx">archiveUrl</span><span class="p">);</span> <span class="c1">// everything in memory</span>
  <span class="nx">using</span> <span class="nx">client</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getWorkflowClient</span><span class="p">();</span>

  <span class="kd">let</span> <span class="nx">batchIndex</span> <span class="o">=</span> <span class="nx">resumeFrom</span><span class="p">;</span>
  <span class="k">for</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">batch</span> <span class="k">of</span> <span class="nx">chunk</span><span class="p">(</span><span class="nx">notes</span><span class="p">,</span> <span class="nx">BATCH_SIZE</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="nx">resumeFrom</span><span class="p">))</span> <span class="p">{</span>
    <span class="nx">heartbeat</span><span class="p">({</span> <span class="nx">batchIndex</span> <span class="p">});</span>
    <span class="k">await</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">all</span><span class="p">(</span>
      <span class="nx">batch</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">note</span><span class="p">)</span> <span class="o">=&gt;</span>
        <span class="nx">client</span><span class="p">.</span><span class="nx">workflow</span><span class="p">.</span><span class="nx">start</span><span class="p">(</span><span class="nx">tastingNoteWorkflow</span><span class="p">,</span> <span class="p">{</span>
          <span class="na">workflowId</span><span class="p">:</span> <span class="s2">`tasting-note-</span><span class="p">${</span><span class="nx">note</span><span class="p">.</span><span class="nx">id</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span>
          <span class="na">taskQueue</span><span class="p">:</span> <span class="nx">TASK_QUEUES</span><span class="p">.</span><span class="nx">TASTING_NOTES</span><span class="p">,</span>
          <span class="na">searchAttributes</span><span class="p">:</span> <span class="p">{</span> <span class="na">ArchiveId</span><span class="p">:</span> <span class="p">[</span><span class="nx">archiveId</span><span class="p">]</span> <span class="p">},</span>
          <span class="na">args</span><span class="p">:</span> <span class="p">[{</span> <span class="nx">note</span> <span class="p">}],</span>
          <span class="na">workflowIdConflictPolicy</span><span class="p">:</span> <span class="dl">"</span><span class="s2">USE_EXISTING</span><span class="dl">"</span><span class="p">,</span>
        <span class="p">}),</span>
      <span class="p">),</span>
    <span class="p">);</span>
    <span class="nx">batchIndex</span><span class="o">++</span><span class="p">;</span>
  <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Look at what the loop waits for. <code class="language-plaintext highlighter-rouge">client.workflow.start</code> resolves when the Temporal frontend has <strong>accepted</strong> the start request: the workflow exists and its first workflow task is scheduled. Nothing has run yet.</p>

<p>So the batching doesn’t throttle the system. Send 200 starts, wait for 200 acknowledgements, send the next 200. The whole archive is handed to Temporal as fast as the server can say “OK”.</p>

<h2 id="act-ii-the-freeze">Act II: the freeze</h2>

<p>Then the archives got big. The largest one I processed had around 200,000 tasting notes, many of them with attachments. I have seen an export with over 400,000, but I never ran anything that large through this pipeline.</p>

<p>What we saw:</p>

<ul>
  <li>Temporal UI showed an enormous number of running workflows, and almost none of them made progress.</li>
  <li>Some workflows waited hours for their <strong>first workflow task</strong>. Not an activity: the very first step of the workflow code.</li>
  <li>The PostgreSQL database behind Temporal (where workflow histories live), with 5 CPUs in Kubernetes, sat at its CPU limit.</li>
  <li>Processing a single archive took up to about 15 hours.</li>
</ul>

<p>Watching it live was strange. A batch of 200 workflows appeared in Temporal UI, and some of them started making progress. A fraction of a second later came the next 200, and a smaller share of those got going. Then the next 200, with an even smaller share. And so on. Things began to unclog, slowly, only once the importer stopped scheduling new workflows.</p>

<p>From the outside it looked like Temporal had frozen. It hadn’t. It was doing exactly what we had asked it to do.</p>

<h2 id="act-iii-firefighting-with-synchronous_commit">Act III: firefighting with <code class="language-plaintext highlighter-rouge">synchronous_commit</code></h2>

<p>The database was the visible bottleneck, so I went there first. Temporal persistence runs a lot of small transactions. By default, PostgreSQL waits for each commit’s WAL record to be flushed to disk before acknowledging it. Setting <code class="language-plaintext highlighter-rouge">synchronous_commit = off</code> removes that wait, and it helped a little.</p>

<p>It is a trade-off, not a fix. With asynchronous commit, a database crash can lose the last fraction of a second of transactions that were already reported as committed. The database stays consistent, it just forgets the most recent writes. For Temporal, that means the server may have acknowledged a state change (a started workflow, a completed task) that is gone after recovery. We accepted that risk consciously. I wouldn’t recommend it as a default.</p>

<p>More importantly, a faster database treats the symptom. The cause was in our code.</p>

<h2 id="act-iv-accepted-is-not-done">Act IV: accepted is not done</h2>

<p>Here is my best explanation of what happened. It fits everything we observed, but I didn’t have the server metrics to prove every step, so treat it as a model rather than a post-mortem.</p>

<p><strong>Every start costs the database work.</strong> Accepting a start means persisting the execution, its first history events and a task that hands the first workflow task over to matching. Our importer produced these writes as fast as the server would accept them, so the database was busy mostly with new starts.</p>

<p><strong>Workflow tasks pile up.</strong> Kubernetes scaled the note workers out to eight instances, and they still couldn’t keep up, so a backlog built up. The first workflow tasks of hundreds of thousands of new workflows filled the queue, adding latency for every workflow that had already been started. For comparison, the bounded version described below ran comfortably on two workers.</p>

<p><strong>The real work has to queue too.</strong> When an early note workflow finally ran, it called <code class="language-plaintext highlighter-rouge">tagFlavors</code> and started child workflows for its attachments. Separate task queues per workflow type helped a little: a photo workflow didn’t wait behind the first tasks of note workflows. The shared activity queue didn’t help at all. A <code class="language-plaintext highlighter-rouge">tagFlavors</code> call from a photo workflow had to compete with the <code class="language-plaintext highlighter-rouge">tagFlavors</code> calls of nearly every note in the archive.</p>

<p>The cluster itself was configured in a standard way (512 history shards, the default in the Helm chart version we used), so the problem wasn’t in Temporal’s configuration.</p>

<h2 id="act-v-wait-for-the-result-not-the-acceptance">Act V: wait for the result, not the acceptance</h2>

<p>The fix changed one thing: what the importer waits for. Instead of starting everything, it keeps a <strong>sliding window</strong> of N notes in flight and starts the next one only when one of them <strong>finishes</strong>. Finishing is what <code class="language-plaintext highlighter-rouge">handle.result()</code> reports, and for a note it covers the whole thing, including the child workflows of its attachments, because a note workflow waits for its children before it completes.</p>

<p>The window itself is a few lines:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">createWindow</span><span class="p">(</span><span class="nx">size</span><span class="p">:</span> <span class="kr">number</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">inFlight</span> <span class="o">=</span> <span class="k">new</span> <span class="nb">Set</span><span class="o">&lt;</span><span class="nb">Promise</span><span class="o">&lt;</span><span class="k">void</span><span class="o">&gt;&gt;</span><span class="p">();</span>
  <span class="kd">let</span> <span class="nx">failures</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span>

  <span class="k">return</span> <span class="p">{</span>
    <span class="k">async</span> <span class="nx">acquire</span><span class="p">()</span> <span class="p">{</span>
      <span class="k">while</span> <span class="p">(</span><span class="nx">inFlight</span><span class="p">.</span><span class="nx">size</span> <span class="o">&gt;=</span> <span class="nx">size</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">await</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">race</span><span class="p">(</span><span class="nx">inFlight</span><span class="p">);</span>
      <span class="p">}</span>
    <span class="p">},</span>
    <span class="nx">track</span><span class="p">(</span><span class="na">handle</span><span class="p">:</span> <span class="nx">WorkflowHandle</span><span class="p">)</span> <span class="p">{</span>
      <span class="kd">const</span> <span class="nx">done</span> <span class="o">=</span> <span class="nx">handle</span><span class="p">.</span><span class="nx">result</span><span class="p">().</span><span class="nx">then</span><span class="p">(</span>
        <span class="p">()</span> <span class="o">=&gt;</span> <span class="kc">undefined</span><span class="p">,</span>
        <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span> <span class="nx">failures</span><span class="o">++</span><span class="p">;</span> <span class="p">},</span>
      <span class="p">);</span>
      <span class="nx">inFlight</span><span class="p">.</span><span class="nx">add</span><span class="p">(</span><span class="nx">done</span><span class="p">);</span>
      <span class="k">void</span> <span class="nx">done</span><span class="p">.</span><span class="k">finally</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">inFlight</span><span class="p">.</span><span class="k">delete</span><span class="p">(</span><span class="nx">done</span><span class="p">));</span>
    <span class="p">},</span>
    <span class="na">drain</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nb">Promise</span><span class="p">.</span><span class="nx">allSettled</span><span class="p">(</span><span class="nx">inFlight</span><span class="p">),</span>
    <span class="na">failures</span><span class="p">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">failures</span><span class="p">,</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And the loop:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">slots</span> <span class="o">=</span> <span class="nx">createWindow</span><span class="p">(</span><span class="nx">WINDOW_SIZE</span><span class="p">);</span>

<span class="k">for</span> <span class="p">(</span><span class="kd">let</span> <span class="nx">i</span> <span class="o">=</span> <span class="nx">startFrom</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="nx">notes</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">signal</span><span class="p">.</span><span class="nx">aborted</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nx">CancelledFailure</span><span class="p">(</span><span class="dl">"</span><span class="s2">import cancelled</span><span class="dl">"</span><span class="p">);</span>

  <span class="k">await</span> <span class="nx">slots</span><span class="p">.</span><span class="nx">acquire</span><span class="p">();</span> <span class="c1">// wait for a free slot first...</span>
  <span class="kd">const</span> <span class="nx">handle</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">client</span><span class="p">.</span><span class="nx">workflow</span><span class="p">.</span><span class="nx">start</span><span class="p">(</span><span class="nx">tastingNoteWorkflow</span><span class="p">,</span> <span class="p">{</span> <span class="cm">/* as before */</span> <span class="p">});</span> <span class="c1">// ...then start</span>
  <span class="nx">slots</span><span class="p">.</span><span class="nx">track</span><span class="p">(</span><span class="nx">handle</span><span class="p">);</span>

  <span class="nx">lastStartedIndex</span> <span class="o">=</span> <span class="nx">i</span><span class="p">;</span>
  <span class="nx">heartbeat</span><span class="p">({</span> <span class="nx">lastStartedIndex</span> <span class="p">});</span>
<span class="p">}</span>

<span class="k">await</span> <span class="nx">slots</span><span class="p">.</span><span class="nx">drain</span><span class="p">();</span>
<span class="nx">log</span><span class="p">.</span><span class="nx">info</span><span class="p">(</span><span class="dl">"</span><span class="s2">import finished</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="na">failedNotes</span><span class="p">:</span> <span class="nx">slots</span><span class="p">.</span><span class="nx">failures</span><span class="p">()</span> <span class="p">});</span>
</code></pre></div></div>

<p>There is also a timer that heartbeats every 5 minutes, because with a full window the loop can sit in <code class="language-plaintext highlighter-rouge">acquire()</code> for a long time.</p>

<p>Notes that fail are logged and counted, but they don’t fail the import. Each note has its own workflow history and can be retried on its own.</p>

<p>The result: the freeze disappeared immediately, and a similar archive was processed in about 7 hours instead of about 15.</p>

<p>And that was with <code class="language-plaintext highlighter-rouge">synchronous_commit</code> back on. For the bounded version we restored the default, so the database was back to full durability, and the improvement came from the code change alone.</p>

<h3 id="how-big-should-the-window-be">How big should the window be?</h3>

<p>I started with a window of 50, a number I pulled out of a hat. It turned out to be too small, because many note workflows finish in a few seconds.</p>

<p>Why so fast? Tasting notes vary wildly. One note is just “Yum!”, another is a detailed review with ingredients and a photo of the wrapper. In our pipeline, <code class="language-plaintext highlighter-rouge">tagFlavors</code> feeds two follow-up activities that run in parallel. For a note like “Yum!” it finds nothing to tag, so the workflow skips both follow-ups and ends almost immediately, freeing its slot in the window within seconds.</p>

<p>Little’s law shows how much the window size matters:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>throughput = items in flight / average time in flight
</code></pre></div></div>

<p>We doubled it to 100.</p>

<p>Read backwards, the numbers hang together. 200,000 notes in about 7 hours is roughly 8 notes per second. With 100 in flight, that means about 12 seconds per note on average, attachments included. As a rough upper-bound estimate, assuming the same average time in flight, a window of 50 would have taken around 14 hours, about as long as the unbounded version. In practice it would probably have been somewhat less, because a smaller window also means less load and shorter waits.</p>

<p>I also ran short experiments with windows of 120, 150 and 200, and none of them looked bad. 150 looked especially good when the workflows were very short, because many notes took the early exit described above. Short workflows put little load on the rest of the system, so more of them can be in flight before anything downstream saturates. Overall, 100 and 150 gave very similar results. The best value depends on the data anyway, so we stayed with the more conservative 100.</p>

<p>The catch is that time in flight isn’t constant. Make the window large enough and the workers, the shared activity queues and the database saturate, each note takes longer, and you drift back towards the freeze. The right size depends on what sits downstream. That’s why we made it an environment variable.</p>

<h2 id="dark-corners">Dark corners</h2>

<p>The window fixed the freeze. But an activity that orchestrates hundreds of thousands of workflows for hours has a few corners worth knowing about.</p>

<h3 id="rebuilding-the-window-after-a-retry">Rebuilding the window after a retry</h3>

<p>Our heartbeat stored only the index of the last started note. After a retry (a deploy, a crashed pod), the activity rebuilt the window as “the last N indices” and re-attached to those workflows.</p>

<p>That assumes the window always holds consecutive indices. It doesn’t. Take a window of 3. Note 0 is slow, while notes 1, 2, 3 and onwards finish quickly. By index 10 the window holds {0, 9, 10}. Rebuilt as [8, 9, 10], note 0 drops off the books:</p>

<ul>
  <li>the importer thinks it has a free slot, so for a while more than N notes run;</li>
  <li>nobody waits for note 0, so <code class="language-plaintext highlighter-rouge">drain()</code> can return, and the import can report success, while note 0 is still running. If it fails, nobody counts it.</li>
</ul>

<p>Nothing is lost, note 0 finishes on its own. We just stop watching it.</p>

<p>Storing the IDs of the in-flight workflows in the heartbeat fixes this, but with a window of 100 it felt clumsy. The alternative, which we never got around to implementing, is to ask Temporal. Every note workflow carries a search attribute with the archive ID, so on resume:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">running</span> <span class="o">=</span> <span class="nx">client</span><span class="p">.</span><span class="nx">workflow</span><span class="p">.</span><span class="nx">list</span><span class="p">({</span>
  <span class="na">query</span><span class="p">:</span> <span class="s2">`ArchiveId = "</span><span class="p">${</span><span class="nx">archiveId</span><span class="p">}</span><span class="s2">" AND WorkflowType = "tastingNoteWorkflow" AND ExecutionStatus = "Running"`</span><span class="p">,</span>
<span class="p">});</span>
<span class="k">for</span> <span class="k">await</span> <span class="p">(</span><span class="kd">const</span> <span class="nx">wf</span> <span class="k">of</span> <span class="nx">running</span><span class="p">)</span> <span class="p">{</span>
  <span class="nx">slots</span><span class="p">.</span><span class="nx">track</span><span class="p">(</span><span class="nx">client</span><span class="p">.</span><span class="nx">workflow</span><span class="p">.</span><span class="nx">getHandle</span><span class="p">(</span><span class="nx">wf</span><span class="p">.</span><span class="nx">workflowId</span><span class="p">));</span>
<span class="p">}</span>
<span class="c1">// then continue the main loop from lastStartedIndex + 1</span>
</code></pre></div></div>

<p>The query doesn’t care about indices. It returns 0, 9 and 10 because those are the ones still running. The heartbeat stays tiny, and if more than N are running after a crash, the main loop’s <code class="language-plaintext highlighter-rouge">acquire()</code> simply waits until enough of them finish.</p>

<p>Visibility is eventually consistent, so the query can be off in two ways. A workflow that has just finished but still shows as running is harmless: its result resolves immediately. A workflow started just before the crash may not be indexed yet. One way to make that safe enough in practice would be to also re-attach the last few indices.</p>

<p>One more thing about the resume index itself. <code class="language-plaintext highlighter-rouge">activityInfo().heartbeatDetails</code> returns the last heartbeat that reached the server, not the last one the code called. The SDK throttles heartbeats, so when a pod dies, the most recent details may never be sent. The resumed index can lag behind real progress, and a few notes get started again. Running ones are simply re-attached thanks to <code class="language-plaintext highlighter-rouge">USE_EXISTING</code>, and finished ones run once more. Note processing is idempotent, so this costs some extra work, not wrong data.</p>

<h3 id="timeouts-are-part-of-correctness">Timeouts are part of correctness</h3>

<p>The importer now lives as long as the whole import. Our options:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">proxyActivities</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">importActivities</span><span class="o">&gt;</span><span class="p">({</span>
  <span class="na">startToCloseTimeout</span><span class="p">:</span> <span class="dl">"</span><span class="s2">24 hours</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">heartbeatTimeout</span><span class="p">:</span> <span class="dl">"</span><span class="s2">20 minutes</span><span class="dl">"</span><span class="p">,</span>
  <span class="na">retry</span><span class="p">:</span> <span class="p">{</span> <span class="na">initialInterval</span><span class="p">:</span> <span class="dl">"</span><span class="s2">1 second</span><span class="dl">"</span><span class="p">,</span> <span class="na">maximumAttempts</span><span class="p">:</span> <span class="mi">3</span> <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">heartbeatTimeout</code> is what detects a dead worker. Without it, a crashed pod would be noticed only when the 24-hour <code class="language-plaintext highlighter-rouge">startToCloseTimeout</code> runs out. Twenty minutes is a comfortable margin over the 5-minute heartbeat timer.</li>
  <li>Every deploy during an import costs an attempt: the running attempt dies with the pod and the activity is retried. With <code class="language-plaintext highlighter-rouge">maximumAttempts: 3</code>, three deploys during one long import fail it, even though nothing was wrong with the data.</li>
  <li>Every retry repeats the preparation: reading the whole archive into memory and preparing documents, before resuming from the heartbeat. In practice even a large archive loaded quickly, so it never caused trouble. It’s still worth watching: if that phase ever ran longer than the heartbeat timeout without heartbeating, every attempt would die in the same place.</li>
</ul>

<h2 id="why-an-activity-and-not-a-workflow">Why an activity and not a workflow?</h2>

<p>Experienced Temporal users will ask why the window lives in an activity. The idiomatic alternative is a parent workflow that starts note workflows as children, keeps N of them running and calls continue-as-new from time to time to keep its history small.</p>

<p>That version survives worker restarts for free. It moves the window state from volatile process memory into Temporal’s durable state, so there is no heartbeat bookkeeping, no rebuilding after a retry and no 24-hour activity to babysit.</p>

<p>We kept the activity because it was the smallest change to code that already worked, and even that took some convincing. The price is everything in the previous section: the state of the window lives in memory, and correctness after a retry depends on how carefully you rebuild it. If I were designing this again, I’d seriously consider the workflow version.</p>

<h2 id="takeaways">Takeaways</h2>

<ol>
  <li><strong>A started workflow is not a finished one.</strong> <code class="language-plaintext highlighter-rouge">start()</code> only tells you Temporal has accepted the work, so batching starts throttles the client, not the system. If you want to control load, wait for <code class="language-plaintext highlighter-rouge">result()</code>.</li>
  <li><strong>New starts compete with the workflows already running.</strong> Throughput is capped anyway, but every new start adds work to the same system that is trying to finish the workflows already in flight. From the outside, that looks like a freeze.</li>
  <li><strong>A shared activity queue limits what separate workers can do.</strong> Work from every workflow type still meets on the shared queue.</li>
  <li><strong>Size the window with Little’s law, then watch downstream.</strong> Too small and you’re slow, too large and you’re back to the freeze.</li>
  <li><strong>Database tuning is a trade-off, not a fix.</strong> <code class="language-plaintext highlighter-rouge">synchronous_commit = off</code> helped a little, but it trades durability for speed and doesn’t touch the cause. Once the code was fixed, we turned it back on.</li>
  <li><strong>A long-running orchestrating activity makes timeouts and heartbeats part of correctness.</strong> They decide how fast a dead worker is noticed, how many deploys an import survives and where it resumes.</li>
</ol>

<h2 id="how-it-ended">How it ended</h2>

<p>With the window in place, imports stopped looking frozen and finished in about half the time.</p>

<p>Some things we didn’t change. The importer still reads the whole archive into memory. Each note workflow still receives the full note as its argument, so the data ends up in its history; passing an ID and loading the note in an activity would be lighter. And one workflow per note stayed. Moving away from it would have been a business decision, and not one I was in a position to make.</p>

<p>The practical lesson: when you hand work to Temporal, decide up front how much of it may be in flight at once.</p>]]></content><author><name>Jakub Milkiewicz</name></author><category term="temporal" /><category term="typescript" /><category term="postgresql" /><category term="backpressure" /><category term="scalability" /><summary type="html"><![CDATA[How starting one Temporal workflow per item made our cluster look frozen, why batching starts is not backpressure, and how a sliding window on handle.result() fixed it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jmilkiewicz.github.io/assets/images/og-temporal-accepted-is-not-done.png" /><media:content medium="image" url="https://jmilkiewicz.github.io/assets/images/og-temporal-accepted-is-not-done.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The compiler stopped guarding the pipe: how rewriting an activity in Python broke our Temporal contract</title><link href="https://jmilkiewicz.github.io/2026/10/temporal-pipe-guarding/" rel="alternate" type="text/html" title="The compiler stopped guarding the pipe: how rewriting an activity in Python broke our Temporal contract" /><published>2026-10-06T00:00:00+02:00</published><updated>2026-10-06T00:00:00+02:00</updated><id>https://jmilkiewicz.github.io/2026/10/temporal-pipe-guarding</id><content type="html" xml:base="https://jmilkiewicz.github.io/2026/10/temporal-pipe-guarding/"><![CDATA[<p>I spent nine months working with <a href="https://temporal.io/">Temporal</a> almost every day: designing workflows, debugging them in production and digging into how they work under the hood. That left me with plenty of stories worth writing down. This is the first post in a planned series (4-5 posts). I’m starting with a relatively simple topic, because it shows well how easily “working” code turns out to be fragile once two different languages sit on either end of the pipe.</p>

<p>The examples are simplified and anonymized (snacks instead of the real domain). You can run all of the code yourself, no Docker needed: <a href="https://github.com/jmilkiewicz/temporal-polyglot-playground">temporal-polyglot-playground</a>.</p>

<h2 id="act-i-everything-in-typescript">Act I: everything in TypeScript</h2>

<p>In the beginning it was simple. Workflow and activity were both written in TypeScript, and I was handed the interface between them “from above”. It just worked. The activity checked whether a given snack is sweet, salty or spicy, and returned this type:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">type</span> <span class="nx">SnackCheckResult</span> <span class="o">=</span> <span class="p">{</span>
  <span class="na">flavors</span><span class="p">:</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="nx">SnackFlavor</span><span class="p">,</span> <span class="nx">boolean</span><span class="o">&gt;</span> <span class="o">|</span> <span class="kc">null</span><span class="p">;</span>
  <span class="nl">caption</span><span class="p">:</span> <span class="kr">string</span> <span class="o">|</span> <span class="kc">null</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div></div>

<p>The workflow used it like this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">function</span> <span class="nx">workflowLogic</span><span class="p">(</span><span class="nx">snackCheckResult</span><span class="p">:</span> <span class="nx">SnackCheckResult</span><span class="p">)</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">flavors</span><span class="p">,</span> <span class="nx">caption</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">snackCheckResult</span><span class="p">;</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">flavors</span> <span class="o">!==</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span> <span class="na">isSweet</span><span class="p">:</span> <span class="nx">flavors</span><span class="p">.</span><span class="nx">SWEET</span><span class="p">,</span> <span class="nx">caption</span> <span class="p">};</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="p">{</span> <span class="na">isSweet</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">caption</span> <span class="p">};</span>
<span class="p">}</span>

<span class="k">export</span> <span class="k">async</span> <span class="kd">function</span> <span class="nx">snackWorkflow</span><span class="p">(</span><span class="nx">args</span><span class="p">:</span> <span class="nx">SnackCheckArgs</span><span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">SnackWorkflowResult</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">snackCheckResult</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">checkSnack</span><span class="p">(</span><span class="nx">args</span><span class="p">);</span>
  <span class="k">return</span> <span class="nx">workflowLogic</span><span class="p">(</span><span class="nx">snackCheckResult</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div></div>

<p>I had inherited this code and never paid attention to how exactly it worked. The realization came fairly quickly, though: <strong>the compiler was a validation layer that nobody was thinking about.</strong> On the activity side, TypeScript would not let you push anything into the pipe that wasn’t a valid <code class="language-plaintext highlighter-rouge">SnackCheckResult</code>. Temporal serialized it, the workflow deserialized it, and everyone was happy.</p>

<h2 id="act-ii-enter-python">Act II: enter Python</h2>

<p>Then came a natural need: the snack-detection logic would be better written by the ML people, and they work in Python. Part of the activities was rewritten. Not a single line of the workflow changed, and neither did the interface.</p>

<p>Except that the <code class="language-plaintext highlighter-rouge">SnackCheckResult</code> type stopped being a contract and became a <strong>wish</strong>. The Python side never sees it, the TypeScript compiler has no idea what actually arrives over the wire, and the type in the workflow code keeps reassuring everyone who reads it.</p>

<p>How easy is it to get this wrong? Plain Pydantic is enough (an illustrative example, not taken from the project):</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">SnackCheckResult</span><span class="p">(</span><span class="n">BaseModel</span><span class="p">):</span>
    <span class="n">flavors</span><span class="p">:</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">bool</span><span class="p">]</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="n">caption</span><span class="p">:</span> <span class="nb">str</span> <span class="o">|</span> <span class="bp">None</span> <span class="o">=</span> <span class="bp">None</span>

<span class="n">SnackCheckResult</span><span class="p">(</span><span class="n">caption</span><span class="o">=</span><span class="s">"a cookie"</span><span class="p">).</span><span class="n">model_dump</span><span class="p">(</span><span class="n">exclude_none</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="c1"># {'caption': 'a cookie'}   &lt;- the "flavors" key simply isn't there
</span></code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">exclude_none=True</code> is a common, reasonable habit on the Python side. From TypeScript’s point of view, though, it breaks the contract: a field that according to the type always exists (even if as <code class="language-plaintext highlighter-rouge">null</code>) has vanished.</p>

<p>We switched over to the Python activity and at first everything worked. Time to open the champagne.</p>

<h2 id="act-iii-a-test-that-had-nothing-to-do-with-the-topic">Act III: a test that had nothing to do with the topic</h2>

<p>The trouble surfaced during internal tests that were completely unrelated to this part of the system. The workflow started falling apart, and we had nothing configured that would have made it obvious why. The reason is that a plain <code class="language-plaintext highlighter-rouge">TypeError</code> thrown from workflow code <strong>does not fail the workflow</strong>. It fails the <em>workflow task</em>, which Temporal retries forever, so in practice the workflow just hangs.</p>

<p>Only in tests, after setting this:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">worker</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">Worker</span><span class="p">.</span><span class="nx">create</span><span class="p">({</span>
  <span class="na">connection</span><span class="p">:</span> <span class="nx">testEnv</span><span class="p">.</span><span class="nx">nativeConnection</span><span class="p">,</span>
  <span class="nx">taskQueue</span><span class="p">,</span>
  <span class="nx">workflowBundle</span><span class="p">,</span>
  <span class="na">activities</span><span class="p">:</span> <span class="p">{</span> <span class="nx">checkSnack</span> <span class="p">},</span>
  <span class="c1">// By default a plain Error thrown from workflow code fails the workflow *task*, and the</span>
  <span class="c1">// task is retried forever: the workflow just hangs. Promote TypeError to a workflow</span>
  <span class="c1">// failure so the test can observe the exception instead of timing out.</span>
  <span class="na">workflowFailureErrorTypes</span><span class="p">:</span> <span class="p">{</span> <span class="dl">"</span><span class="s2">*</span><span class="dl">"</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">TypeError</span><span class="dl">"</span><span class="p">]</span> <span class="p">},</span>
<span class="p">});</span>
</code></pre></div></div>

<p>were we able to see the actual exception: <code class="language-plaintext highlighter-rouge">TypeError: Cannot read properties of undefined (reading 'SWEET')</code>. That message, however, points at the place that detected the problem (the consumer), not the place that caused it (the producer).</p>

<p>Why did that line execute at all? Because the workflow guarded with <code class="language-plaintext highlighter-rouge">if (flavors !== null)</code>. Since TypeScript promised <code class="language-plaintext highlighter-rouge">flavors</code> was either a record or <code class="language-plaintext highlighter-rouge">null</code>, nobody guarded against <code class="language-plaintext highlighter-rouge">undefined</code>. But in JavaScript, <code class="language-plaintext highlighter-rouge">undefined !== null</code> evaluates to <code class="language-plaintext highlighter-rouge">true</code>, so execution walked right into <code class="language-plaintext highlighter-rouge">flavors.SWEET</code>.</p>

<p>To get to the truth, we had to open the Temporal UI and look at the activity’s output. It turned out that <strong>there was no <code class="language-plaintext highlighter-rouge">flavors</code> at all</strong> in that output. Python had simply omitted the field.</p>

<h2 id="act-iv-the-quick-fix">Act IV: the quick fix</h2>

<p>One of the developers decided he knew how to fix it quickly: add validation of the activity result with Zod. We already had Zod in the project, so a schema describing the same type was trivial (Zod 3, which was what we were on at the time):</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">flavors</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nx">record</span><span class="p">(</span><span class="nx">SnackFlavor</span><span class="p">,</span> <span class="nx">z</span><span class="p">.</span><span class="nx">boolean</span><span class="p">()).</span><span class="nx">nullable</span><span class="p">()</span>
</code></pre></div></div>

<p>It was a good idea, but I wanted to check whether the validation really catches everything Python might send. I asked for tests with payload variations: missing flavors, lowercase keys, unknown keys. And my hunch paid off, because one test failed.</p>

<p>To simulate Python without running Python, the tests register activities written in TypeScript that return exactly the payloads Python would produce, and I push them past the compiler with <code class="language-plaintext highlighter-rouge">as unknown as</code>. That’s an important detail: <strong>to simulate Python, I have to cheat the compiler.</strong> In a single-language world these bugs would be impossible.</p>

<p>Let’s take a realistic payload: the producer returns only the flavors it considered relevant.</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">missingSweet</span><span class="p">:</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span>
  <span class="p">({</span>
    <span class="na">flavors</span><span class="p">:</span> <span class="p">{</span> <span class="na">SPICY</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="na">SALTY</span><span class="p">:</span> <span class="kc">true</span> <span class="p">},</span>
    <span class="na">caption</span><span class="p">:</span> <span class="dl">"</span><span class="s2">dried fish</span><span class="dl">"</span><span class="p">,</span>
  <span class="p">})</span> <span class="k">as</span> <span class="nx">unknown</span> <span class="k">as</span> <span class="nx">SnackCheckResult</span>
</code></pre></div></div>

<p>Here are two tests that differ only in which Zod import path they use:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">it</span><span class="p">(</span><span class="dl">'</span><span class="s1">still loses isSweet on a partial record validated with "zod/v3"</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">partial</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">runSnackWorkflow</span><span class="p">(</span>
    <span class="nx">pythonCheckSnackVariants</span><span class="p">.</span><span class="nx">missingSweet</span><span class="p">,</span>
    <span class="nx">snackWorkflowWithValidationZodV3</span><span class="p">,</span>
  <span class="p">);</span>

  <span class="nx">expect</span><span class="p">(</span><span class="nx">partial</span><span class="p">).</span><span class="nx">not</span><span class="p">.</span><span class="nx">toHaveProperty</span><span class="p">(</span><span class="dl">"</span><span class="s2">isSweet</span><span class="dl">"</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">it</span><span class="p">(</span><span class="dl">'</span><span class="s1">rejects a partial record validated with "zod/v4"</span><span class="dl">'</span><span class="p">,</span> <span class="k">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">error</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">runSnackWorkflow</span><span class="p">(</span>
    <span class="nx">pythonCheckSnackVariants</span><span class="p">.</span><span class="nx">missingSweet</span><span class="p">,</span>
    <span class="nx">snackWorkflowWithValidationZodV4</span><span class="p">,</span>
  <span class="p">).</span><span class="k">catch</span><span class="p">((</span><span class="na">err</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">err</span><span class="p">);</span>

  <span class="nx">expect</span><span class="p">(</span><span class="nx">error</span><span class="p">).</span><span class="nx">toBeInstanceOf</span><span class="p">(</span><span class="nx">WorkflowFailedError</span><span class="p">);</span>
  <span class="kd">const</span> <span class="nx">cause</span> <span class="o">=</span> <span class="p">(</span><span class="nx">error</span> <span class="k">as</span> <span class="nx">WorkflowFailedError</span><span class="p">).</span><span class="nx">cause</span><span class="p">;</span>
  <span class="nx">expect</span><span class="p">(</span><span class="nx">cause</span><span class="p">).</span><span class="nx">toBeInstanceOf</span><span class="p">(</span><span class="nx">ApplicationFailure</span><span class="p">);</span>
  <span class="nx">expect</span><span class="p">(</span><span class="nx">cause</span><span class="p">).</span><span class="nx">toMatchObject</span><span class="p">({</span>
    <span class="na">type</span><span class="p">:</span> <span class="nx">SNACK_CHECK_VALIDATION_ERROR</span><span class="p">,</span>
    <span class="na">details</span><span class="p">:</span> <span class="p">[{</span> <span class="na">invalidFields</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">flavors</span><span class="dl">"</span><span class="p">]</span> <span class="p">}],</span>
  <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<p>Same schema, the very same line <code class="language-plaintext highlighter-rouge">z.record(SnackFlavor, z.boolean())</code>. In Zod 3 the validation <strong>passes</strong>, and <code class="language-plaintext highlighter-rouge">isSweet</code> still disappears. In Zod 4 the result is rejected with a readable error that points at the <code class="language-plaintext highlighter-rouge">flavors</code> field, that is, at the producer.</p>

<p>The reason: with enum keys, Zod 3 treats the record as <strong>partial</strong>, while Zod 4 treats it as <strong>exhaustive</strong>. So whether validation protects you from an incomplete record depends on the import path (<code class="language-plaintext highlighter-rouge">"zod"</code> versus <code class="language-plaintext highlighter-rouge">"zod/v4"</code>). The first test deliberately documents the gap (hence “still loses”). If someone closes it one day, the test will start failing.</p>

<p>By the way, this mismatch is already visible in the types themselves. In Zod 3, <code class="language-plaintext highlighter-rouge">z.infer</code> for such a schema gives <code class="language-plaintext highlighter-rouge">Partial&lt;Record&lt;SnackFlavor, boolean&gt;&gt;</code>, not the <code class="language-plaintext highlighter-rouge">Record&lt;SnackFlavor, boolean&gt;</code> our type promises. So Zod was telling us outright that some keys might not exist, and we went on validating a “contract” that doesn’t allow for that. We saw the difference, but decided it didn’t matter and brushed it off. Only a test on a realistic payload showed that this “detail” hides the loss of <code class="language-plaintext highlighter-rouge">isSweet</code>.</p>

<p>We had installed validation and felt safe, but we weren’t. <strong>A false sense of security is more dangerous than none at all.</strong></p>

<p>The worst part is that the naive version has three variants in which nothing visible happens:</p>

<table>
  <thead>
    <tr>
      <th>What “Python” sends</th>
      <th>Naive workflow</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{ caption: "a cookie" }</code> (no <code class="language-plaintext highlighter-rouge">flavors</code>)</td>
      <td><code class="language-plaintext highlighter-rouge">TypeError</code>, workflow hangs</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{ flavors: { SPICY: false, SALTY: true }, ... }</code> (incomplete record, no <code class="language-plaintext highlighter-rouge">SWEET</code>)</td>
      <td><strong>Silence.</strong> Result without <code class="language-plaintext highlighter-rouge">isSweet</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{ flavors: { sweet: true, ... } }</code> (lowercase keys)</td>
      <td><strong>Silence.</strong> The payload says “sweet”, the system detects nothing</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">{ flavors: { ..., UMAMI: true } }</code> (unknown key)</td>
      <td><strong>Silence.</strong> <code class="language-plaintext highlighter-rouge">isSweet: true</code>, the extra key goes unnoticed</td>
    </tr>
  </tbody>
</table>

<p>The missing <code class="language-plaintext highlighter-rouge">isSweet</code> happens because <code class="language-plaintext highlighter-rouge">undefined</code> is dropped during JSON serialization, so the client receives <code class="language-plaintext highlighter-rouge">{ caption: "dried fish" }</code> even though the type promises <code class="language-plaintext highlighter-rouge">isSweet: boolean</code>.</p>

<h2 id="act-v-the-question-that-should-have-come-earlier">Act V: the question that should have come earlier</h2>

<p>At this point I started wondering not about yet another patch, but about something different: <strong>why is this interface so complicated?</strong></p>

<p><code class="language-plaintext highlighter-rouge">Record&lt;SnackFlavor, boolean&gt;</code> makes sense as a method parameter in TypeScript. The compiler enforces completeness, and access by key is convenient. As a contract between languages, though, it is very brittle, because every language has its own idea of a missing field, <code class="language-plaintext highlighter-rouge">null</code>, an empty value and keys. It resembles a situation where, instead of a <em>contract first</em> approach (a contract described independently, like XML Schema in SOAP or JSON Schema in OpenAPI, and only then “translated” into each language), we do plain RPC on functions specific to one language and <strong>hope that the other side’s runtime will somehow adapt</strong>.</p>

<p>If the shape of the data itself allows “incomplete” states, you can either detect them or change the shape so that they don’t exist at all. I chose the latter:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">z</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">zod</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">SNACK_FLAVORS</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">../types</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">SnackFlavor</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="kr">enum</span><span class="p">(</span><span class="nx">SNACK_FLAVORS</span><span class="p">);</span>

<span class="k">export</span> <span class="kd">const</span> <span class="nx">SnackCheckResultV2</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="nx">object</span><span class="p">({</span>
  <span class="na">flavors</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="nx">array</span><span class="p">(</span><span class="nx">SnackFlavor</span><span class="p">).</span><span class="nx">nullable</span><span class="p">(),</span>
  <span class="na">caption</span><span class="p">:</span> <span class="nx">z</span><span class="p">.</span><span class="kr">string</span><span class="p">().</span><span class="nx">nullable</span><span class="p">(),</span>
<span class="p">});</span>
<span class="k">export</span> <span class="kd">type</span> <span class="nx">SnackCheckResultV2</span> <span class="o">=</span> <span class="nx">z</span><span class="p">.</span><span class="nx">infer</span><span class="o">&lt;</span><span class="k">typeof</span> <span class="nx">SnackCheckResultV2</span><span class="o">&gt;</span><span class="p">;</span>
</code></pre></div></div>

<p>The activity now returns <strong>the list of flavors it found</strong>. There is no such thing as an incomplete list: a flavor that isn’t on the list simply isn’t present. The workflow builds the full <code class="language-plaintext highlighter-rouge">Record&lt;SnackFlavor, boolean&gt;</code> locally from <code class="language-plaintext highlighter-rouge">SNACK_FLAVORS</code>.</p>

<p>The workflow itself also stops trusting what the activity promises:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="kd">function</span> <span class="nx">checkSnackAndValidate</span><span class="p">(</span>
  <span class="nx">args</span><span class="p">:</span> <span class="nx">SnackCheckArgs</span><span class="p">,</span>
  <span class="nx">schema</span><span class="p">:</span> <span class="nx">ActivityResultSchema</span><span class="o">&lt;</span><span class="nx">SnackCheckResultV2</span><span class="o">&gt;</span><span class="p">,</span>
<span class="p">):</span> <span class="nb">Promise</span><span class="o">&lt;</span><span class="nx">SnackWorkflowResult</span><span class="o">&gt;</span> <span class="p">{</span>
  <span class="c1">// The activity's declared return type is not trusted: the payload is validated at runtime.</span>
  <span class="kd">const</span> <span class="na">raw</span><span class="p">:</span> <span class="nx">unknown</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">checkSnack</span><span class="p">(</span><span class="nx">args</span><span class="p">);</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">flavors</span><span class="p">,</span> <span class="nx">caption</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">parseActivityResult</span><span class="p">(</span><span class="nx">raw</span><span class="p">,</span> <span class="nx">schema</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="nx">flavors</span> <span class="o">===</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">return</span> <span class="p">{</span> <span class="na">isSweet</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nx">caption</span> <span class="p">};</span>
  <span class="p">}</span>

  <span class="kd">const</span> <span class="nx">flavorRecord</span> <span class="o">=</span> <span class="nb">Object</span><span class="p">.</span><span class="nx">fromEntries</span><span class="p">(</span>
    <span class="nx">SNACK_FLAVORS</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">flavor</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">[</span><span class="nx">flavor</span><span class="p">,</span> <span class="nx">flavors</span><span class="p">.</span><span class="nx">includes</span><span class="p">(</span><span class="nx">flavor</span><span class="p">)]),</span>
  <span class="p">)</span> <span class="k">as</span> <span class="nb">Record</span><span class="o">&lt;</span><span class="nx">SnackFlavor</span><span class="p">,</span> <span class="nx">boolean</span><span class="o">&gt;</span><span class="p">;</span>

  <span class="k">return</span> <span class="p">{</span> <span class="na">isSweet</span><span class="p">:</span> <span class="nx">flavorRecord</span><span class="p">.</span><span class="nx">SWEET</span><span class="p">,</span> <span class="nx">caption</span> <span class="p">};</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">const raw: unknown</code> line expresses the whole lesson of this post: <em>a TypeScript type is not proof, it’s a declaration</em>. The compiler won’t let you use <code class="language-plaintext highlighter-rouge">raw</code> without going through validation. The record is rebuilt so that the rest of the workflow keeps working on the full type, as before.</p>

<p>The results are identical in Zod 3 and Zod 4:</p>

<table>
  <thead>
    <tr>
      <th>Activity</th>
      <th>Payload</th>
      <th>Result</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">sweetCandy</code></td>
      <td><code class="language-plaintext highlighter-rouge">{ flavors: ["SWEET"], ... }</code></td>
      <td><code class="language-plaintext highlighter-rouge">isSweet: true</code></td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">spicyOnly</code></td>
      <td><code class="language-plaintext highlighter-rouge">{ flavors: ["SPICY"], ... }</code></td>
      <td><code class="language-plaintext highlighter-rouge">isSweet: false</code> (correct)</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">lowercaseFlavor</code></td>
      <td><code class="language-plaintext highlighter-rouge">{ flavors: ["sweet"], ... }</code></td>
      <td>rejected</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">unknownFlavor</code></td>
      <td><code class="language-plaintext highlighter-rouge">{ flavors: ["SWEET", "UMAMI"], ... }</code></td>
      <td>rejected</td>
    </tr>
  </tbody>
</table>

<p>Note <code class="language-plaintext highlighter-rouge">spicyOnly</code>. It is the counterpart of the incomplete record that previously led to a silent failure. Now it’s a perfectly valid payload with the correct answer. Lowercase and unknown flavors are caught because the <em>elements</em> of the array are validated, and the Zod import path no longer matters.</p>

<p>Responsibility has shifted too. Before, the producer had to remember to be complete. Now completeness follows from the shape of the data, and the consumer fills in the rest.</p>

<h2 id="how-to-report-a-validation-error">How to report a validation error</h2>

<p>All of the error handling fits in a single function:</p>

<div class="language-ts highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">export</span> <span class="kd">function</span> <span class="nx">parseActivityResult</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">(</span><span class="nx">raw</span><span class="p">:</span> <span class="nx">unknown</span><span class="p">,</span> <span class="nx">schema</span><span class="p">:</span> <span class="nx">ActivityResultSchema</span><span class="o">&lt;</span><span class="nx">T</span><span class="o">&gt;</span><span class="p">):</span> <span class="nx">T</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">parsed</span> <span class="o">=</span> <span class="nx">schema</span><span class="p">.</span><span class="nx">safeParse</span><span class="p">(</span><span class="nx">raw</span><span class="p">);</span>
  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">parsed</span><span class="p">.</span><span class="nx">success</span><span class="p">)</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">fields</span> <span class="o">=</span> <span class="p">[</span>
      <span class="p">...</span><span class="k">new</span> <span class="nb">Set</span><span class="p">(</span><span class="nx">parsed</span><span class="p">.</span><span class="nx">error</span><span class="p">.</span><span class="nx">issues</span><span class="p">.</span><span class="nx">map</span><span class="p">((</span><span class="nx">issue</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nb">String</span><span class="p">(</span><span class="nx">issue</span><span class="p">.</span><span class="nx">path</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">??</span> <span class="dl">"</span><span class="s2">&lt;root&gt;</span><span class="dl">"</span><span class="p">))),</span>
    <span class="p">];</span>
    <span class="c1">// A plain ZodError would only fail the workflow task and retry it forever. A non-retryable</span>
    <span class="c1">// ApplicationFailure fails the workflow execution loudly instead.</span>
    <span class="k">throw</span> <span class="nx">ApplicationFailure</span><span class="p">.</span><span class="nx">nonRetryable</span><span class="p">(</span>
      <span class="s2">`invalid activity result: </span><span class="p">${</span><span class="nx">fields</span><span class="p">.</span><span class="nx">join</span><span class="p">(</span><span class="dl">"</span><span class="s2">, </span><span class="dl">"</span><span class="p">)}</span><span class="s2">`</span><span class="p">,</span>
      <span class="nx">SNACK_CHECK_VALIDATION_ERROR</span><span class="p">,</span>
      <span class="p">{</span> <span class="na">invalidFields</span><span class="p">:</span> <span class="nx">fields</span> <span class="p">},</span>
    <span class="p">);</span>
  <span class="p">}</span>
  <span class="k">return</span> <span class="nx">parsed</span><span class="p">.</span><span class="nx">data</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two decisions:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">ApplicationFailure.nonRetryable</code> instead of a plain <code class="language-plaintext highlighter-rouge">ZodError</code>.</strong> A plain exception would again hang the workflow task in a loop, repeating the <code class="language-plaintext highlighter-rouge">TypeError</code> problem. Here the workflow ends with a clear, named error you can set an alert on. Retrying makes no sense, because with a broken contract every attempt returns the same thing.</li>
  <li><strong>We report only the name of the top-level field.</strong> No values and no Zod messages, which may quote the received data. <code class="language-plaintext highlighter-rouge">ApplicationFailure</code> details end up in the workflow history and are visible in the UI, so real data shouldn’t leak there. And the answer to “which property failed” is exactly what someone with an alert in hand needs.</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">ActivityResultSchema&lt;T&gt;</code> interface describes only <code class="language-plaintext highlighter-rouge">safeParse</code>, the part common to Zod 3 and 4. That’s how one function handles both schemas.</p>

<h3 id="a-note-on-zod-and-workflow-determinism">A note on Zod and workflow determinism</h3>

<p>Any code executed inside a Temporal workflow is subject to the golden rule of Temporal: <strong>determinism during replay</strong>.</p>

<p>Running Zod inside workflow logic is entirely safe because validating structural types (objects, arrays, strings, enums) is a pure computation. However, you need to be cautious with Zod features that can introduce non-determinism:</p>
<ul>
  <li>Defaults or transforms that evaluate time or randomness (e.g., <code class="language-plaintext highlighter-rouge">new Date()</code>, <code class="language-plaintext highlighter-rouge">Date.now()</code>, or <code class="language-plaintext highlighter-rouge">crypto.randomUUID()</code>).</li>
  <li>Refinements (<code class="language-plaintext highlighter-rouge">.refine()</code>) or transforms (<code class="language-plaintext highlighter-rouge">.transform()</code>) that access external state, globals, or environments.</li>
</ul>

<p>As long as schemas are pure validators over the raw payload, workflow replays will produce identical results every time.</p>

<h3 id="why-validate-at-the-call-site-instead-of-an-interceptor">Why validate at the call site instead of an interceptor?</h3>

<p>A fair question for experienced Temporal users: why call <code class="language-plaintext highlighter-rouge">parseActivityResult</code> manually instead of moving validation into a workflow interceptor or a custom data converter?</p>

<p>You certainly can automate this via interceptors once schemas are mapped to activity types across the board. Doing it explicitly at the call site, however, kept the boundary front and center while we were redesigning the contract, made testing isolated workflows trivial, and served as a visible reminder that activity returns are untrusted wire data.</p>

<h2 id="takeaways">Takeaways</h2>

<ol>
  <li><strong>TypeScript types don’t cross the language boundary.</strong> As long as both ends of the pipe are in one language, the compiler does a job you don’t know about. Add a second language and that job disappears without warning.</li>
  <li><strong>Runtime validation at the boundary is necessary, but its mere presence proves nothing.</strong> Zod 3 and Zod 4 behave differently for the same schema. Write tests that check that validation rejects what it should reject, not only that it accepts what it should accept.</li>
  <li><strong>The most dangerous errors are silent.</strong> A hanging workflow raises an alarm. A wrong <code class="language-plaintext highlighter-rouge">isSweet</code> doesn’t.</li>
  <li><strong>The error has to point at the producer.</strong> The <code class="language-plaintext highlighter-rouge">TypeError</code> showed where we crashed. A named field in <code class="language-plaintext highlighter-rouge">ApplicationFailure</code> shows who broke the data.</li>
  <li><strong>Best of all, change the contract so the invalid state can’t be expressed.</strong> An array instead of a record eliminates a whole class of errors instead of detecting them.</li>
  <li><strong>For cross-language communication, think contract first.</strong> The shape of the data should be simple enough for every language to produce and check without guessing what the other runtime will do.</li>
</ol>

<h2 id="how-it-ended">How it ended</h2>

<p>The Python team appreciated the help with changing the contract, and the migration went smoothly. It wasn’t a one-step move, though, but the classic three-step approach: add a new version of the activity next to the old one, switch the workflow to call the new one, and finally remove the old one.</p>

<p>The practical lesson for the future: contracts at language boundaries should be built contract first, on supported standards (JSON Schema, Protobuf or OpenAPI), from which every language generates its own code. That code doesn’t have to be pretty or optimal from a purist’s point of view, because it lives at the edge of the system (in the delivery mechanism), not in the domain logic. Zod at that boundary is a cheaper defense, but not a replacement for a shared schema.</p>

<p>All the code and tests: <a href="https://github.com/jmilkiewicz/temporal-polyglot-playground">github.com/jmilkiewicz/temporal-polyglot-playground</a>. Run them with <code class="language-plaintext highlighter-rouge">npm install &amp;&amp; npm test</code>.</p>]]></content><author><name>Jakub Milkiewicz</name></author><category term="temporal" /><category term="typescript" /><category term="python" /><category term="zod" /><category term="contract-first" /><summary type="html"><![CDATA[How rewriting a Temporal activity from TypeScript to Python silently broke the contract, why Zod 3 and Zod 4 validate the same schema differently, and how changing the data shape fixed it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://jmilkiewicz.github.io/assets/images/og-temporal-pipe-guarding.png" /><media:content medium="image" url="https://jmilkiewicz.github.io/assets/images/og-temporal-pipe-guarding.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>