Skip to main content
The plugin system consists of two groups of pluggable implementations inside the Worker process: the Call Builder turns the task configuration into a 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 outputs and 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 one ResultHandler (type/config). type is the registered name and config is a json.RawMessage that the corresponding implementation parses itself.
  • Neither Builders nor Plugins hold IO at construction time; the Worker passes TaskIOReader / TaskIO as arguments when calling Build / 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. A type not in the Profile misses on lookup at runtime, and the task fails cleanly with BUILDER_NOT_FOUND / PLUGIN_NOT_FOUND.
  • Writer Plugins are fail-fast: if Execute returns an error, the task fails; whether upstream rescheduling is allowed is decided by PluginError.Retryable.
  • Every non-nil element of outputs must be a json.RawMessage containing either a single object (one row) or an array of objects (multiple rows). Table-writing plugins use types.VisitOutputRows for uniform validation and iteration.
  • A streaming Builder (StreamingCallBuilder) splits “static parsing” and “scanning and producing” into two phases: a PrepareStream failure is still an atomic failure with zero calls dispatched; if Run fails 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): parses DBScanBuilderDecl{Block, TriggerSources}; one goroutine per TriggerSource{Table, Operator, FunctionID, Code, Params} (errgroup.SetLimit(3)). For each source: validate the table name identifier → NormalizeInlineCodeOperator.Validateio.Read sends blockdb.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 the Params template. Positions in Params equal to "${<table>}" are replaced with the row map; when Params is empty, the whole row is the only argument; when Params is non-empty but has no placeholder, all calls share the same static ArgsJSON. The CallID format is {taskID}-{table}-{height}-{i}.
  • CallListCallConfigBuilder (call_list/call_list.go): parses {function, code, callList: [][]any}, does no IO, and generates Call{FunctionID, Args, CallID: "{taskID}-{i}"} for the i-th item.
  • BundleScanBuilder (bundlescan/): resolves parquet locations from blockBundle.number + triggerSources[].tableId (or the compatibility field bundleBucket), streams rows through DuckDB, and translates the Operator into SQL pushed down to it. It also implements StreamingCallBuilder (stream.go, batch boundaries defaultBatchMaxRows = 8192 / defaultBatchMaxArgsBytes = 32 MiB) and sets Call.NoResultCache when 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 by app.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 in WorkerFullConfig 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.
Call.ArgsJSON, NoResultCache, and ArgsPooled are all in-process fields: Call must not be JSON round-tripped as a carrier for args, and ArgsPooled may only be set when exactly one call references the buffer. Builders that share static args never set NoResultCache.

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 on PrepareStream static 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.
  • docs/specs/plugin-system.md in 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.md in the blockx repo: the Operator (Plan) JSON format and its differences from Substrait.
  • docs/specs/2026-07-14-dbscan-in-memory-operator.md in the blockx repo: decision record for switching DBScan to GetBlockRows + 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.