CallList before the Executor phase; the Writer Plugin (called ResultHandler on the task side) receives the return values of all calls after the Executor phase and persists or passes them through. They correspond to the Builder phase and the Writer phase of the main Task path in the Architecture overview, and are driven by the Worker orchestrator.
Responsibilities and boundaries
- A Call Builder is only responsible for “reading the input and producing a
CallList”. It does not dispatch calls, fetch function code, or cache results. - A Writer Plugin is only responsible for “consuming
outputsand writing out or returning the result”. It takes no part in call retry or deduplication. - A task has exactly one
FunctionCallConfig(type/config) and at most oneResultHandler(type/config).typeis the registered name andconfigis ajson.RawMessagethat the corresponding implementation parses itself. - Neither Builders nor Plugins hold IO at construction time; the Worker passes
TaskIOReader/TaskIOas arguments when callingBuild/Execute, and IO rate limiting and retry are done in the IO subsystem (see IO access subsystem). - Which Builders / Plugins a Worker process registers is declared by the entry point’s
app.Profile. Atypenot in the Profile misses on lookup at runtime, and the task fails cleanly withBUILDER_NOT_FOUND/PLUGIN_NOT_FOUND. - Writer Plugins are fail-fast: if
Executereturns an error, the task fails; whether upstream rescheduling is allowed is decided byPluginError.Retryable. - Every non-nil element of
outputsmust be ajson.RawMessagecontaining either a single object (one row) or an array of objects (multiple rows). Table-writing plugins usetypes.VisitOutputRowsfor uniform validation and iteration. - A streaming Builder (
StreamingCallBuilder) splits “static parsing” and “scanning and producing” into two phases: aPrepareStreamfailure is still an atomic failure with zero calls dispatched; ifRunfails midway, earlier batches may already have executed.
Code location
Core types and interfaces
The following signatures are copied from the code.event/types also declares a SchemaProvider interface, but it currently has no production callers; all column definitions go through schema.ReadColumns.Data flow / execution flow
1
Activation: build TaskCtx
Orchestrator.activateAndRun (orchestrator_phases.go) retrieves the parsed TaskCtx from core, injects Ctx / Cancel, and promotes debug:true in ResultHandler.Config to TaskCtx.DebugMode.2
Builder phase
runBuilderPhase first acquires builderSem (Admission.BuilderSlots), then does a Lookup in builderRegistry by FunctionCallConfig.Type. If it hits a StreamingCallBuilder and StreamBuild.Enabled is on, it goes through PrepareStream; otherwise Build. On success, releaseBuilderConfigPayload sets FunctionCallConfig.Config to nil to release the request JSON. Failure codes: BUILDER_NOT_FOUND / BUILDER_FAILED; Retryable depends on whether the error is a retryable IOError.3
Calls phase
Non-streaming:
runCallPhase hands the whole CallList to the dispatcher. Streaming: runCallStreamPhase runs producer.Run under scanSem, and makeBatchEmit provides an emit callback with credit-based (MaxOutstandingBatches) backpressure. See Call execution subsystem for details.4
Writer phase
runWriterPhase acquires pluginSem (Admission.PluginSlots); if ResultHandler is non-nil, it does a Lookup and calls plugin.Execute(resultCtx, ioTaskCtx, cmd.Outputs, ioScope); the return value is wrapped as commontypes.PluginResult and goes into TaskResult.ExecuteResult.PluginResults. When ResultHandler is nil, the phase succeeds immediately.Built-in Call Builders
- DBScanBuilder (
dbscan/dbscan.go): parsesDBScanBuilderDecl{Block, TriggerSources}; one goroutine perTriggerSource{Table, Operator, FunctionID, Code, Params}(errgroup.SetLimit(3)). For each source: validate the table name identifier →NormalizeInlineCode→Operator.Validate→io.Readsendsblockdb.ReadOp[*GetBlockRowsRequest](Op: "GetBlockRows", the logical table name is passed to BlockDB as-is) →Operator.ApplyInMemory(rows)(filter first, then deduplicate, in relation chain order) → build args from theParamstemplate. Positions inParamsequal to"${<table>}"are replaced with the row map; whenParamsis empty, the whole row is the only argument; whenParamsis non-empty but has no placeholder, all calls share the same staticArgsJSON. TheCallIDformat is{taskID}-{table}-{height}-{i}. - CallListCallConfigBuilder (
call_list/call_list.go): parses{function, code, callList: [][]any}, does no IO, and generatesCall{FunctionID, Args, CallID: "{taskID}-{i}"}for the i-th item. - BundleScanBuilder (
bundlescan/): resolves parquet locations fromblockBundle.number+triggerSources[].tableId(or the compatibility fieldbundleBucket), streams rows through DuckDB, and translates theOperatorinto SQL pushed down to it. It also implementsStreamingCallBuilder(stream.go, batch boundariesdefaultBatchMaxRows = 8192/defaultBatchMaxArgsBytes = 32 MiB) and setsCall.NoResultCachewhen row-key uniqueness can be proven. See Bundle clusters for details.
Operator (Plan)
TriggerSource.Operator is a *types.Plan whose JSON looks like {"relations": [{"read": ...}, {"filter": ...}, {"deduplicate": ...}]}; relations are chained into a linear list through input.relation_id (array index). Plan.Validate requires exactly one read, a chain with no branches, and every selection.field within read.base_schema.names. filter uses SQL three-valued logic (comparisons with NULL yield UNKNOWN, only TRUE is kept), and equal(col, null) is interpreted as IS NULL; deduplicate keys use a deterministic encoding with type tags and length prefixes (appendDedupValue). Supported function_reference values: and, or, not, equal, not_equal, gt, gte, lt, lte, is_null, is_not_null.
Plan.UnmarshalJSON also accepts a legacy form: an expression tree with a top-level type field (Attribute / Literal / EqualTo / In / And / Or / Not, etc.), which legacyConditionToPlan converts into a single filter relation.
See docs/specs/substrait_ast.md in the blockx repo for the full format.
Built-in Writer Plugins
When
TaskCtx.DebugMode is true, all table-writing plugins only PreviewOutputs to count rows and emit a table_write_debug log; they neither read the schema nor send write requests.
devstub
internal/worker/devstub/ provides StaticCallBuilder (name static, returns fixed calls), PayloadCallBuilder (payload, takes calls from config.calls, config.builderFail simulates failure), LogWriterPlugin (log), and TestWriterPlugin (test, config.pluginFail simulates failure). They are only listed in the cmd/worker Profile; cmd/bundle_worker does not register them. They are for e2e / perf (e.g. e2e/perf/s2_call_count_payload_test.go) and local integration testing; do not use them in production tasks.
State and lifecycle
The set of registered Builders and Writer Plugins is determined byapp.Profile at process startup and does not change dynamically per task after startup. A regular Builder runs once in the Builder phase. A streaming Builder’s producer continues into the Calls phase and ends when production completes, fails, or dispatch stops. A Writer Plugin runs once after the Calls phase converges successfully. The Worker owns phase state, timeouts, and terminal state; the plugin registry does not store them.
Builders and Writer Plugins receive the current task’s TaskCtx and IO interfaces on every invocation. They do not own Worker slots, task terminal state, or cross-task IO scopes.
Configuration
Plugins have no configuration file of their own; all related items live inWorkerFullConfig in internal/worker/app/config.go:
Extension points
Adding a Call Builder
1
Define the name
Add a
CallBuilderName to the const block in internal/plugin/callbuilder/types/types.go; its value is the functionCallConfig.type string in the task.2
Implement the interface
Create
internal/plugin/callbuilder/<name>/ and implement types.CallBuilder. In Build, parse taskCtx.FunctionCallConfig.Config with sonic.Config{UseNumber: true}, handle inline code with types.NormalizeInlineCode, and do all read IO through the passed-in io.Read. Produce per-row args with types.CompactCall. To support streaming, also implement types.StreamingCallBuilder (do all static validation in PrepareStream, emit batch by batch in Run, use a fresh backing array for each batch, and return types.ErrProductionStopped as-is when you encounter it).3
Assemble
Add a case to the
switch name in newBuilderRegistry in internal/worker/app/app.go to construct it; then add the name to the Profile that needs it (the Builders list in cmd/worker/main.go or cmd/bundle_worker/main.go). Without the case, startup fails with unknown call builder.4
Test
In-package unit tests (see
dbscan/dbscan_test.go and call_list/call_list_test.go: use a fake TaskIOReader to assert request types, CallID, args, and error classification); add the name to the TestNewBuilderRegistry_ProfileMembership series in internal/worker/app/profile_test.go; for end-to-end, see e2e/system/logical_type/dbscan_operator_test.go.Adding a Writer Plugin
1
Define the name
Add a
PluginName to the const block in internal/plugin/event/types/types.go; its value is resultHandler.type.2
Implement the interface
Create
internal/plugin/event/<name>/ and implement types.WriterPlugin. In Execute: return *types.PluginError{Kind: types.PluginErrExecution, ...} when taskCtx.ResultHandler is nil or config parsing fails; iterate outputs with types.VisitOutputRows (or ConsumeOutputRows); do write IO through the passed-in io.Write; use types.IsRetryableError(err) on downstream errors to decide Retryable. Respect taskCtx.DebugMode: only PreviewOutputs, no external IO. On success, return the data to pass through (nil if none).3
Assemble
Add a case to
newPluginRegistry in internal/worker/app/app.go; add the name to the Plugins list in cmd/*/main.go. If it depends on a new endpoint, add a startup check in validateProfileRuntimeConfig.4
Test
In-package unit tests (see the
blockdbwrite tests and tableupserts/table_upserts_test.go: single row / multiple rows / missing columns / explicit nil / write failure retryability / debug mode); cover the new error classification in TestIsRetryableError in event/types/errors_test.go; TestNewPluginRegistry_ProfileMembership in internal/worker/app/profile_test.go; the orchestrator-side TestOrchestrator_EventPhase_* tests already cover the generic path and usually need no changes.Testing
internal/plugin/callbuilder/callbuilder_test.go,internal/plugin/event/event_test.go: registry and “runner”-style integration cases;internal/plugin/pipeline_integration_test.go: starts a gRPC mock BlockDB and runs the full DBScan → BlockDBWrite chain.internal/plugin/callbuilder/types/operator_test.go: three-valued logic, exact big-integer comparison, legacy operator conversion, dedup keys.internal/plugin/callbuilder/bundlescan/stream_test.go: streaming batch boundaries, single-row overflow, zero emits onPrepareStreamstatic errors.- e2e:
e2e/system/logical_type/dbscan_operator_test.go(real Worker process + gRPC mock BlockDB),e2e/perf/s1_builder_dbscan_test.go,s5_plugin_return_value_test.go. See Testing for an overview.
Related docs
docs/specs/plugin-system.mdin the blockx repo: interfaces, data structures, assembly, and the detailed design of DBScan / BlockDBWrite / BlockBundle / TableUpserts (the primary reference).docs/specs/architecture.md§4.2.3 in the blockx repo: where the Plugin module sits in the overall architecture.docs/specs/substrait_ast.mdin the blockx repo: the Operator (Plan) JSON format and its differences from Substrait.docs/specs/2026-07-14-dbscan-in-memory-operator.mdin the blockx repo: decision record for switching DBScan toGetBlockRows+ the in-memory interpreter.- On this site: Worker (orchestrator and phase admission), Call execution subsystem (Calls phase and streaming dispatch), IO access subsystem (implementation and rate limiting of
TaskIOReader/TaskIO), Bundle clusters (bundlescan / bundlewrite expanded), Task lifecycle.