Skip to content

Sandbox isinstance/issubclass interception is process-global during activations: measured 1.9x on payload validation, no opt-out short of UnsandboxedWorkflowRunner #1755

Description

@ZacharyHampton

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions