<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Temporal on vishctl</title><link>https://vishctl.dev/tags/temporal/</link><description>Recent content in Temporal on vishctl</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Mon, 05 Oct 2026 02:30:00 +0800</lastBuildDate><atom:link href="https://vishctl.dev/tags/temporal/index.xml" rel="self" type="application/rss+xml"/><item><title>Temporal Hands-On: Durable Execution on Kubernetes</title><link>https://vishctl.dev/posts/temporal-python-kubernetes-hands-on/</link><pubDate>Mon, 05 Oct 2026 02:30:00 +0800</pubDate><guid>https://vishctl.dev/posts/temporal-python-kubernetes-hands-on/</guid><category>temporal</category><category>python</category><category>kubernetes</category><category>minikube</category><category>colima</category><category>workflows</category><category>durable-execution</category><description>A hands-on Temporal demo in Python: build a small order workflow, deploy it on Kubernetes, then delete the worker pods mid-execution and watch durable execution resume it.</description><content:encoded><![CDATA[<p><a href="https://temporal.io/">Temporal</a> lets you write a long-running process as plain Python, and then guarantees it <strong>runs to completion</strong>, even if the process executing it is killed halfway.</p>
<p>That guarantee is called <strong>durable execution</strong>. In this post I&rsquo;ll build a tiny app, deploy it on Kubernetes, and then do the rude thing: delete the worker pods in the middle of a running workflow.</p>
<p><img alt="Animated diagram: a workflow&rsquo;s worker pod crashes mid-activity, and a new worker resumes it from the saved history" loading="lazy" src="https://vishctl.dev/images/posts/temporal-python-k8s/durable-execution.svg"></p>
<hr>
<h2 id="why-durable-execution-matters">Why Durable Execution Matters</h2>
<p>Without it, a multi-step process (charge a card, reserve stock, wait for approval, send an email) needs a pile of glue: a database for state, a queue, retry loops, cron jobs, and recovery code for every crash. With Temporal:</p>
<ul>
<li><strong>State survives crashes.</strong> Locals and progress live in the server&rsquo;s event history, not in your process.</li>
<li><strong>Retries are built in.</strong> Failed steps retry with a policy you declare. No hand-rolled loops.</li>
<li><strong>Waiting is free.</strong> A workflow can sleep or wait for a signal for days with zero resources held.</li>
<li><strong>Everything is visible.</strong> Every step is a recorded event you can inspect in the UI.</li>
</ul>
<h2 id="the-demo">The Demo</h2>
<p>An order workflow. Two steps misbehave on purpose: <code>send_email</code> fails twice before succeeding, and <code>pack_items</code> is slow, so we have time to kill it.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">charge_card -&gt; reserve_stock -&gt; pack_items (slow) -&gt; wait for approve/cancel -&gt; send_email
</span></span><span class="line"><span class="cl">                                                                    └─ cancel -&gt; refund_card
</span></span></code></pre></div><p><img alt="Animated architecture diagram showing client, Temporal server, worker pods, Postgres and Web UI" loading="lazy" src="https://vishctl.dev/images/posts/temporal-python-k8s/architecture.svg"></p>
<p>The server owns state. Workers are stateless Python processes that long-poll for work, which is why they can be killed and replaced freely.</p>
<hr>
<h2 id="the-code">The Code</h2>
<p>Two ideas: a <strong>workflow</strong> (the orchestration, which must be deterministic) and <strong>activities</strong> (the side-effecting steps).</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># app/workflows.py</span>
</span></span><span class="line"><span class="cl"><span class="nd">@workflow.defn</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">OrderWorkflow</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="nd">@workflow.signal</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">approve</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span> <span class="bp">self</span><span class="o">.</span><span class="n">approved</span> <span class="o">=</span> <span class="kc">True</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nd">@workflow.signal</span>
</span></span><span class="line"><span class="cl">    <span class="k">def</span> <span class="nf">cancel</span><span class="p">(</span><span class="bp">self</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kc">None</span><span class="p">:</span> <span class="bp">self</span><span class="o">.</span><span class="n">approved</span> <span class="o">=</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nd">@workflow.run</span>
</span></span><span class="line"><span class="cl">    <span class="k">async</span> <span class="k">def</span> <span class="nf">run</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">order_id</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></span><span class="line"><span class="cl">        <span class="bp">self</span><span class="o">.</span><span class="n">approved</span> <span class="o">=</span> <span class="kc">None</span>
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">workflow</span><span class="o">.</span><span class="n">execute_activity</span><span class="p">(</span><span class="n">charge_card</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span> <span class="o">**</span><span class="n">OPTS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">workflow</span><span class="o">.</span><span class="n">execute_activity</span><span class="p">(</span><span class="n">reserve_stock</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span> <span class="o">**</span><span class="n">OPTS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">workflow</span><span class="o">.</span><span class="n">execute_activity</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">pack_items</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">start_to_close_timeout</span><span class="o">=</span><span class="n">timedelta</span><span class="p">(</span><span class="n">minutes</span><span class="o">=</span><span class="mi">2</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">            <span class="n">heartbeat_timeout</span><span class="o">=</span><span class="n">timedelta</span><span class="p">(</span><span class="n">seconds</span><span class="o">=</span><span class="mi">5</span><span class="p">),</span>   <span class="c1"># detect a dead worker fast</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">workflow</span><span class="o">.</span><span class="n">wait_condition</span><span class="p">(</span><span class="k">lambda</span><span class="p">:</span> <span class="bp">self</span><span class="o">.</span><span class="n">approved</span> <span class="ow">is</span> <span class="ow">not</span> <span class="kc">None</span><span class="p">)</span>  <span class="c1"># can wait for days</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="ow">not</span> <span class="bp">self</span><span class="o">.</span><span class="n">approved</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">            <span class="k">await</span> <span class="n">workflow</span><span class="o">.</span><span class="n">execute_activity</span><span class="p">(</span><span class="n">refund_card</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span> <span class="o">**</span><span class="n">OPTS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">            <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">order_id</span><span class="si">}</span><span class="s2">: cancelled and refunded&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">workflow</span><span class="o">.</span><span class="n">execute_activity</span><span class="p">(</span><span class="n">send_email</span><span class="p">,</span> <span class="n">order_id</span><span class="p">,</span> <span class="o">**</span><span class="n">OPTS</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">order_id</span><span class="si">}</span><span class="s2">: completed&#34;</span>
</span></span></code></pre></div><p>The slow activity reports progress with heartbeats. If its worker dies, the retry reads the last heartbeat and <strong>resumes instead of restarting</strong>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># app/activities.py</span>
</span></span><span class="line"><span class="cl"><span class="nd">@activity.defn</span>
</span></span><span class="line"><span class="cl"><span class="k">async</span> <span class="k">def</span> <span class="nf">pack_items</span><span class="p">(</span><span class="n">order_id</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></span><span class="line"><span class="cl">    <span class="n">done</span> <span class="o">=</span> <span class="n">activity</span><span class="o">.</span><span class="n">info</span><span class="p">()</span><span class="o">.</span><span class="n">heartbeat_details</span>          <span class="c1"># progress saved by the previous attempt</span>
</span></span><span class="line"><span class="cl">    <span class="n">start</span> <span class="o">=</span> <span class="n">done</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">+</span> <span class="mi">1</span> <span class="k">if</span> <span class="n">done</span> <span class="k">else</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">step</span> <span class="ow">in</span> <span class="nb">range</span><span class="p">(</span><span class="n">start</span><span class="p">,</span> <span class="mi">11</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="n">asyncio</span><span class="o">.</span><span class="n">sleep</span><span class="p">(</span><span class="mi">2</span><span class="p">)</span>                        <span class="c1"># pretend to pack</span>
</span></span><span class="line"><span class="cl">        <span class="n">activity</span><span class="o">.</span><span class="n">heartbeat</span><span class="p">(</span><span class="n">step</span><span class="p">)</span>                      <span class="c1"># save progress to Temporal</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="sa">f</span><span class="s2">&#34;packed:</span><span class="si">{</span><span class="n">order_id</span><span class="si">}</span><span class="s2">(resumed at step </span><span class="si">{</span><span class="n">start</span><span class="si">}</span><span class="s2">)&#34;</span>
</span></span></code></pre></div><p>The worker just registers them and polls a queue:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># app/worker.py</span>
</span></span><span class="line"><span class="cl"><span class="n">client</span> <span class="o">=</span> <span class="k">await</span> <span class="n">Client</span><span class="o">.</span><span class="n">connect</span><span class="p">(</span><span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;TEMPORAL_ADDRESS&#34;</span><span class="p">,</span> <span class="s2">&#34;localhost:7233&#34;</span><span class="p">))</span>
</span></span><span class="line"><span class="cl"><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="s2">&#34;orders&#34;</span><span class="p">,</span> <span class="n">workflows</span><span class="o">=</span><span class="p">[</span><span class="n">OrderWorkflow</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">                <span class="n">activities</span><span class="o">=</span><span class="p">[</span><span class="n">charge_card</span><span class="p">,</span> <span class="n">reserve_stock</span><span class="p">,</span> <span class="n">pack_items</span><span class="p">,</span> <span class="n">send_email</span><span class="p">,</span> <span class="n">refund_card</span><span class="p">])</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="n">worker</span><span class="o">.</span><span class="n">run</span><span class="p">()</span>
</span></span></code></pre></div><p>All files: <a href="https://vishctl.dev/downloads/temporal-python-k8s/app/workflows.py">workflows.py</a>, <a href="https://vishctl.dev/downloads/temporal-python-k8s/app/activities.py">activities.py</a>, <a href="https://vishctl.dev/downloads/temporal-python-k8s/app/worker.py">worker.py</a>, <a href="https://vishctl.dev/downloads/temporal-python-k8s/Dockerfile">Dockerfile</a>, <a href="https://vishctl.dev/downloads/temporal-python-k8s/k8s/worker.yaml">worker.yaml</a>.</p>
<hr>
<h2 id="try-it-locally-first">Try It Locally First</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">brew install temporal uv
</span></span><span class="line"><span class="cl">temporal server start-dev                 <span class="c1"># server + Web UI on :8233</span>
</span></span><span class="line"><span class="cl">uv run python -m app.worker               <span class="c1"># in another terminal</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">temporal workflow start --type OrderWorkflow --task-queue orders <span class="se">\
</span></span></span><span class="line"><span class="cl">  --workflow-id order-1 --input <span class="s1">&#39;&#34;1&#34;&#39;</span>
</span></span><span class="line"><span class="cl">temporal workflow signal --workflow-id order-1 --name approve
</span></span></code></pre></div><p>Open <code>http://localhost:8233</code> and you&rsquo;ll see the same UI as below. Now let&rsquo;s put it on Kubernetes.</p>
<hr>
<h2 id="deploy-on-kubernetes">Deploy on Kubernetes</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">brew install colima docker minikube kubectl helm
</span></span><span class="line"><span class="cl">colima start --vm-type vz --cpu <span class="m">4</span> --memory <span class="m">8</span> --disk <span class="m">40</span>
</span></span><span class="line"><span class="cl">minikube start --driver<span class="o">=</span>docker --cpus<span class="o">=</span><span class="m">4</span> --memory<span class="o">=</span>6g
</span></span></code></pre></div><p><strong>1. Postgres + Temporal</strong> (the Helm chart no longer bundles a database, so a tiny Postgres goes in first; see <a href="https://vishctl.dev/downloads/temporal-python-k8s/k8s/postgres.yaml">postgres.yaml</a> and <a href="https://vishctl.dev/downloads/temporal-python-k8s/k8s/temporal-values.yaml">temporal-values.yaml</a>):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl apply -f k8s/postgres.yaml
</span></span><span class="line"><span class="cl">helm repo add temporal https://go.temporal.io/helm-charts
</span></span><span class="line"><span class="cl">helm install temporal temporal/temporal -n temporal -f k8s/temporal-values.yaml --timeout 10m
</span></span><span class="line"><span class="cl">kubectl -n temporal <span class="nb">wait</span> --for<span class="o">=</span><span class="nv">condition</span><span class="o">=</span>available deploy --all --timeout<span class="o">=</span>300s
</span></span></code></pre></div><p><strong>2. Build and deploy the worker</strong> (built inside Minikube&rsquo;s Docker, so no registry):</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">eval</span> <span class="k">$(</span>minikube docker-env<span class="k">)</span>
</span></span><span class="line"><span class="cl">docker build -t order-worker:v2 .
</span></span><span class="line"><span class="cl">kubectl apply -f k8s/worker.yaml          <span class="c1"># 2 replicas</span>
</span></span></code></pre></div><p><strong>3. Reach it from your laptop:</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">kubectl -n temporal port-forward svc/temporal-frontend 7233:7233 <span class="p">&amp;</span>
</span></span><span class="line"><span class="cl">kubectl -n temporal port-forward svc/temporal-web 8080:8080 <span class="p">&amp;</span>
</span></span></code></pre></div><p>You now have Temporal&rsquo;s services, your two worker pods and the UI at <code>http://localhost:8080</code>:</p>
<p><img alt="Temporal Web UI on Kubernetes listing several order workflows" loading="lazy" src="https://vishctl.dev/images/posts/temporal-python-k8s/k8s-list.png"></p>
<hr>
<h2 id="the-crash-test">The Crash Test</h2>
<p>Start an order, and while <code>pack_items</code> is mid-run, delete <strong>every worker pod</strong>:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">temporal workflow start --type OrderWorkflow --task-queue orders <span class="se">\
</span></span></span><span class="line"><span class="cl">  --workflow-id order-3003 --input <span class="s1">&#39;&#34;3003&#34;&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">kubectl -n temporal delete pod -l <span class="nv">app</span><span class="o">=</span>order-worker --grace-period<span class="o">=</span><span class="m">0</span> --force
</span></span></code></pre></div><p>Here it is live, captured from the Web UI:</p>
<figure>
  <video controls autoplay loop muted playsinline preload="metadata" style="width:100%;border-radius:8px">
    <source src="https://vishctl.dev/images/posts/temporal-python-k8s/crash-demo.webm" type="video/webm">
  </video>
  <figcaption style="text-align:center;font-size:0.9em;opacity:0.8">Both worker pods killed mid-activity; the workflow carries on with brand-new pods.</figcaption>
</figure>

<p>Then approve it:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">temporal workflow signal --workflow-id order-3003 --name approve
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">3003: completed
</span></span></code></pre></div><h3 id="what-actually-happened">What actually happened</h3>
<p>The timeline shows <code>pack_items</code> marked <strong>2</strong> (second attempt) and <code>send_email</code> marked <strong>3</strong>, the failures that Temporal retried for us:</p>
<p><img alt="Timeline of order-3003: pack_items retried after the crash, send_email retried twice, approve signal received" loading="lazy" src="https://vishctl.dev/images/posts/temporal-python-k8s/crash-timeline.png"></p>
<p>And the event history has the receipts: the second attempt ran on a <strong>different pod</strong>, and it <strong>resumed at step 3</strong> from the saved heartbeat, not step 1:</p>
<p><img alt="Event history of order-3003 showing the pack_items result &ldquo;resumed at step 3&rdquo; and a different worker pod identity" loading="lazy" src="https://vishctl.dev/images/posts/temporal-python-k8s/crash-history.png"></p>
<p>The sequence:</p>
<ol>
<li>Worker A dies mid-<code>pack_items</code>. It stops heartbeating.</li>
<li>After the 5s heartbeat timeout, the server schedules the activity again.</li>
<li>Worker B (a new pod) picks it up and reads the last heartbeat from the server.</li>
<li>It resumes at step 3, finishes, and the workflow moves on to wait for the approval signal.</li>
</ol>
<p>Nothing in the app handled any of this: no recovery code, no state table, no queue. We wrote an ordinary <code>for</code> loop.</p>
<hr>
<h2 id="when-to-reach-for-temporal">When to Reach for Temporal</h2>
<ul>
<li>Multi-step business processes: payments, onboarding, order fulfilment, sagas with compensation (<code>refund_card</code> above)</li>
<li>Anything that waits on humans or timers: approvals, reminders, trial expirations</li>
<li>Long-running jobs and pipelines where &ldquo;start over&rdquo; is expensive</li>
<li>Replacing cron-plus-database-plus-queue glue code</li>
</ul>
<p>Skip it for simple request/response calls or a one-shot background task that is cheap to rerun.</p>
<h2 id="caveats">Caveats</h2>
<ul>
<li>This setup is for learning. Production needs a real database with backups, more replicas and shards, TLS and auth on the UI. The Postgres here uses <code>emptyDir</code>, so deleting its pod wipes all history.</li>
<li>Workflow code must be deterministic (no random numbers, clock reads or I/O); put those in activities.</li>
<li>To skip hosting the server, <a href="https://temporal.io/cloud">Temporal Cloud</a> runs it for you and you only run workers.</li>
</ul>
<h2 id="cleanup">Cleanup</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">pkill -f <span class="s2">&#34;kubectl.*port-forward&#34;</span>
</span></span><span class="line"><span class="cl">minikube delete <span class="o">&amp;&amp;</span> colima stop
</span></span></code></pre></div><h2 id="cheat-sheet">Cheat sheet</h2>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Durable execution = the workflow runs to completion even if its process is killed
</span></span><span class="line"><span class="cl">Workflow          = the orchestration code; must be deterministic
</span></span><span class="line"><span class="cl">Activity          = a side-effecting step (charge, email, pack); retried by policy
</span></span><span class="line"><span class="cl">Worker            = a stateless process that long-polls a task queue and runs your code
</span></span><span class="line"><span class="cl">Event history     = the server-side record of every step; this is where state lives
</span></span><span class="line"><span class="cl">Heartbeat         = progress an activity reports, so a retry resumes instead of restarting
</span></span><span class="line"><span class="cl">Heartbeat timeout = how long the server waits before rescheduling a silent activity
</span></span><span class="line"><span class="cl">Signal            = an outside message a workflow can wait on, such as an approval
</span></span><span class="line"><span class="cl">Determinism rule  = no random numbers, clock reads, or I/O in workflow code
</span></span><span class="line"><span class="cl">Good fit          = multi-step processes, human approvals, long jobs, cron-and-queue glue
</span></span><span class="line"><span class="cl">Poor fit          = simple request/response calls or cheap one-shot tasks
</span></span></code></pre></div><p><strong>Related posts:</strong> more hands-on Kubernetes labs in <a href="https://vishctl.dev/posts/karmada-kubernetes-multi-cluster/">What Is Karmada?</a> and the <a href="https://vishctl.dev/posts/simulate-gpu-vllm-llm-d-on-laptop/">laptop GPU simulation lab</a>.</p>
]]></content:encoded></item></channel></rss>