Skip to main content
Python Executor is a resident Python process (python -m blockx_executor) in the Function Executor Pool local to the Worker. It receives ExecuteCall from the Worker, loads and runs the user function, translates the BlockDB / RPC / subfunction calls made by the user function into CallWaiting messages sent back to the Worker, and restores the original execution stack when ResumeCall arrives. See Architecture overview for where it sits in the system, and Call execution subsystem for its Worker-side counterpart (dispatcher, executor adapter, subfunctions).

Responsibilities and boundaries

Responsible for:
  • Receiving ExecuteCall / ResumeCall / CancelCall / HeartbeatAck and emitting CallWaiting / CallCompleted / CallFailed / Heartbeat over a single long-lived UDS connection.
  • Compiling and caching function modules by source digest, isolating module namespaces per task, and fetching source from the Worker by digest when it is missing.
  • Using greenlet to suspend synchronous-style user code at SDK call points and resume it after ResumeCall.
  • Running cancellation and deadline / budget checks at safe points, and preempting pure-CPU loops with a SIGALRM timer.
  • Reporting process metrics (RSS, CPU utilization, cumulative CPU seconds, call counts per state) every heartbeat interval.
Not responsible for:
  • Deciding the call dispatch order or which Executor to pick (that is the Worker dispatcher’s job).
  • Accessing BlockDB / node RPC / Function Code View directly. All SDK IO is intercepted and goes out through the Worker.
  • Persisting or recovering any call across processes. After a process restart, all context is rebuilt.
Core invariants:
  • User code runs only in greenlets on the main thread, one at a time; the UDS reader / writer / heartbeat threads are separate and never switch greenlets.
  • An ExecContext has at most one pending requestId at any time; a ResumeCall must match callId, the resumeKind corresponding to the state, and requestId all at once, otherwise it is dropped as stale.
  • A second ExecuteCall for the same callId is treated as a new attempt: the old greenlet is marked stale and terminated without emitting a terminal state; the new attempt gets a new greenlet.
  • Every discarded context emits exactly one terminal message (CallCompleted or CallFailed), except on attempt replacement.
  • Compiled artifacts are shared across tasks by digest; exec’d module namespaces are isolated per task_id and released when the task’s last call finishes.

Code location

Core types and interfaces

The Provider protocol verbatim:

User function programming model

A function module is a piece of Python source whose entry point is a top-level def. The entry-name resolution rules are in resolve_entry_name in blockx_audit/tables.py: prefer the top-level def whose name matches the last segment of functionId, then def _ (or a _ = some_def alias), and finally fall back to the first public def. In practice, just write def _(...). When ExecuteCall.payload.args is a JSON array it is unpacked positionally; otherwise it is passed as a single argument (_run_callable in runtime.py: target(*input_payload) if isinstance(input_payload, list) else target(input_payload)). The return value is row data directly: a dict is one row, a list[dict] is multiple rows, and None means no output (see Plugin system for details). Return values are serialized by uds_codec.dumps_wire: Python int values beyond 64 bits (e.g. uint256) can be returned directly, datetime / date become RFC 3339 strings, dataclasses are converted through asdict as a fallback (with a one-time warning), and other types such as Decimal are not serializable and must be converted to str first (which is why the example below uses str(total)). Below is a real case from cmd/worker/worker_process_subcall_test.go. It has no import at all, because the unrestricted namespace is preloaded with tables.PRELUDE_IMPORTS (json, Decimal, datetime, date, blockdb.Table / BlockTable / TimeTable / Block, blockx.function, leafage.ChainState, and so on):
The node RPC read pattern from e2e/perf/s4_noderpc_batch_test.go:
How each kind of SDK call looks in user code and where it is intercepted: The relationship between blockx_sdk and blockx-py: blockx-py / blockdb-py / leafage-py are the SDKs users actually face, and they can also connect to the services directly outside the Executor; blockx_sdk is not a replacement for them but the seam inside the Executor process: blockx_sdk.runtime keeps the current Provider in contextvars, and blockx-py’s function.call lazily imports it when it detects the Executor environment. The early blockx_sdk.db / rpc / call_subfunc entry points now mainly appear in unit tests, Go-side E2E fixtures, and the load functions generated by cmd/bundle-loadgen.
When auditing is enabled (ExecuteCall.payload.audit is present), the module is exec’d inside restricted_runtime.restricted_namespace: __builtins__ contains only SAFE_BUILTINS, import only yields facades of whitelisted modules, and blockx exposes only function and the capability classes. See docs/specs/function-code-python-whitelist.md in the blockx repo for which syntax and APIs are whitelisted.

Data flow / execution flow

