Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ informal introduction to the features and their implementation.
- [Customizing the Sandbox](#customizing-the-sandbox)
- [Passthrough Modules](#passthrough-modules)
- [Invalid Module Members](#invalid-module-members)
- [Debugging Workflows with `breakpoint()` / `pdb`](#debugging-workflows-with-breakpoint--pdb)
- [Known Sandbox Issues](#known-sandbox-issues)
- [Global Import/Builtins](#global-importbuiltins)
- [Sandbox is not Secure](#sandbox-is-not-secure)
Expand Down Expand Up @@ -1241,6 +1242,80 @@ my_worker = Worker(..., workflow_runner=SandboxedWorkflowRunner(restrictions=my_

See the API for more details on exact fields and their meaning.

##### Debugging Workflows with `breakpoint()` / `pdb`

Setting `debug_mode=True` on the `Worker` (or `TEMPORAL_DEBUG=1` in the environment) routes workflow activations
onto the asyncio main thread instead of a worker thread pool. This lets `breakpoint()` and `pdb.set_trace()`
inside workflow code open an interactive REPL — without it, pdb hangs because its `input()` call would run on a
thread that does not own the controlling TTY.

A minimal runnable example:

```python
import asyncio
from datetime import timedelta

from temporalio import workflow
from temporalio.client import Client
from temporalio.worker import Worker


@workflow.defn
class DebugMeWorkflow:
@workflow.run
async def run(self) -> str:
x = 42
breakpoint() # interactive pdb prompt opens at this line
return f"x was {x}"


async def main() -> None:
client = await Client.connect("localhost:7233")
async with Worker(
client,
task_queue="debug-me",
workflows=[DebugMeWorkflow],
debug_mode=True,
):
result = await client.execute_workflow(
DebugMeWorkflow.run,
id="debug-me-wf",
task_queue="debug-me",
task_timeout=timedelta(minutes=10), # see caveat below
)
print(result)


if __name__ == "__main__":
asyncio.run(main())
```

Run with `python debug_me.py`, or under pytest with `pytest -s` (the `-s` flag disables pytest's stdin
capture). At the `(Pdb)` prompt you'll land at the line where `breakpoint()` was called, with workflow
locals in scope. Try `p x`, `n`, `c`, `q`.

**Quitting cleanly.** Typing `q` or hitting Ctrl-D continues the workflow rather than raising `BdbQuit`
(which would fail the workflow task). To genuinely abort, kill the outer process with Ctrl-C.

Two caveats when pausing at a breakpoint inside a workflow:

1. **Workflow task timeout.** Temporal expires a workflow task after ~10 seconds by default. If you sit at the
`(Pdb)` prompt longer than that, the server reassigns the task and your workflow replays from the start when
you continue — re-hitting the breakpoint. Pass `task_timeout=timedelta(minutes=N)` to `execute_workflow` /
`start_workflow` to give yourself debugging headroom:

```python
await client.execute_workflow(MyWorkflow.run, ..., task_timeout=timedelta(minutes=10))
```

2. **Deterministic replay.** Workflows are deterministic and replay from history; any wall-clock pause violates
that contract. For post-mortem debugging without these caveats, use the [Replayer](#replayer) on a recorded
history instead of live debugging.

Calling `breakpoint()` from sandboxed workflow code without `debug_mode` raises a sandbox
`RestrictedWorkflowAccessError` with a message pointing at `debug_mode=True`, so the failure mode is loud
and the fix is obvious.

##### Known Sandbox Issues

Below are known sandbox issues. As the sandbox is developed and matures, some may be resolved.
Expand Down
15 changes: 12 additions & 3 deletions scripts/gen_protos.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
re.compile(r"from protoc_gen_openapiv2\.").sub,
r"from temporalio.api.dependencies.protoc_gen_openapiv2.",
),
partial(
re.compile(r"from nexusannotations\.").sub,
r"from temporalio.api.dependencies.nexusannotations.",
),
partial(
re.compile(r"from temporal\.sdk\.core\.").sub, r"from temporalio.bridge.proto."
),
Expand All @@ -58,6 +62,10 @@
re.compile(r"protoc_gen_openapiv2\.").sub,
r"temporalio.api.dependencies.protoc_gen_openapiv2.",
),
partial(
re.compile(r"nexusannotations\.").sub,
r"temporalio.api.dependencies.nexusannotations.",
),
partial(re.compile(r"temporal\.sdk\.core\.").sub, r"temporalio.bridge.proto."),
]

Expand Down Expand Up @@ -191,11 +199,12 @@ def generate_protos(output_dir: Path):
grpc_file.unlink()
# Apply fixes before moving code
fix_generated_output(output_dir)
# Move openapiv2 dependency protos
# Move dependency protos
deps_out_dir = api_out_dir / "dependencies"
shutil.rmtree(deps_out_dir / "protoc_gen_openapiv2", ignore_errors=True)
deps_out_dir.mkdir(exist_ok=True)
(output_dir / "protoc_gen_openapiv2").replace(deps_out_dir / "protoc_gen_openapiv2")
for dep in ["protoc_gen_openapiv2", "nexusannotations"]:
shutil.rmtree(deps_out_dir / dep, ignore_errors=True)
(output_dir / dep).replace(deps_out_dir / dep)
(deps_out_dir / "__init__.py").touch()
# Move protos
for p in (output_dir / "temporal" / "api").iterdir():
Expand Down
20 changes: 10 additions & 10 deletions temporalio/api/activity/v1/message_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading