<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Backend Development on Shane&apos;s Personal Blog</title><description>Recent content in Backend Development on Shane&apos;s Personal Blog</description><link>https://shanechang.com/categories/backend-development/</link><language>en-us</language><lastBuildDate>Mon, 09 Mar 2026 00:00:00 GMT</lastBuildDate><atom:link href="https://shanechang.com/categories/backend-development/index.xml" rel="self" type="application/rss+xml"/><item><title>Temporal Time-Skipping: The Clock You Didn&apos;t Know Existed</title><link>https://shanechang.com/p/temporal-time-skipping-the-clock-you-didnt-know-existed/</link><guid isPermaLink="true">https://shanechang.com/p/temporal-time-skipping-the-clock-you-didnt-know-existed/</guid><description>&lt;img src=&quot;https://shanechang.com/_astro/cover.D09JL2C2_Z1BbfYs.webp&quot; alt=&quot;Featured image of post Temporal Time-Skipping: The Clock You Didn&apos;t Know Existed&quot; /&gt;&lt;p&gt;I spent hours debugging a test that worked perfectly in production but exploded in the test environment. The workflow ran fine. The activities completed. The signals were correct. But every single test failed with a &lt;code&gt;TimeoutError&lt;/code&gt; — the workflow would just… die, before my test code even had a chance to interact with it.&lt;/p&gt;
&lt;p&gt;The culprit was a clock I didn’t know existed.&lt;/p&gt;
&lt;p&gt;This post is about the mental model that finally made Temporal’s time-skipping test server make sense to me. If you’ve ever been confused by &lt;code&gt;WorkflowEnvironment.start_time_skipping()&lt;/code&gt;, or had tests fail mysteriously with timeouts that don’t happen in production, this is for you.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;what-is-temporal-the-30-second-version&quot;&gt;What is Temporal? (The 30-Second Version)&lt;/h2&gt;
&lt;p&gt;Temporal is a workflow orchestration engine. You define your business logic as a “workflow” — a sequence of steps — and Temporal takes care of running it reliably. If a step fails, Temporal retries it. If your server crashes, Temporal picks up where it left off. It handles all the ugly stuff: retries, timeouts, state persistence, distributed coordination.&lt;/p&gt;
&lt;p&gt;The important thing for this post: Temporal runs your workflow inside its own runtime environment. Your workflow code doesn’t just execute like a normal Python function. It runs &lt;em&gt;inside Temporal&lt;/em&gt;, and Temporal manages when things happen.&lt;/p&gt;
&lt;p&gt;That distinction — “runs inside Temporal” — is the root of everything that follows.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;temporal-is-a-timekeeper&quot;&gt;Temporal is a Timekeeper&lt;/h2&gt;
&lt;p&gt;Here’s the insight that changed everything for me: &lt;strong&gt;Temporal doesn’t skip your code. It only controls its own clock.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I had been thinking about Temporal as some kind of execution engine that speeds up my code. It’s not. Temporal is a &lt;strong&gt;timekeeper&lt;/strong&gt;. Think of it like a kernel — a central dispatcher that keeps a timetable of scheduled events.&lt;/p&gt;
&lt;p&gt;When your workflow calls &lt;code&gt;workflow.sleep(3600)&lt;/code&gt; (sleep for one hour), Temporal doesn’t somehow make your Python code run faster. What it does is add an entry to its internal timetable:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&quot;Wake up workflow ABC at current_time + 3600 seconds&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When your workflow starts an activity with a 30-second timeout, Temporal adds:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&quot;If activity XYZ hasn&apos;t returned by current_time + 30 seconds, fail it&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When your workflow has an execution timeout of 10 minutes:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&quot;If workflow ABC isn&apos;t done by current_time + 600 seconds, kill it&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Sleeps, timeouts, activity deadlines, heartbeat intervals — from Temporal’s perspective, they’re all the same thing. They’re entries in a timetable. Each one says: “fire event X at time T.” The only difference is what event gets fired — resume the workflow, fail an activity, kill the whole thing. But the mechanism is identical: a timestamp and an action.&lt;/p&gt;
&lt;p&gt;This is the foundation for understanding time-skipping.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;time-skipping-fast-forwarding-idle-time&quot;&gt;Time-Skipping: Fast-Forwarding Idle Time&lt;/h2&gt;
&lt;p&gt;Temporal’s Python SDK gives you two test environments:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;WorkflowEnvironment.start_time_skipping()&lt;/code&gt;&lt;/strong&gt; — downloads a lightweight Rust-based test server binary that runs in-process (no Docker needed). This server has a virtual clock that can jump forward.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;WorkflowEnvironment.start_local()&lt;/code&gt;&lt;/strong&gt; — runs a full Temporal server with a real clock, just like production.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The time-skipping server does one clever thing: &lt;strong&gt;when nothing is happening, it fast-forwards its clock to the next scheduled event.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Imagine your workflow does this:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; workflow.sleep(&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt;3600&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;)   &lt;/span&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# sleep 1 hour&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; do_some_activity()&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; workflow.sleep(&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt;7200&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;)   &lt;/span&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# sleep 2 hours&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In production, this takes 3 hours of wall-clock time (plus however long the activity takes). With time-skipping, here’s what happens:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Workflow calls &lt;code&gt;sleep(3600)&lt;/code&gt;. Temporal adds to its timetable: “resume at now + 3600s.”&lt;/li&gt;
&lt;li&gt;Nothing else is pending. The server jumps its clock forward 3600 seconds instantly.&lt;/li&gt;
&lt;li&gt;Timer fires. Workflow resumes. Activity starts.&lt;/li&gt;
&lt;li&gt;Activity is running — the server &lt;strong&gt;does not skip&lt;/strong&gt;, because it’s waiting for a real result.&lt;/li&gt;
&lt;li&gt;Activity completes. Workflow calls &lt;code&gt;sleep(7200)&lt;/code&gt;. Timetable entry added.&lt;/li&gt;
&lt;li&gt;Nothing pending. Server jumps forward 7200 seconds.&lt;/li&gt;
&lt;li&gt;Timer fires. Workflow finishes.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Total real time: however long the activity took (maybe milliseconds if it’s a mock). The 3 hours of sleeping? Gone. Skipped.&lt;/p&gt;
&lt;p&gt;This leads to a really important corollary that solidified my understanding:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;If your code never sleeps, never waits, and never sets timeouts, time-skipping gives you zero speedup.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Think about it. Time-skipping only fast-forwards idle time on Temporal’s clock. If there’s no idle time — no timers, no sleeps, no timeouts — there’s nothing to skip. Your workflow would run at the exact same speed with time-skipping as without it.&lt;/p&gt;
&lt;p&gt;Of course, that’s a theoretical extreme. Real workflows almost always have timeouts, retry intervals, and sleeps. But the principle is clarifying: time-skipping is not about making your code faster. It’s about eliminating wait time between events on Temporal’s timetable.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-handle-your-remote-control&quot;&gt;The Handle: Your Remote Control&lt;/h2&gt;
&lt;p&gt;When you start a workflow, Temporal gives you back a &lt;strong&gt;handle&lt;/strong&gt;. Think of it like a restaurant ticket — you placed your order (started the workflow), and now you have a ticket to interact with it while it’s being prepared.&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;handle &lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; client.start_workflow(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;    MyWorkflow.run,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;    inputs,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#953800;--shiki-dark:#FFA657&quot;&gt;    id&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#0A3069;--shiki-dark:#A5D6FF&quot;&gt;&quot;order-123&quot;&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#953800;--shiki-dark:#FFA657&quot;&gt;    task_queue&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#0A3069;--shiki-dark:#A5D6FF&quot;&gt;&quot;kitchen&quot;&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The workflow is now running independently inside Temporal’s runtime. The handle is your remote control. It has a few buttons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;handle.result()&lt;/code&gt;&lt;/strong&gt; — “Call me when my food is ready.” You sit on the phone, waiting, until the workflow finishes and gives you the result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;handle.query()&lt;/code&gt;&lt;/strong&gt; — “Hey, how’s my order coming?” A quick question. You get an answer immediately and hang up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;handle.signal()&lt;/code&gt;&lt;/strong&gt; — “Actually, add extra cheese.” You send a message to the running workflow. It gets delivered immediately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;handle.cancel()&lt;/code&gt;&lt;/strong&gt; — “Cancel my order.”&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The handle is just &lt;code&gt;temporalio.client.WorkflowHandle&lt;/code&gt; — nothing magical. But understanding what each button &lt;em&gt;does&lt;/em&gt; to the time-skipping server is where it gets interesting.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;result-vs-query--the-trap&quot;&gt;&lt;code&gt;result()&lt;/code&gt; vs &lt;code&gt;query()&lt;/code&gt; — The Trap&lt;/h2&gt;
&lt;p&gt;This is where my intuition was completely backwards, and getting it right is what finally unblocked me.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;handle.result()&lt;/code&gt;&lt;/strong&gt; is a phone call where you say: &lt;em&gt;“Don’t hang up until my food is ready.”&lt;/em&gt; You’re blocking. You’re waiting. And critically, you’re telling the time-skipping server: &lt;strong&gt;“I’m waiting for this workflow to finish.”&lt;/strong&gt; The server hears that and thinks: “They want the result. Let me help by fast-forwarding to when it’s done.”&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;handle.query()&lt;/code&gt;&lt;/strong&gt; is a phone call where you say: &lt;em&gt;“Is my food ready? No? Okay, bye.”&lt;/em&gt; One shot. You get the current state, and then you’re done. The server has no reason to fast-forward anything — you didn’t ask it to wait for anything.&lt;/p&gt;
&lt;p&gt;Here’s the part that tripped me up: &lt;strong&gt;&lt;code&gt;result()&lt;/code&gt; is event-based (wait for the “done” event), and &lt;code&gt;query()&lt;/code&gt; is polling (check the current state and return immediately).&lt;/strong&gt; You’d think the event-based approach is the “better” one — and in normal programming, it usually is. But in time-skipping mode, the polling approach is safer, because it doesn’t give Temporal permission to mess with its clock.&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Operation&lt;/th&gt;&lt;th&gt;What it says to Temporal&lt;/th&gt;&lt;th&gt;Triggers time-skipping?&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;handle.result()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;”Wake me up when it’s done”&lt;/td&gt;&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt; — server tries to &lt;em&gt;make&lt;/em&gt; it be done&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;handle.query()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;”What’s the status right now?”&lt;/td&gt;&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt; — server just answers&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;handle.signal()&lt;/code&gt;&lt;/td&gt;&lt;td&gt;”Deliver this message”&lt;/td&gt;&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt; — immediate delivery&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;That middle column is the whole story. &lt;code&gt;result()&lt;/code&gt; gives the server license to fast-forward. &lt;code&gt;query()&lt;/code&gt; doesn’t.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;the-race-condition-death-by-time-travel&quot;&gt;The Race Condition: Death by Time Travel&lt;/h2&gt;
&lt;p&gt;Now you have all the pieces. Let me show you how they combine to create a very confusing bug.&lt;/p&gt;
&lt;h3 id=&quot;the-setup&quot;&gt;The Setup&lt;/h3&gt;
&lt;p&gt;Our workflow has steps that need user feedback. After a step runs (say, analyzing a document), the workflow pauses and waits for the user to review the output and click “Continue.” In production, this might take minutes or hours — the user is reading, thinking, editing.&lt;/p&gt;
&lt;p&gt;In the code, this waiting looks like:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# backend/src/genai/temporal/workflow_executor.py, line 149&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; workflow.wait_condition(&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;lambda&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;: waiter.signal_received)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells Temporal: “Pause here until &lt;code&gt;signal_received&lt;/code&gt; becomes &lt;code&gt;True&lt;/code&gt;.” There’s no timeout — it waits as long as it takes. In production, that’s fine. The user eventually clicks Continue, a signal is sent, &lt;code&gt;signal_received&lt;/code&gt; flips to &lt;code&gt;True&lt;/code&gt;, and the workflow resumes.&lt;/p&gt;
&lt;h3 id=&quot;the-exam-analogy&quot;&gt;The Exam Analogy&lt;/h3&gt;
&lt;p&gt;Imagine you’re a teacher proctoring an exam with a 2-hour time limit. You have a clock on the wall.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Production (real clock):&lt;/strong&gt; A student raises their hand. “I need my calculator from my locker.” You wait. Someone brings it. The student finishes the exam. The clock says 45 minutes passed. No problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Time-skipping (magic clock):&lt;/strong&gt; A student raises their hand. “I need my calculator from my locker.” You look around the room. Nobody is actively writing. Nothing is happening. So you spin the magic clock forward — 30 minutes, 1 hour, 1.5 hours, 2 hours. “Time’s up! Exam over!” The student fails.&lt;/p&gt;
&lt;p&gt;The person bringing the calculator walks in 0.1 real seconds later. But the clock already says 2 hours. Too late.&lt;/p&gt;
&lt;h3 id=&quot;what-actually-happened&quot;&gt;What Actually Happened&lt;/h3&gt;
&lt;p&gt;Here’s the exact sequence in our tests:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real time 0.00s&lt;/strong&gt; — Test starts the workflow. Mock activities return instantly (they’re fakes — no real I/O).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real time ~0.02s&lt;/strong&gt; — All activities are done. Workflow enters &lt;code&gt;wait_condition()&lt;/code&gt; — waiting for a feedback signal. At this moment, Temporal’s timetable has &lt;strong&gt;nothing pending&lt;/strong&gt;: no activities running, no timers set. Just a workflow sitting at a &lt;code&gt;wait_condition&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real time ~0.02s&lt;/strong&gt; — Test calls &lt;code&gt;handle.result()&lt;/code&gt;: “Tell me when the workflow finishes.”&lt;/p&gt;
&lt;p&gt;The time-skipping server hears this and thinks: &lt;em&gt;“The client wants the result. Let me check what’s pending… No activities. No timers. Nothing to wait for except a &lt;code&gt;wait_condition&lt;/code&gt; I can’t satisfy. But there IS a workflow execution timeout at now + 600 seconds. Let me jump there.”&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Server clock jumps:&lt;/strong&gt; 0s → 600s.&lt;/p&gt;
&lt;p&gt;Workflow execution timeout fires. Workflow dies with &lt;code&gt;TimeoutError&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Real time ~0.03s&lt;/strong&gt; — &lt;code&gt;handle.result()&lt;/code&gt; returns with an error.&lt;/p&gt;
&lt;p&gt;Meanwhile, the test had a polling loop that was &lt;em&gt;supposed&lt;/em&gt; to query for pending feedback steps and send signals. That loop was scheduled to run after &lt;code&gt;asyncio.sleep(0.1)&lt;/code&gt; — at real time 0.1 seconds. But the workflow is already dead. The signals arrive at a corpse.&lt;/p&gt;
&lt;p&gt;The whole thing happened in ~30 milliseconds of real time. The server just… jumped to the end.&lt;/p&gt;
&lt;h3 id=&quot;the-fix&quot;&gt;The Fix&lt;/h3&gt;
&lt;p&gt;The fix is almost anticlimactic once you understand the problem. Don’t call &lt;code&gt;handle.result()&lt;/code&gt; while the workflow is waiting for signals. Instead, use &lt;code&gt;handle.query()&lt;/code&gt; to poll, send signals when needed, and only call &lt;code&gt;result()&lt;/code&gt; after confirming the workflow is already done:&lt;/p&gt;
&lt;pre class=&quot;astro-code astro-code-themes github-light-default github-dark-default&quot; style=&quot;background-color:#ffffff;--shiki-dark-bg:#0d1117;color:#1f2328;--shiki-dark:#e6edf3; overflow-x: auto;&quot; tabindex=&quot;0&quot; data-language=&quot;python&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# backend/tests/integration/genai/test_temporal_workflow.py, lines 49-77&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;async&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt; def&lt;/span&gt;&lt;span style=&quot;color:#8250DF;--shiki-dark:#D2A8FF&quot;&gt; _run_workflow_with_feedback&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;(handle):&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;    while&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt; True&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;        await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; asyncio.sleep(&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt;0.1&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;)    &lt;/span&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# real-time sleep in the test process&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;        # query() doesn&apos;t trigger time-skipping — clock stays frozen&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;        progress &lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; handle.query(JurorAnalysisWorkflow.get_progress)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;        # Send signals for any steps waiting for feedback&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;        for&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; step_id &lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;in&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; progress.pending_feedback_steps:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;            await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; handle.signal(&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;                JurorAnalysisWorkflow.submit_step_feedback,&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;                StepFeedbackSignal(&lt;/span&gt;&lt;span style=&quot;color:#953800;--shiki-dark:#FFA657&quot;&gt;release_step_id&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;step_id),&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;            )&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;        # Only call result() AFTER the workflow reports it&apos;s done&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;        if&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; progress.status &lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;==&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; WorkflowStatus.&lt;/span&gt;&lt;span style=&quot;color:#0550AE;--shiki-dark:#79C0FF&quot;&gt;COMPLETED&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt;            return&lt;/span&gt;&lt;span style=&quot;color:#CF222E;--shiki-dark:#FF7B72&quot;&gt; await&lt;/span&gt;&lt;span style=&quot;color:#1F2328;--shiki-dark:#E6EDF3&quot;&gt; handle.result()  &lt;/span&gt;&lt;span style=&quot;color:#6E7781;--shiki-dark:#8B949E&quot;&gt;# returns instantly — nothing to skip&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Why this works:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;query()&lt;/code&gt; doesn’t touch the clock.&lt;/strong&gt; The workflow stays frozen at its &lt;code&gt;wait_condition&lt;/code&gt;. No fast-forwarding.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Our polling loop runs in real time.&lt;/strong&gt; &lt;code&gt;asyncio.sleep(0.1)&lt;/code&gt; is a real sleep in the test process — Temporal doesn’t control it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Signals are delivered immediately&lt;/strong&gt; regardless of what Temporal’s clock says.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;By the time we call &lt;code&gt;result()&lt;/code&gt;, the workflow is already complete.&lt;/strong&gt; There’s nothing to fast-forward to. It returns instantly.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The workflow’s clock never jumps because we never gave the server permission to jump it.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&quot;takeaways&quot;&gt;Takeaways&lt;/h2&gt;
&lt;p&gt;A few rules of thumb I’m keeping in my back pocket:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The mental model:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Temporal is a timekeeper, not an execution engine. It manages a timetable of timers, timeouts, and activity deadlines.&lt;/li&gt;
&lt;li&gt;Time-skipping fast-forwards idle time on Temporal’s clock. It doesn’t speed up your code.&lt;/li&gt;
&lt;li&gt;If there’s nothing on the timetable to skip to, there’s nothing to skip.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;The practical rules:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Never &lt;code&gt;await handle.result()&lt;/code&gt; on a workflow that’s waiting for external signals. It gives Temporal permission to fast-forward, and the workflow will die before your signals arrive.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;handle.query()&lt;/code&gt; to poll workflow state. Queries are passive reads — they don’t affect the clock.&lt;/li&gt;
&lt;li&gt;Signals work immediately in any time mode. They don’t care what the server clock says.&lt;/li&gt;
&lt;li&gt;Only call &lt;code&gt;handle.result()&lt;/code&gt; after you’ve confirmed the workflow is done via &lt;code&gt;query()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;When to use which test environment:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;start_time_skipping()&lt;/code&gt;&lt;/strong&gt; — great for workflows that are self-contained (just timers and activities, no external interaction). Also works for signal-based workflows &lt;em&gt;if&lt;/em&gt; you use the query-based polling pattern.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;start_local()&lt;/code&gt;&lt;/strong&gt; — safer for workflows that require signals/queries during execution, since it runs in real-time. But slower, because timers actually wait.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We kept &lt;code&gt;start_time_skipping()&lt;/code&gt; because the query-based pattern works correctly, and we get free speedup on any timer-based operations (like activity retry intervals and jitter sleeps).&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;em&gt;Disclaimer: Written by Human, improved using AI where applicable.&lt;/em&gt;&lt;/p&gt;</description><pubDate>Mon, 09 Mar 2026 00:00:00 GMT</pubDate></item></channel></rss>