Skip to main content
A task is the smallest scheduling and execution unit in BlockX: the Client generates a 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 on TaskResult.executeResult.retryable.
  • No cancellation: only task timeouts (taskTimeoutMs) and optional Watch-disconnect reclamation.

Task model

The structure submitted externally is the gRPC TaskInput (api/grpc/worker/worker.proto); the Go counterpart is commontypes.TaskInput (internal/common/types/types.go): Currently registered Builder 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 also docs/specs/architecture.md §3).
1

Client builds the task

The Client generates a task_id and prepares a TaskInput. BlockX is not responsible for task generation, DAG orchestration, or scheduled triggering.
2

Client asks the Coordinator for placement (optional)

Call 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.
3

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.
4

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.
5

Coordinator returns worker_addr + slot_id

The Coordinator stores no task payload, stores no results, and writes no slot truth to etcd; slot_id is just an opaque string to it.
6

Client submits SubmitTask

The Client connects directly to 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.
7

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.
8

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.
9

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.
10

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.
11

Converge outputs

Once all root calls reach a terminal state, 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, ...).
12

Plugin phase writes results

The core emits 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.
13

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.
14

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.
15

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_ms must be positive (<= 0 returns InvalidArgument) and only controls automatic reclamation of ALLOCATED slots; it is unrelated to the task timeout. TickTimers scans ExpiredSlots every 500ms; expired slots are reclaimed by HandleSlotLeaseExpired, which also clears task records still in Preparing.
  • SubmitTask(task, slot_id?): when slot_id is provided it must match a record that is ALLOCATED and bound to the same taskId, otherwise InvalidSlot; when omitted, the Worker requests a slot inline once using DefaultTTLMs, returning NoSlot if there is no capacity.
  • Idempotency: while the same taskId is ALLOCATED / RUNNING, RequestTaskSlot returns the same slot_id; once it has entered RUNNING or its result is still within the retention window, SubmitTask returns the stable current state and never starts another execution. After the old execution finishes and its result expires, the same taskId can 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.
The boundary between admission errors and execution terminal states (see also the end of 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. For details on the slot table, 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). Other points:
  • 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 as TIMED_OUT without taking executorSem.
  • After the Calls phase succeeds, the core always emits StartWriterPhase; when ResultHandler is empty, runWriterPhase still acquires pluginSem but does not execute a plugin, and pluginResults is empty.
  • Streaming Builders (StreamingCallBuilder, only for BlockBundleCallConfig, StreamBuildConfig.Enabled off by default) shrink the Builder phase to PrepareStream and move the scan into the Calls phase, constrained by scanSem (ScanSlots = 8); a failure midway through production means some calls have already executed.
  • Timeouts are handled by TickTimers scanning TimedOutTasks and calling HandleTaskTimedOut, which forces convergence to TIMED_OUT (retryable=true) without requiring immediate interruption of calls running in the Executor; in-flight calls receive CancelCall.
A task has only four externally visible states (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 state field (SUCCEEDED / FAILED); the Client does not need to infer it from success. TIMED_OUT, WATCH_DISCONNECTED, and so on are failureCodes under FAILED, 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 in internal/worker/core/worker.go, internal/worker/adapters/orchestrator_phases.go, and internal/worker/core/dispatcher_internal.go).
  • ReturnValueResultHandler puts the array of call outputs into pluginResults[i].result (JSON bytes on gRPC).
  • The gRPC PluginResult has only four fields: plugin_name / success / failure_code / result; the FailureMsg and Retryable fields of the Go type are not sent on the wire (taskResultToPB).
Both query entry points are provided by the Worker; the Coordinator is not involved:
  • GetTaskResult(task_id): WorkerCore.HandleGetTaskResult. Returns RUNNING for an active task; ALLOCATED if only reserved and not yet submitted; state + result if terminal and not expired; otherwise NotFound.
  • WatchTasks(task_ids) -> stream TaskUpdate: SubscriptionManager.WatchTasks. It first sends a current snapshot for each task_id (an unknown task_id gets is_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. TaskUpdate contains task_id / state / is_terminal / worker_addr / timestamp_ms / result / error. There is no separate unsubscribe method; you unsubscribe by closing the stream.
Watch also doubles as an optional “run credential”: once a task has been watched for the first time, the disconnection of the last Watch triggers 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.
For streaming needs on non-onchain tables, a compromise is to have the Writer Plugin write to both the online table and Kafka, and have the business side listen to the topic and submit new tasks as needed. 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.
Site pages: