What are you really trying to do?
Run payload-heavy workflows (large activity results validated with pydantic on resolution) without paying an avoidable constant-factor CPU multiplier inside every activation.
Describe the bug
workflow_sandbox/_importer.py unconditionally replaces builtins.isinstance/issubclass for the duration of every activation: _ThreadLocalCallable.__call__ → the thread-local current property → unwrap_second_param → 2× RestrictionContext.unwrap_if_proxied → the real C builtin — roughly 6 Python-level calls where the interpreter normally does one C call. Because the swap is on process globals rather than the import graph, host library code (passthrough modules included) pays it too.
Payload conversion runs inside this context (_apply_resolve_activity → _convert_payloads happens under the sandbox importer during activate()), so any per-object Python work in a payload converter — e.g. pydantic validation with callable discriminators, which calls isinstance per JSON node (see pydantic/pydantic-ai#7472) — is multiplied.
Measured (MRE below): validating a synthetic 37 KB nested JSON payload through a pydantic TypeAdapter whose union uses a callable discriminator:
- bare interpreter: 1.06 ms/validate
- inside
Importer(...).applied() (default restrictions, passthrough-all-modules): 2.01 ms/validate — 1.9×, with 2,829 intercepted isinstance calls per validation.
workflow.unsafe.is_sandbox_unrestricted() is not consulted on this path, so there is no opt-out short of UnsandboxedWorkflowRunner.
Ask: a restriction-config flag to skip the builtins isinstance/issubclass wrapping for deployments that don't rely on restricted proxies, and/or a cheaper unwrap path (e.g. only install the wrapper when a restriction context actually holds proxied objects).
Minimal Reproduction
import builtins, json, time
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field, TypeAdapter
from temporalio.worker.workflow_sandbox._importer import Importer
from temporalio.worker.workflow_sandbox._restrictions import (
RestrictionContext, SandboxRestrictions,
)
# isinstance-heavy validation standing in for real activation-time payload work:
# a discriminated union whose discriminator is a Python callable.
class Row(BaseModel):
title: str
attrs: dict[str, str]
tags: list[str]
class Content(BaseModel):
kind: Literal["tool_content_result"]
result: list[Row]
class Other(BaseModel):
kind: Literal["other"]
def disc(v):
# per-node callable discriminator (the shape pydantic-ai uses for tool returns)
return "tool_content_result" if isinstance(v, dict) and "result" in v else "other"
from pydantic import Discriminator
TheUnion = Annotated[Union[Content, Other], Discriminator(disc)]
row = {"title": "x" * 80,
"attrs": {f"k{i}": "v" * 40 for i in range(24)},
"tags": ["t" * 12] * 10}
payload = json.dumps(
{"kind": "tool_content_result", "result": [dict(row) for _ in range(25)]}
).encode()
ta = TypeAdapter(TheUnion)
ta.validate_json(payload) # warm
def bench():
t0 = time.perf_counter()
for _ in range(100):
ta.validate_json(payload)
return (time.perf_counter() - t0) * 10 # ms/validate
bare = bench()
restrictions = SandboxRestrictions.default.with_passthrough_all_modules()
importer = Importer(restrictions, RestrictionContext())
orig = builtins.isinstance
with importer.applied():
assert builtins.isinstance is not orig # interception active
sandboxed = bench()
print(f"bare {bare:.2f} ms, sandboxed {sandboxed:.2f} ms, {sandboxed/bare:.2f}x")
Environment/Versions
temporalio 1.30.0, pydantic 2.13.4 / pydantic-core 2.46.4, CPython 3.12.11, macOS arm64 (also observed on linux/x86_64 workers).
Additional context
The comment in _importer.py already acknowledges the tradeoff ("It is unfortunate we have to change these globals for everybody") — this issue is the measurement plus an opt-out request, not a surprise report.
What are you really trying to do?
Run payload-heavy workflows (large activity results validated with pydantic on resolution) without paying an avoidable constant-factor CPU multiplier inside every activation.
Describe the bug
workflow_sandbox/_importer.pyunconditionally replacesbuiltins.isinstance/issubclassfor the duration of every activation:_ThreadLocalCallable.__call__→ the thread-localcurrentproperty →unwrap_second_param→ 2×RestrictionContext.unwrap_if_proxied→ the real C builtin — roughly 6 Python-level calls where the interpreter normally does one C call. Because the swap is on process globals rather than the import graph, host library code (passthrough modules included) pays it too.Payload conversion runs inside this context (
_apply_resolve_activity→_convert_payloadshappens under the sandbox importer duringactivate()), so any per-object Python work in a payload converter — e.g. pydantic validation with callable discriminators, which callsisinstanceper JSON node (see pydantic/pydantic-ai#7472) — is multiplied.Measured (MRE below): validating a synthetic 37 KB nested JSON payload through a pydantic
TypeAdapterwhose union uses a callable discriminator:Importer(...).applied()(default restrictions, passthrough-all-modules): 2.01 ms/validate — 1.9×, with 2,829 interceptedisinstancecalls per validation.workflow.unsafe.is_sandbox_unrestricted()is not consulted on this path, so there is no opt-out short ofUnsandboxedWorkflowRunner.Ask: a restriction-config flag to skip the builtins
isinstance/issubclasswrapping for deployments that don't rely on restricted proxies, and/or a cheaper unwrap path (e.g. only install the wrapper when a restriction context actually holds proxied objects).Minimal Reproduction
Environment/Versions
temporalio 1.30.0, pydantic 2.13.4 / pydantic-core 2.46.4, CPython 3.12.11, macOS arm64 (also observed on linux/x86_64 workers).
Additional context
The comment in
_importer.pyalready acknowledges the tradeoff ("It is unfortunate we have to change these globals for everybody") — this issue is the measurement plus an opt-out request, not a surprise report.