mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
fix(wac): return the checkpointed value from step(), not the live object (#10367)
* fix(wac): return the checkpointed value from step(), not the live object Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(wac): regenerate system prompts and narrow the round-trip claim Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style(wac): condense the round-trip comments and fix the fallback note Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sdk): type step() as the JSON round trip of its body's result Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(sdk): apply the JSON round trip to task() and the standalone paths Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sdk): encode bigint, keep unknown as unknown, align dropped-key results Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): null out results whose key JSON.stringify would drop Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): normalize only the top-level result, keeping nested keys as they were Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(wac): normalize a child task's result so a deployed job cannot fail to parse Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(sdk): pin non-finite number behavior in Jsonified and its tests Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sdk): admit undefined for keys whose value JSON.stringify may omit Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sdk): make a key JSON.stringify may omit optional, not just nullable Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(sdk): treat a class-valued property as dropped, like any other function Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,47 @@
|
||||
"""Tests for the Workflow-as-Code SDK."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from wmill.client import WorkflowCtx, _StepSuspend, TaskError, workflow, task, step, sleep, parallel, wait_for_approval, _run_workflow, _run_workflow_async
|
||||
|
||||
|
||||
class _StubInlineClient:
|
||||
"""Stands in for the httpx client the inline fast path POSTs with.
|
||||
|
||||
Decodes each request body, so a test sees exactly the JSON that reaches
|
||||
``/jobs/wac/inline_checkpoint`` — and therefore what a replay reads back.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.posted = []
|
||||
|
||||
async def post(self, url, content=None):
|
||||
self.posted.append(json.loads(content))
|
||||
|
||||
class _Response:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
return _Response()
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
|
||||
def _set_inline_fast_path_env(monkeypatch):
|
||||
for var, val in (
|
||||
("WM_JOB_ID", "job-1"),
|
||||
("WM_WORKSPACE", "admins"),
|
||||
("BASE_INTERNAL_URL", "http://localhost:8000"),
|
||||
("WM_TOKEN", "tok"),
|
||||
):
|
||||
monkeypatch.setenv(var, val)
|
||||
|
||||
|
||||
@task
|
||||
async def extract_data(url: str):
|
||||
pass # body unused in workflow context
|
||||
@@ -974,31 +1010,14 @@ class TestRaisingInlineStepIsCheckpointed:
|
||||
the same ``TaskError`` a replay rebuilds from the marker — raising the
|
||||
original ``ValueError`` here would make ``except ValueError:`` catch on
|
||||
this run and miss on the next one."""
|
||||
for var, val in (
|
||||
("WM_JOB_ID", "job-1"),
|
||||
("WM_WORKSPACE", "admins"),
|
||||
("BASE_INTERNAL_URL", "http://localhost:8000"),
|
||||
("WM_TOKEN", "tok"),
|
||||
):
|
||||
monkeypatch.setenv(var, val)
|
||||
_set_inline_fast_path_env(monkeypatch)
|
||||
|
||||
posted = []
|
||||
|
||||
class _StubResponse:
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
class _StubClient:
|
||||
async def post(self, url, json=None):
|
||||
posted.append(json)
|
||||
return _StubResponse()
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
stub = _StubInlineClient()
|
||||
posted = stub.posted
|
||||
|
||||
async def run():
|
||||
ctx = WorkflowCtx({})
|
||||
ctx._inline_http_client = _StubClient()
|
||||
ctx._inline_http_client = stub
|
||||
with pytest.raises(TaskError, match="boom") as live:
|
||||
await ctx._run_inline_step("risky", self._boom)
|
||||
# ...and the replay of that very checkpoint raises the same thing.
|
||||
@@ -1031,6 +1050,52 @@ class TestRaisingInlineStepIsCheckpointed:
|
||||
assert r["result"] == 10
|
||||
|
||||
|
||||
class TestInlineStepRoundParity:
|
||||
"""The round that runs a ``step()`` body must see what a replay sees.
|
||||
|
||||
The fast path returns the value it checkpointed, not the in-memory one:
|
||||
a workflow branching on a datetime attribute or a tuple would otherwise
|
||||
take one path on the round that ran the body and another on every replay,
|
||||
which can change which tasks get dispatched, not just crash later.
|
||||
"""
|
||||
|
||||
CASES = [
|
||||
("dt", lambda: datetime(2026, 1, 1, tzinfo=timezone.utc), "2026-01-01 00:00:00+00:00"),
|
||||
("pair", lambda: (1, 2), [1, 2]),
|
||||
("intkeys", lambda: {1: "a"}, {"1": "a"}),
|
||||
]
|
||||
|
||||
def test_outside_a_workflow_returns_the_same_shape(self):
|
||||
"""No checkpoint, no replay — but a local run must not hand back a shape
|
||||
a deployed one never produces, or testing a workflow locally proves
|
||||
nothing. The async task path is the sharp edge: the wrapper is sync, so
|
||||
the value has to be round-tripped after the await, not before."""
|
||||
|
||||
@task
|
||||
async def make_pair():
|
||||
return (1, datetime(2026, 1, 1, tzinfo=timezone.utc))
|
||||
|
||||
assert asyncio.run(step("pair", lambda: (1, 2))) == [1, 2]
|
||||
assert asyncio.run(make_pair()) == [1, "2026-01-01 00:00:00+00:00"]
|
||||
|
||||
def test_live_round_matches_checkpoint_and_replay(self, monkeypatch):
|
||||
_set_inline_fast_path_env(monkeypatch)
|
||||
|
||||
async def run():
|
||||
for key, fn, expected in self.CASES:
|
||||
stub = _StubInlineClient()
|
||||
ctx = WorkflowCtx({})
|
||||
ctx._inline_http_client = stub
|
||||
live = await ctx._run_inline_step(key, fn)
|
||||
checkpointed = stub.posted[0]["result"]
|
||||
assert checkpointed == expected
|
||||
assert live == expected and type(live) is type(expected)
|
||||
replayed = WorkflowCtx({"completed_steps": {key: checkpointed}})
|
||||
assert await replayed._run_inline_step(key, fn) == live
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# TASK OPTIONS TESTS
|
||||
# =====================================================================
|
||||
|
||||
Reference in New Issue
Block a user