(type, config) pairs: functionCallConfig picks a Call Builder, and resultHandler picks a Writer Plugin (called a Handler in blockx-py). type must be a name registered in the current Worker process; config is parsed by the corresponding implementation itself, and the Worker only checks that it is valid JSON at submission time.
Call Builder
BlockTableCallConfig
- A task can have multiple
triggerSources; each is scanned independently and the results are merged into one call list. functionIdandcode.sourceCodeare mutually exclusive: the former is a registered function, the latter is inline source code.- When
paramsis empty, the whole row is the only argument; whenparamsis non-empty but has no"${<table>}"placeholder, all calls share the same static arguments, so the function runs only once. operatoris optional; when omitted, every row of the trigger table at that block produces a call.
InputsCallConfig
- Performs no IO.
callList[i]directly becomes the argument array of the i-th call. - The field name is
function, notfunctionId; blockx-py does this renaming automatically. - Results are collected in completion order and are not guaranteed to match the row order of
callList. When you need to map results back to inputs, have the return value carry the input.
BlockBundleCallConfig
- A bundle is a data partition covering 1000 blocks, identified by
blockBundle.number. tableIdonly identifies the source table. The Worker resolves this bundle’s data files from the table’s Iceberg snapshot by(tableId, number); state tables resolve through<tableId>._archive.- The field names are
function/param(singular), unlikefunctionId/paramsinBlockTableCallConfig. - The bundle worker enables stream build by default: scanning and execution are pipelined, so even millions of rows do not need to be materialized into a full call list first.
Operator: filtering and deduplication
operator is a Substrait-style relations chain: exactly one read, followed by any number of filter and deduplicate relations, linked into a linear chain by input.relation_id.
filtersupportsand,or,not,equal,not_equal,gt,gte,lt,lte,is_null, andis_not_null. It evaluates with SQL three-valued logic and keeps only rows whose result isTRUE.deduplicateremoves duplicates by the given columns; the key uses a deterministic encoding with type tags.- dbScan executes the chain in Worker memory; bundleScan translates it to SQL and pushes it down to DuckDB.
- blockdb-py’s
filter()/Operatorproduce this structure directly, so you can put it intooperatoras-is.
docs/specs/substrait_ast.md in the blockx repository for the format details.
Writer Plugin
Common behavior:
- Table-writing plugins read the target table’s column definitions first and project only the columns of the returned dict that belong to the target table;
nullvalues inoutputsare skipped. - When any handler’s
configcarries"debug": true, table-writing plugins only count rows and emit atable_write_debuglog; they read no schema and send no write request. blockx-py turns this on with the environment variableBLOCKDB_DEBUG=1. - Omitting
resultHandleris also valid: after the Calls phase succeeds, the task goes straight to a terminal state andoutputsare discarded. - Writer Plugins are fail-fast: a write failure makes the task
PLUGIN_FAILED, andretryableis determined by the plugin’s error classification.
BlockTableWriteHandler
- One name maps to two implementations, assembled at startup by the Worker process’s deployment profile (
block/bundle); the caller cannot choose. - The block cluster implementation requires
config.block, writes once per task, and returns{written_rows, finish_time}. - The bundle cluster implementation requires
config.blockBundleand overwrites the same bundle of the target table through a BlockDB BatchWrite job (Init → presigned PUT → Commit). - A shape mismatch fails in the Writer phase with
PLUGIN_FAILED(block deployment requires config.block/bundle deployment requires config.blockBundle), by which time all calls have already run. blockx-py validates the shape against the target cluster before submitting, so you do not waste a full run.
NormalTableWriteHandler
- Upserts by the primary key
id. conditionis optional:{"column": "ts", "policy": "UpdateIfSmaller"}means a row is overwritten only when the new value is smaller in that column;policyisUpdateIfSmallerorUpdateIfLarger. This makes out-of-order or replayed writes naturally idempotent.- Carries no block context, so the same config can be reused on both the real-time and backfill paths.
ReturnValueHandler
- Assembles
outputsas-is into one JSON array and puts it inpluginResults[i].result. In blockx-py, readresult.handler_result. - The bundle worker does not register it; a task that carries it and is submitted to the bundle cluster is rejected with
InvalidArgumentatSubmitTask. blockx-py therefore always routes such tasks to the block cluster.
Combination cheat sheet
Summary
- Call Builders decide the input:
BlockTableCallConfigreads a block,InputsCallConfiguses the task’s own arguments,BlockBundleCallConfigreads a bundle. - Writer Plugins decide where results go:
BlockTableWriteHandlerwrites L2,NormalTableWriteHandlerwrites L1,ReturnValueHandlerreturns them to the Client. - The
configshape ofBlockTableWriteHandlermust follow the cluster:blockon the block cluster,blockBundleon the bundle cluster. operatorfilters and deduplicates trigger rows in the Builder phase to reduce the number of calls.
- Plugin system: interfaces, registry, assembly, and how to add a new Builder / Plugin.
- Bundle clusters: the bundleScan streaming scan and the BatchWrite write path.
- Submit a task: examples: the blockx-py code for every combination.