taskId, submits a TaskInput to a Worker, and the Worker runs the three phases Builder → Calls → Plugin locally and converges on a TaskResult. This page covers only that main path; for component details see Worker, Coordinator, Call execution subsystem, and Plugin system; for the overall architecture see Architecture overview.
The main path relies on the following constraints (see also docs/specs/architecture.md §2.1 in the blockx repo):
- Task-centric: shared state, read cache, and timeouts are all anchored to the task.
- Single-Worker execution: a task runs on exactly one Worker. The Worker is unaware of the Coordinator and only exposes
RequestTaskSlot/SubmitTask. - All calls within a task run in parallel: there are no ordering dependencies between calls; the dispatcher ramps them up gradually in windows.
- Fixed function version per task: on activation, a
taskCodeEpoch(TaskContext.TaskCodeEpoch) is pinned; code updates during that time only affect subsequent tasks. - Read-only functions: user functions never write to the database directly; all writes go through a Writer Plugin.
- No task-level automatic retries: the Worker only does bounded attempt retries for individual calls (
CallRetryPolicy); whether to resubmit a task is decided by the Client based onTaskResult.executeResult.retryable. - No cancellation: only task timeouts (
taskTimeoutMs) and optional Watch-disconnect reclamation.
Task model
The structure submitted externally is the gRPCTaskInput (api/grpc/worker/worker.proto); the Go counterpart is commontypes.TaskInput (internal/common/types/types.go):
types (internal/plugin/callbuilder/types/types.go): BlockTableCallConfig (dbScan), CallListCallConfig, BlockBundleCallConfig (bundleScan). Currently registered Writer Plugin types (internal/plugin/event/types/types.go): BlockWriteResultHandler, ReturnValueResultHandler, BlockBundleWriteResultHandler, TableUpsertsResultHandler.
A minimal JSON example (assembled from the fields of commontypes.TaskInput and call_list.CallListCallConfigBuilderDecl; not a fixture from the repo):
TaskInput carries no block context at the top level. Block information is carried by the specific Builder’s config (for example dbscan.DBScanBuilderDecl.Block) and then written into each call by the Builder.
When the Worker receives a request, submitTaskFromPB in internal/worker/adapters/wire.go converts the proto into commontypes.SubmitTaskRequest (also validating that config is valid JSON and task_timeout_ms >= 0); parseTaskPayload in internal/worker/adapters/payload.go then wraps it into commontypes.TaskCtx:
TaskCtx is the minimal context shared by the Builder, Writer Plugin, and IO scope; Stage is rewritten by the adapter to builder / executor / plugin as phases advance. WorkerCore only hangs it on TaskContext.AdapterTaskCtx as an opaque pointer.
End-to-end sequence
The 16 steps of the main path are laid out below (see alsodocs/specs/architecture.md §3).
Client builds the task
task_id and prepares a TaskInput. BlockX is not responsible for task generation, DAG orchestration, or scheduled triggering.Client asks the Coordinator for placement (optional)
CoordinatorService.ReserveWorkerSlot(task_id) (api/grpc/coordinator/coordinator.proto). The Coordinator rebuilds its Worker view from the heartbeats it watches in etcd (CoordinatorCore.ApplyWorkerUpdate) and picks candidates with SelectCandidate. Without going through the Coordinator, the Client can call RequestTaskSlot on a Worker directly, or call SubmitTask directly and let the Worker request the slot automatically.Coordinator requests a slot from the Worker
CoordinatorServer.ReserveWorkerSlot (internal/coordinator/adapters/grpc_server.go) tries candidate Workers sequentially, calling WorkerService.RequestTaskSlot(task_id, ttl_ms) on each, with ttl_ms taken from CoordinatorCore.SlotTTLMs(). On rejection it moves to the next candidate; an RPC timeout counts as “possibly allocated” and is retried a bounded number of times against the same Worker with the same task_id.Worker allocates a slot atomically
WorkerCore.HandleRequestTaskSlot (internal/worker/core/worker.go) checks freeCapacity() under o.mu, switches a FREE slot to ALLOCATED, writes LeaseDeadlineMs = now + ttlMs, and returns slot_id and state=ALLOCATED.Coordinator returns worker_addr + slot_id
slot_id is just an opaque string to it.Client submits SubmitTask
worker_addr and calls WorkerService.SubmitTask(task, slot_id). Orchestrator.SubmitTask (internal/worker/adapters/orchestrator.go) first computes the effective deadline (WorkerCore.EffectiveTaskDeadlineMs) and parses the payload, then hands off to WorkerCore.HandleSubmitTask.Worker validates and activates
HandleSubmitTask does the following in order: ready/draining check; if the same taskId is already in w.tasks, return RUNNING, and if a retained result exists, return the terminal state; if no slot_id was provided, run HandleRequestTaskSlot inline once; verify that the slot exists, is ALLOCATED, and is bound to the same taskId; once that passes, create a TaskContext (Phase = Preparing) and return the PrepareTaskActivation command. The gRPC layer returns state=RUNNING at this point.Adapter completes activation
Orchestrator.activateAndRun (internal/worker/adapters/orchestrator_phases.go) resolves the current taskCodeEpoch in a goroutine, pins a Function Code View snapshot, then feeds back WorkerCore.HandleTaskActivationPrepared: the slot switches to RUNNING, Phase advances to Builder, and StartBuilderPhase is emitted. It then creates the TaskIOScope (WorkerIOScope.NewTaskScope) and the adapter-side taskRuntime. If any step fails, it goes through HandleTaskActivationFailed with failureCode ACTIVATION_FAILED or IO_SCOPE_FAILED.Builder phase generates the call list
runBuilderPhase first acquires builderSem, looks up the Builder in BuilderRegistry by FunctionCallConfig.Type, and calls CallBuilder.Build(ctx, taskCtx, io) to get a cbtypes.CallList. The result is handed back to the core via HandleTaskPhaseFinished(PhaseBuilder, ...), and the core emits StartCallPhase.Calls phase executes in parallel
runCallPhase acquires executorSem and calls DispatcherCore.PrepareCallPhaseWithDigests to set up TaskDispatchState; the dispatch loop then sends calls to the Python Executor over UDS via ExecuteCall in windows. SDK IO requests inside the Executor come back to the Worker as CallWaiting, are handled by the IO scope, and then ResumeCall; subfunction calls likewise return to the dispatcher for rebinding. See Call execution subsystem and IO access subsystem for details.Converge outputs
DispatcherCore.checkConvergeTask produces ConvergeTaskCalls{Success, FailureCode, Outputs}; Outputs contains only the return values of successful root calls, and subcall return values are not included. The adapter converts it into a PhaseOutcome and calls HandleTaskPhaseFinished(PhaseCalls, ...).Plugin phase writes results
StartWriterPhase{Outputs}; runWriterPhase acquires pluginSem, and if ResultHandler is non-empty, looks up the plugin in PluginRegistry and calls WriterPlugin.Execute(ctx, taskCtx, outputs, io), producing []PluginResult.Converge to the terminal state
HandleTaskPhaseFinished(PhasePlugin, ...) calls convergeTask: Phase = Terminal, taskIndex switches from ActiveSlotID to Result, RetainedUntil = now + ResultRetentionMs, and within the same critical section releaseSlot returns the slot to FREE, removes the task from w.tasks, and emits ConvergeTask. The adapter’s executePhaseResult cancels unfinished calls, closes the TaskIOScope, deletes the taskRuntime, and finally triggers OnTaskTerminal.Client fetches the result
SubmitTask does not return a TaskResult. The Client receives the terminal TaskUpdate via a WatchTasks stream, or polls GetTaskResult. OnTaskTerminal is wired to SubscriptionManager.NotifyTerminal (internal/worker/adapters/stream_subscriber.go), which pushes the terminal state once to every stream subscribed to that taskId.Heartbeat reflects capacity
WorkerCore.DeriveHeartbeat derives reservedSlots / runningTasks from the slot table, and EtcdPublisher.RunHeartbeatLoop writes to etcd periodically; the Coordinator updates its view from this.Slots and admission
RequestTaskSlot and SubmitTask are two steps of the same Worker protocol; whether the caller is the Coordinator or the Client makes no difference to the Worker.
RequestTaskSlot(task_id, ttl_ms)only reserves; it does not execute.ttl_msmust be positive (<= 0returnsInvalidArgument) and only controls automatic reclamation ofALLOCATEDslots; it is unrelated to the task timeout.TickTimersscansExpiredSlotsevery 500ms; expired slots are reclaimed byHandleSlotLeaseExpired, which also clears task records still inPreparing.SubmitTask(task, slot_id?): whenslot_idis provided it must match a record that isALLOCATEDand bound to the sametaskId, otherwiseInvalidSlot; when omitted, the Worker requests a slot inline once usingDefaultTTLMs, returningNoSlotif there is no capacity.- Idempotency: while the same
taskIdisALLOCATED/RUNNING,RequestTaskSlotreturns the sameslot_id; once it has enteredRUNNINGor its result is still within the retention window,SubmitTaskreturns the stable current state and never starts another execution. After the old execution finishes and its result expires, the sametaskIdcan be allocated a slot again. - There is no release interface: if you obtain a slot and then abandon the submission, you can only wait for the TTL to reclaim it.
docs/specs/architecture.md §4.3.1): every failure before the slot switches from ALLOCATED to RUNNING is returned as a gRPC status and never enters TaskResult; once HandleSubmitTask has created a TaskContext, subsequent activation failures, Builder / Calls / Plugin failures, and timeouts all converge into a TaskResult terminal state.
taskIndex, and heartbeat derivation see Worker; for the Coordinator’s candidate selection, circuit breaking, and uncertain-state retries see Coordinator.
The three phases
WorkerCore only decides whether to enter the next phase and when to write the terminal state; actual execution happens in the adapter. Phases advance via command / event round trips: the core returns StartBuilderPhase → the adapter executes → it feeds back HandleTaskPhaseFinished(PhaseBuilder, outcome) → the core returns StartCallPhase, and so on (internal/worker/core/types.go defines all command types).
- If the task ctx is cancelled while a phase is waiting for admission (for example, it has already converged due to timeout), the phase fails with
CANCELLED; before entering Calls, the deadline is also checked first, and if it has already expired the task converges directly asTIMED_OUTwithout takingexecutorSem. - After the Calls phase succeeds, the core always emits
StartWriterPhase; whenResultHandleris empty,runWriterPhasestill acquirespluginSembut does not execute a plugin, andpluginResultsis empty. - Streaming Builders (
StreamingCallBuilder, only forBlockBundleCallConfig,StreamBuildConfig.Enabledoff by default) shrink the Builder phase toPrepareStreamand move the scan into the Calls phase, constrained byscanSem(ScanSlots = 8); a failure midway through production means some calls have already executed. - Timeouts are handled by
TickTimersscanningTimedOutTasksand callingHandleTaskTimedOut, which forces convergence toTIMED_OUT(retryable=true) without requiring immediate interruption of calls running in the Executor; in-flight calls receiveCancelCall.
commontypes.TaskState); the internal phases (core.TaskPhase: Preparing / Builder / Calls / Plugin / Terminal) are all folded into RUNNING:
Results and queries
TaskResult only expresses the task-level result and does not include each call’s return value. The following example shows the in-memory Go type (commontypes.TaskResult) serialized as JSON. The gRPC proto carries the corresponding wire fields but omits some Go-only fields:
- The terminal state is expressed by the
statefield (SUCCEEDED/FAILED); the Client does not need to infer it fromsuccess.TIMED_OUT,WATCH_DISCONNECTED, and so on arefailureCodes underFAILED, not separate terminal states. failureCodes that appear in the code:ACTIVATION_FAILED,IO_SCOPE_FAILED,SLOT_LOST,BUILDER_NOT_FOUND,BUILDER_FAILED,CALL_FAILED,OUTPUT_BYTES_EXCEEDED,PLUGIN_NOT_FOUND,PLUGIN_FAILED,CANCELLED,TIMED_OUT,WATCH_DISCONNECTED(defined ininternal/worker/core/worker.go,internal/worker/adapters/orchestrator_phases.go, andinternal/worker/core/dispatcher_internal.go).ReturnValueResultHandlerputs the array of call outputs intopluginResults[i].result(JSON bytes on gRPC).- The gRPC
PluginResulthas only four fields:plugin_name / success / failure_code / result; theFailureMsgandRetryablefields of the Go type are not sent on the wire (taskResultToPB).
GetTaskResult(task_id):WorkerCore.HandleGetTaskResult. ReturnsRUNNINGfor an active task;ALLOCATEDif only reserved and not yet submitted;state + resultif terminal and not expired; otherwiseNotFound.WatchTasks(task_ids) -> stream TaskUpdate:SubscriptionManager.WatchTasks. It first sends a current snapshot for eachtask_id(an unknowntask_idgetsis_terminal=true, error="not_found"so the whole batch does not fail), then pushes one terminal update for each task still running; once all tasks are terminal, the stream closes itself.TaskUpdatecontainstask_id / state / is_terminal / worker_addr / timestamp_ms / result / error. There is no separate unsubscribe method; you unsubscribe by closing the stream.
HandleWatchDetached, which sets WatchDisconnectDeadlineMs = now + WatchDisconnectGraceMs; if there is still no Watch when it expires, HandleWatchDisconnected converges the task to FAILED / WATCH_DISCONNECTED / retryable=false. With WatchDisconnectGraceMs = 0 (the code default) this is fully disabled; tasks that have never been watched are unaffected. The Worker only starts the timer after gRPC confirms the stream has disconnected, so a silent network drop must first pass through keepalive (default 30s PING + 10s ACK).
Result retention: convergeTask writes RetainedUntil = now + ResultRetentionMs, and PurgeExpiredResults in TickTimers clears it on expiry. The slot is reclaimed at the moment of convergence, independent of result retention.
Timing semantics quick reference
WorkerCore.EffectiveTaskDeadlineMs is where these values come together: a negative task_timeout_ms or one exceeding MaxTaskTimeoutMs returns InvalidArgument directly, and 0 takes TaskDeadlineMs.
Unsuitable scenarios
BlockX’s task model targets single-row state, lightweight functions, and one-shot writes; the following scenarios are not a good fit (docs/specs/architecture.md §5):
- Computations that depend on a large number of rows in the same table: wide-range aggregations, window statistics, candlestick (K-line) charts.
- Anything requiring arbitrary time-window state or large-scale stateful computation.
- Strong dependence on a streaming engine’s exactly-once state recovery.
- Arbitrary incremental streaming computation over ordinary online tables.
Related docs
Specs in the blockx repo:docs/specs/architecture.md: §2 task model, §3 main path, §4.3 results and protocols, §5 unsuitable scenarios.docs/specs/worker.md: §2 external protocol, §3 local state model, §4 execution flow, §8 failure semantics.docs/specs/task-resource-coordinator.md: placement, uncertain-state retries, circuit breaking.docs/specs/call-execution-subsystem.md: dispatcher, Executor, subfunction calls.docs/specs/plugin-system.md: declaration structures for Call Builders and Writer Plugins.
- Architecture overview: overall system architecture.
- Protocols and interfaces: gRPC / UDS / etcd protocol details.
- Worker: slot table,
WorkerCoreevents and commands, configuration. - Coordinator: the
ReserveWorkerSlotprocessing chain. - Call execution subsystem: inside the Calls phase.
- Plugin system: Builder and Writer Plugin extensions.
- IO access subsystem:
TaskIOScope, retry scopes, backend admission.