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), InputsCallConfig, BlockBundleCallConfig (bundleScan). Currently registered Writer Plugin types (internal/plugin/event/types/types.go): BlockTableWriteHandler, ReturnValueHandler, NormalTableWriteHandler.
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
The Worker core converges the task and records its terminal result. Its result index retains that result independently of slot reuse. Worker adapters serve the same result through unary queries and Watch updates.HandleWatchDetached starts a deadline only when disconnect handling is enabled and a Watch was attached. The timer can converge the task with WATCH_DISCONNECTED; the default disables this behavior.
The caller contract and completion boundaries are defined in Execution semantics and results. Full result fields and errors are in the reference.
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.