The main loop lives in __main__.py: while session.is_connected(): runtime.run_until_idle(); session.wait_for_message(timeout=runtime.next_scheduler_delay(None)). Each round of run_until_idle first wakes sleepers that are due, refreshes the connection state, and processes inbound messages, then takes one call_id from _sleep_ready (preferred, yielding after 64 consecutive picks) or _runnable and hands it to _step_context. _step_context has only three branches: if cancel_reason is set, _terminate_context; if resume_payload is set, switch(payload) back to the wait point; otherwise the first switch() enters _run_callable. At startup, ExecutorRuntime.__init__ performs the one-time hook installation: install_global_sleep_patch(), _install_blockdb_bridge (blockdb’s mode.set_mock(False) + set_channel_factory), _install_noderpc_bridge, _install_localtest_bridge (blockx-py’s channel factory, used by the Python clients of capability backends), and sets BLOCKX_EXECUTOR_RUNTIME=1. SdkBridgeProvider is not installed globally once; instead set_provider runs on every entry into _run_callable and reset_provider on exit.
The SDK hooks combine two techniques: blockx_sdk uses provider injection; blockdb-py and blockx-py are replaced at the gRPC channel layer through the set_channel_factory seam they expose; leafage-py is patched by replacing W3_DICT entries. There are no hooks at the socket layer.
BlockDB and NodeRPC request bodies do not go through JSON: MessageEnvelope.binary_payload carries the raw protobuf / JSON-RPC bytes, and uds_codec.encode_frame packs them into a BXB1-tagged frame; the Worker’s ResumeCall reply also carries the response bytes in a sidecar, runtime._handle_inbound_message_typed puts them into result["Data"], and the bridge hands them to the gRPC stub for deserialization. blockdb-py additionally caches mode / channel / stub on its side. There is no Python-local L1 read cache and no protocol-level batching inside the Executor process.

State and lifecycle

The actual values of ExecState (context.py): A few additional notes:
  • On resume, the state does not first go back to RUNNABLE: resume_call only writes resume_payload and enqueues with queued=True, and _perform_wait switches directly to RUNNING after switch() returns. Whether a waiting context is already queued is expressed by the queued flag, not by the state.
  • A DONE context is removed from _contexts immediately, so inflight_count() is just len(_contexts).
  • WAITING_FUNCTION_CODE is used for fetching source by digest. Concurrent misses on the same digest are coalesced locally (singleflight) in SdkBridge._function_code_fetches; only the leader emits CallWaiting(waitKind=function_code).
Attempts and requestId. _replace_existing_attempt runs inside submit_call: the old context is marked stale, removed from the queues, and terminated with _terminate_context(..., suppress_terminal_emit=True); attempt_seq comes from ExecuteCall.payload.attemptSeq (default 1), not from a local counter. requestId has the form req-<processInstanceId>-<seq> (next_request_id); the process-instance-id prefix ensures a restarted process never collides with the old process’s pending requests, and seq increases monotonically within the process. Stale resumes / cancels increment _stale_resume_dropped / _stale_cancel_dropped respectively. Cancellation. For WAITING_* states, cancel_call runs _terminate_context immediately (throw(CallCancelledError) into the greenlet, then GreenletExit); for RUNNING / RUNNABLE it only sets cancel_reason and enqueues, and check_cancelled raises at the next safe point. CancelCall.payload.reason defaults to worker_shutdown; the local timeout latch uses call_timeout, and the terminal state is classified as deadline_exceeded rather than cancelled. Deadline and budget. ExecContext.deadline_expired_reason() is the single decision point: budget_ms (callBudgetMs, which counts only CPU + IO backend + subfunction + sleep time, not queueing) takes precedence over the absolute call_deadline_ms. The safe points are: before entering the user function, before sending and after resuming in every _perform_wait, after waking from sleep, and before returning. In addition, _switch_greenlet_with_deadline calls signal.setitimer(ITIMER_REAL, remaining, 0.1) before every switch into the greenlet, and _on_deadline_signal raises _DeadlineSignalExpired only when that call is RUNNING and has actually expired. This SIGALRM path can preempt pure-CPU loops; loops that swallow BaseException or long C calls still rely on the Worker’s wedge handling. Heartbeat and connection. _heartbeat_loop is a separate thread that sends Heartbeat every heartbeat_interval_s; the payload contains runnableCount, inflightCount, availableContexts (max(0, max_inflight - inflight); 1<<30 when unlimited), memoryBytes, executorCpuUtilization, executorCpuSecondsTotal, executorProcessInstanceId, lastSchedulerActiveAtMs, runningCallId, callStateCounts, sentAtMs. ProcessMetricsSampler performs a capability probe with retries when ExecutorRuntime is constructed, and process startup fails if the probe fails; sampling failures at runtime keep the previous values. After the UDS disconnects, _refresh_transport_state marks every active context transport_lost and enqueues it to converge, and submit_call rejects new calls. Admission. A top-level call raises ExecutorCapacityExhausted when inflight_count() >= executor_max_inflight (replied as CallFailed(errorKind=executor_capacity_exhausted, retryable=true)); subfunctions skip this gate and only require the parent context to be resident in this process, otherwise subcall_parent_not_resident (not retryable). executor_max_runnable is only received and stored; it is not enforced locally.

