Responsibilities and boundaries
The Worker is responsible for:- Exposing the gRPC
WorkerService(api/grpc/worker/worker.proto):RequestTaskSlot,SubmitTask,GetTaskResult,WatchTasks.WatchTasksis a server-streaming RPC; the Worker listens on a single TCP port. See Protocols and interfaces for protocol details. - Maintaining the local slot table and task index, with atomic admission and
taskId-level idempotency. - Pinning one
taskCodeEpochper task, running the Call Builder serially, handing the call list to the dispatcher for parallel execution, and finally running the Writer Plugin serially. - Converging the
TaskResult, pushing it toWatchTaskssubscribers, and retaining it forresultRetentionMs. - Registering with etcd and publishing periodic heartbeats so the Coordinator can select it.
- Generating
taskId, building payloads, DAG orchestration, or scheduled triggering (these live upstream). - Cross-Worker deduplication, global result queries, or local recovery after a crash. After a restart, all old slots, old results, and subscriptions are lost.
- Call-level scheduling details (dispatcher, executor adapter). See Call execution subsystem for that part.
WorkerCoreis a pure in-memory, Sans-IO decision engine. It never reads the clock; every method receivesnowexplicitly.- The slot table is the single source of truth for capacity and task state transitions; the etcd heartbeat is only a derived view of it.
slotTableandtaskIndexare updated together within the same method call; there is no torn state.- Once a task is successfully activated, every subsequent failure (Builder, call, Plugin, timeout, Watch disconnect) converges into a terminal state in
TaskResult; no further submission-layer errors are returned. - The adapter does not keep a second copy of slot or phase truth. It only executes the commands the core emits and feeds results back as events.
Code location
Core types and interfaces
core (internal/worker/core/)
WorkerCore(worker.go): a single-threaded state machine. It holdsslots []SlotEntry,taskIndex map[string]*TaskIndexEntry,tasks map[string]*TaskContext,ready, anddraining.- Input events (all in
worker.gounless noted):HandleRequestTaskSlot(taskID, ttlMs, now),HandleSubmitTask(req, adapterTaskCtx, now),HandleTaskActivationPrepared(taskID, slotID, taskCodeEpoch, now),HandleTaskActivationFailed(taskID, failureCode, now),HandleTaskPhaseFinished(taskID, phase, outcome, now),HandleTaskTimedOut(taskID, now),HandleWatchAttached/HandleWatchDetached/HandleWatchDisconnected(taskID, now),HandleSlotLeaseExpired(slotID, now)(worker_slots.go),HandleGetTaskResult(taskID). - Timer-scan helpers:
ExpiredSlots(now),TimedOutTasks(now),WatchDisconnectedTasks(now),PurgeExpiredResults(now). The adapter calls them on every tick and feeds the results back into the correspondingHandle*. - Output commands (
types.go):PrepareTaskActivation,StartBuilderPhase,StartCallPhase{CallList, Streaming},StartWriterPhase{Outputs},ConvergeTask{State, Result}. They are returned embedded in the result typesRequestTaskSlotResult,SubmitTaskResult,ActivationResult,PhaseTransitionResult,LeaseExpiredResult, andGetTaskResultOutput. TaskContext(types.go):TaskID,SlotID,TaskCodeEpoch,Phase,DeadlineMs,WatchAttached/WatchEverAttached/WatchDisconnectDeadlineMs,CallFailurePolicy,CallRetryPolicy,AcceptedAtMs,AdapterTaskCtx, plus thePhaseOutcomeof each of the three phases.DeriveHeartbeat(now) etcd.WorkerHeartbeat: derivesReservedSlots/RunningTasksfrom the slot table.
internal/worker/adapters/)
GRPCServer(grpc_server.go): implementsRequestTaskSlot/SubmitTask/GetTaskResultand delegatesWatchTaskstoSubscriptionManager.workerErrToStatus(wire.go) mapscore.WorkerErrorto a gRPC status; business error codes go throughworkerCodeToGRPC:InvalidArgument → InvalidArgument,NoSlot → ResourceExhausted,Unavailable → Unavailable,InvalidSlot → FailedPrecondition,NotFound → NotFound,SlotUncertain → Aborted.Orchestrator(orchestrator.go): bridgesWorkerCoreandDispatcherCore. Entry methods:RequestTaskSlot,SubmitTask,GetTaskResult,TickTimers,DeriveHeartbeat,SetDraining,WaitDrain,RunDispatchLoop,SyncExecutorSnapshots,CheckWedgedExecutors.OrchestratorDeps(orchestrator.go): constructor dependencies, includingBuilderRegistry,PluginRegistry,IOScope,Admission,FunctionCodeViewProvider,EpochResolver,ExecAdapter,AuditGate, andStreamBuild.taskRuntime(task_runtime.go): the adapter-side runtime record for a task. It holdsioTaskCtx,TaskIOScope,ctx/cancel,functionCodeView, the streaming producer handle, andTaskStatistic. It does not hold phase or slot truth.SubscriptionManager(stream_subscriber.go): onestreamSubscriberperWatchTasksstream; translates the first attach / last detach intoOrchestrator.HandleWatchAttached/HandleWatchDetached.EtcdPublisher(etcd_publisher.go):Register,RunHeartbeatLoop,Deregister. The key isKeyPrefix + workerAddr; the lease TTL defaults to 10s.ExecutorAdapterinterface (exec_adapter.go): the UDS executor surface the Orchestrator depends on; the production implementation isexecutor.Adapter.
internal/worker/app/)
Profile(app.go):Deployment(block/bundle, used only as an observability label),Service(tracing / Prometheus service name),WorkerRegistryPrefix(etcd registration prefix),UsageService,Builders,Plugins,TuneDefaults(only changes built-in defaults).Run(p Profile): the single wiring and startup/shutdown sequence.
Data flow / execution flow
The Worker follows an “input event → core decision → adapter executes command → event fed back” pattern.Orchestrator guards WorkerCore with o.mu and runtimes with runtimeMu; DispatcherCore is only mutated on the dispatch actor (the RunDispatchLoop goroutine), and other goroutines post events to it via postDispatchEvent. The lock order and actor invariants are documented in the header comment of orchestrator_actor.go and enforced by actor_invariants_test.go.
A few key points:
SubmitTaskonly goes as far asHandleSubmitTaskbefore returning. The core immediately creates aTaskContext(Phase = Preparing) so that duplicate submissions hit the idempotency path; activation and phase execution happen asynchronously in theactivateAndRungoroutine.- Activation failures (epoch resolution failure,
Pinfailure,NewTaskScopefailure) go throughHandleTaskActivationFailedwith failure codeACTIVATION_FAILEDorIO_SCOPE_FAILED. - Builder phase:
runBuilderPhasefirst acquiresbuilderSem, then looks upFunctionCallConfig.TypeinBuilderRegistry. If it is not found, the task converges directly withBUILDER_NOT_FOUND. If the builder implementsStreamingCallBuilderandStreamBuild.Enabledis set, the Builder phase only runsPrepareStreamand feeds back aCallStreamMarker; the core emitsStartCallPhase{Streaming: true}, andrunCallStreamPhasethen starts the producer goroutine underscanSem. - Calls phase:
runCallPhaseacquiresexecutorSem, prepares the call context outside the lock withdispCore.PrepareCallPhaseWithDigests, then postsevCallPhaseStartedto the actor. Call-level scheduling, retries, and subcalls belong to the Call execution subsystem. When the actor receives theConvergeTaskCallscommand, it callsHandleTaskPhaseFinished(PhaseCalls). - Plugin phase:
runWriterPhaseacquirespluginSemand looks upPluginRegistrybyResultHandler.Type. If noResultHandleris configured, no plugin runs and success is fed back directly. - Terminal state:
executePhaseResultsendsCancelCallto any calls still in flight, cleans up dispatcher state, closes theTaskIOScope, deletes thetaskRuntime, writes thetask_finishedlog, and notifies subscribers throughOnTaskTerminal. Slot release has already completed synchronously insideWorkerCore.convergeTask. - Timers:
app.Runcallsorch.TickTimers()every 500ms. It handles slot lease expiry, task timeouts, Watch grace expiry, and result-cache cleanup in turn, followed bySyncExecutorSnapshots,CheckWedgedExecutors, andexecAdapter.CheckHeartbeatTimeouts. - Shadow forwarding: with
ShadowSubmitTargetAddrconfigured,SubmitTaskShadowForwardermirrors the request to another Worker at the configured sampling rate after the primary submission succeeds; the receiving side recognizes it viaisShadowSubmitand strips theResultHandler.
CheckWedgedExecutors in orchestrator_wedge.go is a last-resort safeguard: if an executor is still heartbeating but its LastSchedulerActiveAtMs has not advanced for longer than SchedulerStallTimeoutMs while it reports a RunningCallID, the hook injected via SetExecutorKiller hard-kills it so the Pool Manager can spawn a new process. app.Run derives SchedulerStallTimeoutMs from CallDeadlineMs and clamps it to a safe lower bound.
State and lifecycle
Slot state machine
SlotState has only three values (core/types.go): FREE, ALLOCATED, RUNNING. The number of slots is fixed at taskSlots, and SlotID takes the form slot-N.
HandleRequestTaskSlotfirst performs the idempotency check (if the sametaskIdis still active, it returns a stable snapshot; if it is already terminal and within the retention window, it returns the terminal state), then checksfreeCapacity() = TaskSlots - (ALLOCATED + RUNNING), and finally does a linear scan to allocate the firstFREEslot.- When
SubmitTaskcarries noslotId, the core runsHandleRequestTaskSlotinline withDefaultTTLMsas the TTL. - If the lease expires between
SubmitTaskandHandleTaskActivationPreparedand the slot is reclaimed, the core converges that task toFAILEDwithSLOT_LOST.
Task-visible states and internal phases
Externally visible states (commontypes.TaskState): ALLOCATED, RUNNING, SUCCEEDED, FAILED. Internal phases (core.TaskPhase): Preparing, Builder, Calls, Plugin, Terminal. All internal phases appear as RUNNING externally.
Progression rules in HandleTaskPhaseFinished:
Builderfails →FAILED, with the failure code taken from the outcome orBUILDER_FAILED. Succeeds →Calls.Callsfails →FAILED. Succeeds → always emitsStartWriterPhase.Pluginfinishes → terminal state; the failure code is taken from the outcome orPLUGIN_FAILED, andPluginResultsis written intoExecuteResult.- Out-of-order phase events (
tc.Phase != phase) are silently dropped.
Time semantics
Idempotency semantics: both
RequestTaskSlot and SubmitTask are idempotent by taskId within a single Worker. When the same taskId is already in tasks, SubmitTask returns the RUNNING snapshot; if it is already terminal and within the retention window, it returns the terminal snapshot without re-executing.
Task reclamation reuses the WatchTasks stream to detect disconnects; there is no separate owner lease and no Cancel API. The Watch grace row in the table above is exactly this path, and WatchDisconnectGraceMs defaults to 0, so it is off by default. See docs/specs/2026-07-16-task-owner-lease-and-cancellation.md in the blockx repo for the design review.
Startup and shutdown order
1
Startup
Run proceeds in order: validate the Profile → load configuration (built-in defaults → TuneDefaults → WORKER_CONFIG JSON → environment variables) → initialize tracing / Prometheus / chlog → wire the IO backend, Function Code View, registries, Orchestrator, SubscriptionManager, and GRPCServer → start the UDS server and the executor PoolManager → RunDispatchLoop → initial SyncExecutorSnapshots → 500ms ticker → gRPC Serve → only after the first healthy executor appears (WorkerCore.SetReady) does it register with etcd and start heartbeats.2
Shutdown (SIGINT / SIGTERM)
Phase 1 drain: stop registration →
orch.SetDraining() (new RequestTaskSlot calls and not-yet-activated SubmitTask calls return Unavailable) → etcd Deregister → WaitDrain until the active task count reaches 0 or DrainTimeoutMs expires. Phase 2 force: cancel the lifecycle ctx → poolMgr.Stop() → execAdapter.Close() → CloseIOScope() → subMgr.CloseAll() → GracefulStop (then Stop after 5s).Configuration
The configuration struct isapp.WorkerFullConfig (internal/worker/app/config.go). Load order: DefaultWorkerFullConfig() → Profile.TuneDefaults → the JSON file pointed to by WORKER_CONFIG (LoadWorkerFullConfigFrom, which only overrides fields present in the file) → environment variables (per-field envOverride* in app.Run). An explicitly written file / env value always wins, which is also the channel for rolling back a profile’s defaults.
core.Config.Validate requires taskSlots > 0, taskDeadlineMs <= maxTaskTimeoutMs, and defaultRetryJitterPercent between 0 and 5. See Call execution subsystem for the dispatcher-related fields (dispatcher.*).
Extension points
- Adding a Call Builder: implement
cbtypes.CallBuilder(optionallyStreamingCallBuilder) ininternal/plugin/callbuilder/, define aCallBuilderNameincbtypes, add a case to thenewBuilderRegistryswitch ininternal/worker/app/app.go, and add the name toBuildersin the Profile of everycmd/*/main.gothat needs it. A builder not listed in the Profile is not registered, and tasks fail withBUILDER_NOT_FOUND. See Plugin system for details. - Adding a Writer Plugin: implement
evtypes.WriterPlugin, add a case innewPluginRegistry, and add it toPluginsin the Profile. If the plugin depends on a specific backend address, add validation invalidateProfileRuntimeConfig. - Adding an entry point / deployment form: write only a new
cmd/<name>/main.gothat declares anapp.Profileand callsapp.Run. Do not hand-roll wiring in the entry point.Profile.Validatecurrently accepts only theblockandbundleDeploymentvalues. - Adding a configuration item: add the field and default in
WorkerFullConfig, add anenvOverride*inapp.Run, and add a constraint inValidateif needed. - Changing slot / phase / idempotency semantics: change only
internal/worker/core/worker.go, and add core unit tests ininternal/worker/core/worker*_test.go. No new state decisions should appear in the adapter. - Changing phase execution side effects:
orchestrator_phases.go. Any new path that touchesdispCoremust respect the I1–I5 invariants in the header oforchestrator_actor.go;actor_invariants_test.gocatches violations. - Adding a public RPC: change
api/grpc/worker/worker.proto, regenerateworkerpb, add the handler ingrpc_server.go, and put the pb conversion inwire.go.
Testing
Tests are organized in three layers perdocs/specs/worker-test-organization.md:
- core unit (
internal/worker/core/):worker_test.go,worker_activation_test.go, andworker_watch_test.gocoverWorkerCore;dispatcher_*_test.gocoversDispatcherCore. No transport or processes are involved. - adapter integration (
internal/worker/adapters/):orchestrator_*_test.go(lifecycle, phases, dispatch, stream, budget, deadline_repro, subcall, send_failure, and other slices),grpc_server_test.go,stream_subscriber_test.go,etcd_publisher_test.go,actor_invariants_test.go. They use a fake executor adapter and do not start Python.internal/worker/app/*_test.gocovers the Profile, configuration parsing, and etcd registration wiring. - process e2e (
cmd/worker/worker_process_*_test.go,cmd/bundle_worker/*_test.go): starts real worker processes and Python executors to verify the public gRPC contract (activation, watch, shutdown, timing, subcall, io, function code, and other slices).
<boundary>_test.go or <boundary>_<slice>_test.go; each file belongs to exactly one implementation boundary and one test layer. See Test organization and commands for more commands.
Related docs
Specs in the blockx repo:docs/specs/worker.md: detailed Worker design. Protocol, state model, execution flow, Executor Pool Manager, heartbeat and failure semantics.docs/specs/architecture.md§4.2.1 / §4.2.2: the Worker’s responsibilities in the overall architecture, plus the three layers of concurrency control: phase-level admission, the per-task ramp-up window, and backend admission.docs/specs/worker-test-organization.md: the Worker test organization standard.docs/specs/2026-07-16-task-owner-lease-and-cancellation.md: review record for the Watch-disconnect reclamation design (watchDisconnectGraceMs).docs/specs/2026-07-28-registry-prefix-migration.md: format and migration ofworkerRegistryPrefix.
Call execution subsystem
dispatcher, executor adapter, subcalls, and the wedge safeguard
Task Resource Coordinator
Who calls RequestTaskSlot, and how the Coordinator consumes heartbeats
Plugin system
Call Builder and Writer Plugin interfaces
Function Code View
Where taskCodeEpoch and code snapshots come from
IO access subsystem
TaskIOScope and backend admission
Protocols and interfaces
gRPC / etcd protocol details