syncinvoker) is BlockX’s synchronous function-call service. The caller sends one gRPC unary Invoke, the server executes the function once in its local Python executor pool, and the same RPC returns the result, call_id, and attributed execution duration. It is deployed independently, bypasses the Coordinator, and does not go through the Worker’s slot / task flow; it only reuses the Worker’s executor pool, UDS protocol, Function Code View, and read-only IO subsystem. See Architecture overview for where it sits overall.
Responsibilities and boundaries
Responsible for:- Accepting
Invoke/DebugInvoke, executing a registered function (function_id) or ad-hoc source (inline_source), and returningresult_json/Failure,execution_duration_ms,queue_wait_ms. - admission: among
Nexecutors ×Kcontext slots, pick the executor with the lowest load and dispatch to it; returnRESOURCE_EXHAUSTEDwhen full. - deadline: at the deadline only send a soft
CancelCall, then wait for the executor’s real terminal state (cooperative cancel / SIGALRM) or the fallback sweep (wedge / heartbeat timeout) to converge. - subcall: subcalls issued by a parent function are dispatched on the same executor; parent and children share one deadline, one code epoch, and one IO scope.
Precheck: only statically audits the source without executing it; returnspass + findings.- Execution attribution:
execution_duration_ms= effective CPU of the whole subcall tree + this call’s IO backend duration;EXECUTOR_LOSTalways counts as 0 and is retryable. - Failure-origin labeling: every failure carries a
FailureOrigin(USER/SYSTEM/CAPACITY/UNKNOWN); admission-layer failures carry it viagoogle.rpc.ErrorInfo.metadata.
- Authentication, API keys, rate limiting, billing, business-level concurrency control (gateway side).
- Any write path:
HandleIOrejectsmode == "write"; no BlockDB writer / Event Writer / ResultHandler is wired in. - task orchestration: no readyQueue, fairness, failure retry, call result cache, callbuilder / writer plugin.
- Server-side retry: a call enters
EXECUTINGat most once; retries are new calls issued by the SDK.
- Terminal states are irreversible:
core.Registry.Finishis the single terminal gate and lets each call through only once; late executor / IO / subcall results are discarded. - A call enters
EXECUTINGat most once (Registry.Bindonly acceptsACCEPTED). - Every sync-call (including inline) pins the current FCV epoch at
ACCEPTED, sends it down viaExecuteCallPayload.TaskCodeEpoch, and subcalls inherit it. - Metering numbers come only from the executor’s real terminal payload, or are explicitly 0 on
EXECUTOR_LOST; they are never inferred from heartbeat snapshots. - Configuration constraint:
MaxCallDeadlineMs + heartbeat_interval < SchedulerStallTimeoutMs, otherwise the process refuses to start (cmd/syncinvoker/main.go). [reg.Bind → SendExecuteCall], subtree cleanup on deadline / client-gone, and executor-lost fan-out all shareService.dispatchMuand are pairwise mutually exclusive.
Code location
cmd/syncinvoker/ is about 1100 lines of non-test code, heavier than a typical cmd/ entry point. The reason is that it has no internal/syncinvoker/app/ package: the executor pool, the three FCV sources, IO backend assembly, and health probes all mirror cmd/worker’s assembly logic directly inside cmd/. Before changing the assembly, check whether the corresponding cmd/worker file already has an equivalent implementation.Core types and interfaces
core (Sans-IO)
core.CallState(internal/syncinvoker/core/call.go):ACCEPTED/EXECUTING+ six terminal states;IsTerminal().core.FailureCode/core.FailureOrigin/core.Failure: same values as the proto enums but without depending on proto;FailureTerminal(f)picks the terminal state by code and fills in the origin withdefaultFailureOrigin.core.Terminal: the final result handed to the waiter, carryingEffectiveDurationUs,QueueWaitUs,IOBackendDurationMs,Debug.core.Registry:Accept/AcceptSubcall/Bind/Finish/Drop/Subtree/CallsByRoot/InflightCount/MarkTerminating/ObserveSnapshot. Executor health is onlyHealthReady/HealthTerminating; the attach generation distinguishes a replacement from the ghost of a killed process.core.CallMeta: theEpochpinned at accept,CacheKey(cycle detection),DeadlineMs,Root.core.PickExecutor(loads, maxInflight)/core.RankExecutors(loads, maxInflight, shuffle)(admission.go): the default strategy and the admit-spread strategy.
adapters
adapters.ServiceConfig(service.go):DefaultDeadlineMs,ExecutorMaxInflight,MaxCallDeadlineMs,MaxAcceptedDeadlineMs,SchedulerStallTimeoutMs,AdmitSpread.adapters.Service:NewService(cfg, exec, codeView)hooks the callbacks onto the executor adapter at construction time (wireExecutorCallbacks). Main methods:
adapters.ExecutorAdapter(exec_adapter.go):Bind/BindSubcall/Unbind/MarkDraining/SendExecuteCall/SendResumeCall/SendCancelCall/Snapshots+SetCallbacks/SetExecutorLostCallback/SetIOHandler/SetSubcallHandler. Tests inject a stub; production passes*executor.Adapter.adapters.FunctionCodeView:CurrentEpoch()+Fetch(functionID, epoch);fccore.SnapshotStoresatisfies it directly. May be nil (inline only).adapters.GRPCServer(grpc_server.go): the three handlersInvoke/DebugInvoke/Precheck; on admission failuremirrorCallIDwritescall_idinto the trailerx-blockx-sync-call-id.callScope(service.go, unexported): a per-sync-calliocore.TaskIOScope+ cancel + in-flight IO counter; the whole subcall tree shares the root’s scope.
Protocol (api/grpc/syncinvoker/sync_invoker.proto)
Two layers of error semantics: admission-layer failures return a non-OK gRPC status (
INVALID_ARGUMENT / NOT_FOUND / RESOURCE_EXHAUSTED / UNAVAILABLE) with call_id and failure_origin in ErrorInfo.metadata; execution-layer failures return OK + success=false + Failure{code, message, retryable, origin}.
Data flow / execution flow
Service.Invoke is the main path. core only makes decisions (Registry state transitions, PickExecutor); the adapter does Bind / Send / timing / IO around it.
Key steps (service.go):
1
Pin epoch and resolve source
pinEpoch reads FunctionCodeView.CurrentEpoch(); resolveSource checks that exactly one oneof field is set, Fetches function_id at that epoch, and for inline returns the source directly while still pinning the epoch.2
Audit gate and argument validation
auditSource decides whether to gate according to FUNCTION_CODE_AUDIT_MODE: off skips auditing, enforce fails closed, dark only logs; functions whose space is in the allowlist are skipped. normalizeArgsJSON validates the JSON array and compacts whitespace in a single scan; the same bytes serve as the cycle key and are sent to the executor.3
Admission
admit: readyLoads filters Snapshots for executors that are Healthy with AvailableContexts > 0, reconciles attach generations via Registry.ObserveSnapshot, then uses Registry.InflightCount (Bind +1 / Finish -1) as the load key; PickExecutor picks the lowest load, a failed exec.Bind is retried once with a new pick, and if it still fails the result is UNAVAILABLE. With ADMIT_SPREAD=1 it switches to RankExecutors and tries Bind on each candidate in turn; exhausting the candidates yields RESOURCE_EXHAUSTED.4
Dispatch and wait
openCallScope creates the IO scope; SendExecuteCall sends uds.ExecuteCallPayload{TaskID: callID, TaskCodeEpoch, CallDeadlineMs, Debug, Audit}. Then a three-way select: terminal state on w.ch, the deadline timer, and ctx.Done().5
Terminal state and attribution
onCallTerminal passes through the single-shot Registry.Finish gate, Unbinds, folds treeEffective (accumulated effective µs of subcalls) into the root, closes the scope and FinalizeCallIO, and signals the waiter. buildOutput computes execution_duration_ms = effective/1000 + IOBackendDurationMs; EXECUTOR_LOST is always 0. queue_wait_ms is taken directly from the terminal payload’s QueueWaitUs, reported by the executor, and is not folded into execution_duration_ms.State and lifecycle
Transitions ofcore.CallState are owned exclusively by Registry. TERMINAL_ADMISSION_DENIED lands in two ways: a failure before Bind goes through Registry.Drop (no terminal state is recorded; the call is simply removed from the table); only when openCallScope / SendExecuteCall fails after Bind does it Finish(StateTerminalAdmissionDenied).
The deadline path (onDeadline) converges in layers, and every layer yields a real terminal state:
- Only send a soft
SendCancelCallto theSubtreeand the root, withoutFinishing either;quiesceCallScopestops new IO. - Bounded wait on
w.ch, with windowdeadlineEscalationWindow = max(5s, 2×SchedulerStallTimeoutMs + 2s). All four paths deliver the signal: the executor’s cooperative cancel or SIGALRM self-heal sends a real terminal state;CheckWedgedExecutorsdetects a scheduler frozen for longer thanSchedulerStallTimeoutMswhoseRunningCallIdis past its deadline →reapExecutorCause("wedge_sweep"); heartbeat timeout → the adapter’s executor-lost callback →reapExecutor; window exceeded →reapExecutorCause("deadline_escalation")forces convergence. - After the terminal state arrives,
CallsByRootclears subcalls added during the grace period, and the finalFailureis uniformly overridden toTIMED_OUT(originUNKNOWN).
onClientGone) is different: it immediately Finishes the root, cancels the subtree, MarkDrainings, and returns the gRPC context error; only subcall CPU and IO durations already observed on the Go side are recorded.
Executor side: Registry tracks only {ready, terminating}. reapExecutor marks the executor terminating, fans out EXECUTOR_LOST to all in-flight calls, and calls PoolManager.KillExecutor; the pool’s waitLoop spawns a replacement process, which rejoins admission only once ObserveSnapshot sees a higher attach generation.
Differences from Worker
Reuse boundary (
docs/specs/sync-invoker.md §6):
internal/worker/adapters/executor:Adapter(UDS server,Bind/BindSubcall/Unbind/MarkDraining, heartbeat,Snapshots),PoolManager(forks N Python executors, restarts on crash,KillExecutor),IOHandler/SubcallHandler/IORequestinterfaces.internal/worker/core: onlyExecutorSnapshotis used.internal/worker/devstub:StubBackendAdapter, the IO stub when no external backend is configured.internal/functioncode/*:SnapshotStore, Redis / BlockDB syncer,audit.Pool.internal/io/core,internal/io/assembly,internal/io/adaptive:WorkerIOScope/TaskIOScope, backend assembly, AIMD admission.internal/sdk/*: blockdb / noderpc / localtestservice / router clients.api/uds:ExecuteCallPayload/CallCompletedPayload/CallFailedPayload/ResumeCallPayload.
Precheck and audit gate
Precheck and Invoke’s audit gate share the same audit.Auditor (audit.NewPool, started at process startup for all three modes off / enforce / dark, Size: 2), but the two are decoupled:
FUNCTION_CODE_AUDIT_MODEonly controls the Invoke gate:SetAuditGateOff(true)(off) /SetAuditDark(true)(dark) / neither (enforce).Precheckignores the mode and the space allowlist, always audits under the_entry, and returns the real verdict. A failed audit isOK + pass=false + findings; only when no verdict can be produced is the status non-OK:FAILED_PRECONDITION(no auditor),UNAVAILABLE(auditor down),INVALID_ARGUMENT(empty source),CANCELED/DEADLINE_EXCEEDED(caller ctx canceled, counted separately).- In enforce mode, an auditor startup failure prevents the process from starting; off / dark degrade to no auditor (Invoke is not gated, Precheck returns
FAILED_PRECONDITION).
Failure classification and attribution
Execution-layer failures are mapped infailedTerminal (service.go) from uds.CallFailedPayload.ErrorKind, without parsing the message:
classifyFailure:execute_call_rejected→CODE_LOAD_FAILED;runtime_error/cancelled→CALL_FAILED;deadline_exceeded→TIMED_OUT;transport_lost/resume_send_failed/terminal_decode_failed→EXECUTOR_LOST;executor_capacity_exhausted→EXECUTOR_CAPACITY_EXHAUSTED; the IO classifier’ssystem_error/retryable_io/param_error/non_retryable/timeout/scope_closed→IO_PROXY_FAILED.failureRetryable:EXECUTOR_LOST/EXECUTOR_CAPACITY_EXHAUSTEDare always true;IO_PROXY_FAILEDhonors the payload’s retryable flag; everything else is false.classifyFailureOrigin: byErrorKind+ internalDetailCode(e.g.grpc:NotFound→USER,syncinvoker:io_not_configured→SYSTEM) +ChildOrigin(subcall_failedinherits the subcall’s origin);deadline_exceededisUNKNOWN. The full table is indocs/specs/sync-invoker-failure-origin.md.- Admission layer:
denyAdmission→withCallIDwritescall_idandfailure_originintoErrorInfo.metadata;classifyAdmissionOrigindetermines the origin by reason / gRPC code.
Configuration
Everything lives incmd/syncinvoker/config.go; environment variables override DefaultConfig().
ShutdownTimeoutMs is fixed at 5000 with no environment variable override. FunctionSnapshotRetention() feeds both the FCV syncer and ServiceConfig.MaxAcceptedDeadlineMs.
Observability
Metrics are defined ininternal/obs/metrics_syncinvoke.go; semantics and dashboards are in docs/sync-invoker-grafana.md in the blockx repo. The most important ones:
blockx_syncinvoke_calls_total{status, failure_code}: execution-layer terminal-state count (excludes admission rejections).blockx_syncinvoke_admission_denied_total{reason}: rejections before dispatch.blockx_syncinvoke_call_duration_milliseconds/blockx_syncinvoke_billed_duration_milliseconds/blockx_syncinvoke_queue_wait_milliseconds: end-to-end wall clock, attributed duration, queueing.blockx_syncinvoke_deadline_overruns_total{resolved_by}: which layer converged the calls that overran their deadline (completed_late/cooperative_cancel/in_process_timeout/executor_reaped/escalation_reap).blockx_syncinvoke_executor_reaps_total{cause}/blockx_syncinvoke_executor_lost_victims_total{cause}: hard-kill frequency and collateral count; should be 0 when healthy.blockx_syncinvoke_precheck_total{result}.
obs.Tracer("blockx-syncinvoker"); under the syncinvoker.invoke span are resolve_source / admission / dispatch / wait_terminal / build_output.
Deployment shape
sync-invoker is deployed as an independent fleet parallel to the worker fleet (EC2 systemd +nerdctl + host containerd sandbox), gRPC on 9900, metrics/health on 9901; see Deployment overview and docs/deploy/syncinvoker-systemd-nerdctl.md in the blockx repo for details.
Extension points
- Adding a failure classification: change
classifyFailure/failureRetryable/classifyFailureOrigin(service.go); a new code also requires updatingcore.FailureCode, the protoFailureCode,failureCodeToProto, andfailureCodeLabel. - Adding an admission strategy: add a pure function in
core/admission.goand switch on it inService.admitvia aServiceConfigflag; depend only onExecutorLoad. - Adding a read-only IO backend: append an
assembly.ModuletoioBackendModulesincmd/syncinvoker/io.go, keeping the registration identical tocmd/worker. - Adding an FCV source: add a branch to
setupFunctionCodeViewincmd/syncinvoker/function_code.gothat returnsfunctionCodeSetup{view, start, close, desc, devstub}. - Adding an RPC: change
api/grpc/syncinvoker/sync_invoker.proto→make proto→ add a handler inGRPCServer→ add transport-agnosticXxxInput/XxxOutputtoService. - Adding a config option: a
Configfield +DefaultConfig+envXxxinLoadConfigFromEnvChecked, passed through toServiceConfigwhen needed.
Testing
internal/syncinvoker/core/*_test.go:Registryterminal gate, executor health / attach generation,PickExecutor/RankExecutors.internal/syncinvoker/adapters/service_test.go: admission, subcall races,onDeadline/onClientGone, duration aggregation, failure classification;audit_gate_test.go: enforce / dark / allowlist;precheck_test.go: the various Precheck return codes.cmd/syncinvoker/syncinvoker_process_test.go:startSyncInvokerlaunches a real process and covers inline / function_id / debug / read-only IO / write rejection / subcall /RESOURCE_EXHAUSTED/ CPU hog self-heal / wedge hard kill / graceful shutdown / health probes.cmd/syncinvoker/syncinvoker_perf_test.go: latency-breakdown perf test;syncinvoker_sandbox_e2e_test.gocarries the build tagsandbox_e2e.- The
Makefilelistscmd/syncinvokerunderSLOW_TEST_PACKAGES, andmake testruns it file by file (each file with its ownSYNCINVOKER_TEST_TIMEOUT, default 3m) to avoid a whole-package timeout.
Related docs
- blockx repo
docs/specs/sync-invoker.md: shape, protocol, admission, deadline layering, subcall, lifecycle, reuse boundary, attribution, capacity. - blockx repo
docs/specs/sync-invoker-failure-origin.md:FailureOriginsemantics and mapping table. - blockx repo
docs/sync-invoker-grafana.md: metric list, PromQL, troubleshooting paths. - blockx repo
docs/deploy/syncinvoker-systemd-nerdctl.md: EC2 runbook. - On this site: Worker, Call execution subsystem, Python Executor, Function Code View, IO access subsystem, Observability, Protocols and interfaces.