Configuration

Command-line arguments (blockx_executor/__main__.py, passed in by executorRuntimeArgs in internal/worker/adapters/executor/spawner.go): Environment variables: In sandbox mode (EXECUTOR_SPAWN_MODE=sandbox), the Go side only passes through the variables in the childEnvKeys whitelist in internal/worker/adapters/executor/pool.go (everything in the table above plus LEAFAGE_ENDPOINT and PROXY_SOCKET_PATH); bare-process mode still inherits the entire parent environment. The container has an empty netns, so a direct OTLP connection is unavailable. The Python process itself is unaware of the spawn mode; see docs/specs/executor-sandbox-isolation.md in the blockx repo.

Extension points

  • Connect a new external SDK to the Worker IO channel: write a bridge module (see blockdb_bridge.py / noderpc_bridge.py for reference) that replaces the SDK’s transport seam with calls to provider.read_req / write_req, and install it in ExecutorRuntime.__init__; the backend name must match the backend registered in the Worker-side IO subsystem (see IO access subsystem). If the SDK is gRPC unary, reuse BridgeChannel(provider_getter, backend=..., streaming_methods=frozenset()) directly.
  • Add a new SDK entry point for user code: only add a thin wrapper under blockx_sdk that calls get_provider(); do not hold connections in the SDK. For restricted mode, also update the whitelist in blockx_audit/tables.py and the restricted_runtime facade.
  • Add a new ResumeCall error type: add a RemoteCallError subclass in sdk_bridge.py and extend _map_resume_error; use register_io_error_kinds for backend-specific mappings.
  • Add a heartbeat field or message type: add a constructor in messages.py, a constant in wire_keys.py, a branch in runtime._handle_inbound_message_typed, and update api/uds/types.go in step.
  • Change safe points or accounting: all expiry decisions go through ExecContext.deadline_expired_reason and raise_if_deadline_expired; do not write separate checks elsewhere.

Testing

python/tests/ is split into files by implementation boundary (test_<boundary>.py), and each file is split into TestCases by logical block (<Boundary><Theme>Test); the convention is in docs/specs/python-test-organization.md in the blockx repo. The main boundaries:
  • Runtime: test_executor_runtime_scheduler.py, test_executor_runtime_termination.py, test_executor_runtime_attempt.py, test_executor_budget.py, test_executor_subcall.py, test_executor_oom_hardening.py, test_terminal_bounds.py.
  • Protocol: test_executor_protocol_contract.py (ExecuteCall validation, inbound control messages, heartbeat fields), test_uds_codec.py, test_uds_session.py, test_envelope_*.py.
  • SDK hooks: test_executor_sdk_hook.py, test_sdk_bridge_provider.py, test_sdk_bridge_capability.py, test_perform_wait_emit_failure.py, test_blockdb_bridge.py, test_noderpc_bridge.py, test_sleep_patch.py.
  • Loading and isolation: test_module_registry.py, test_restricted_runtime.py, test_executor_audit_gate.py.
  • Context and metrics: test_exec_context_*.py, test_process_metrics_sampler.py, test_logging_setup.py, test_tracing_setup.py, test_debug_capture.py.
  • Auditor: test_audit_*.py.
Unit tests do not need a real UDS: when ExecutorRuntime("exec-1") is constructed without a session, messages go to drain_outbox(); feed inbound messages with handle_inbound_message(dict) and advance with run_until_idle(). E2E tests live on the Go side: cmd/worker/worker_process_*_test.go and cmd/syncinvoker/*_test.go spawn a real Worker + python/.venv/bin/python -m blockx_executor; e2e/system brings up the whole system; e2e/perf runs under systemd-run cgroup limits through test.sh at the repo root. See Testing for the commands. blockx repo specs:
  • docs/specs/python-function-executor.md — the in-process design of the Executor: scheduling, states, attempt / requestId, cancellation and deadlines, resource bounds (the key one).
  • docs/specs/worker-executor-connection-and-python-sdk-hook.md §6–§8 — the SDK hook approach, the SdkBridge synchronous contract, the suspend / resume sequence.
  • docs/specs/python-executor-process-metrics.md — sampling and degradation of process metrics in heartbeats.
  • docs/specs/2026-07-23-execute-call-function-code-reference.md — the protocol for fetching source by digest.
  • docs/specs/executor-sandbox-isolation.md — the containerd sandbox spawn mode.
  • docs/specs/2026-07-30-python-executor-cpu-optimization.md — the CPU optimization plan.
  • docs/specs/python-test-organization.md — the unit test organization standard.
  • docs/specs/function-code-python-whitelist.md, docs/specs/function-code-audit-design.md — user code whitelist and auditing.
On this site: