Skip to main content
The Bundle cluster is BlockX’s independent deployment for historical backfill (bundle backfill). It consists of cmd/bundle_coordinator and cmd/bundle_worker. It reuses the block cluster’s Coordinator / Worker assembly code. Only the entry-point profile narrows its capabilities, switches the etcd registry prefix, and enables stream build by default. See Architecture overview for the full system. A block bundle is a contiguous slice of data split by block height. BundleRef.Number = N covers heights N*1000+1 … (N+1)*1000; bundle 0 contains only height 0. The data is stored as parquet files in the bundle=N partition of an S3 / Iceberg table. A bundle backfill task feeds the parquet rows to the user function, then uses BlockDB BatchWrite to overwrite the same bundle in the target table.

Responsibilities and boundaries

  • Responsible for:
    • Accepting tasks of type BlockBundleCallConfig / CallListCallConfig.
    • Streaming rows from parquet with DuckDB and producing calls in batches.
    • Writing output back through a BlockDB BatchWrite Job with BlockBundleWriteResultHandler / TableUpsertsResultHandler. The write flow is Init → presigned PUT → Commit.
  • Not responsible for:
    • Registering block capabilities such as single-block real-time dbscan (DBScanBuilder), the ReturnValue handler, or devstub. Related tasks are rejected at admission.
    • Changing shared semantics such as the task protocol, slot state machine, or heartbeat. These belong to Coordinator and Worker.
    • Orchestrating upstream routing. When the SDK shifts traffic to the bundle endpoint is outside this component.
  • Invariant 1: membership discovery isolation. block workers register only under workers-kind registries, bundle workers only under bundle-workers-kind registries; each Coordinator watches its own exact prefix list. Profile.Validate rejects mismatches before any external dependency is created.
  • Invariant 2: one codebase, different profiles.
    • There is one internal/worker/app.Run and one internal/coordinator/app.Run; cmd/* only declares a Profile.
    • The bundle Coordinator exposes only bundlecoordinator.v1.BundleCoordinatorService; the block Coordinator exposes only coordinator.v1.CoordinatorService.
    • The proxy separates the two traffic types by gRPC method path.
  • Invariant 3: stream build must be on in production. The bundle profile’s TuneDefaults sets StreamBuild.Enabled=true; an operator can still roll back to the fully materialized Build() path with an explicit STREAM_BUILD_ENABLED=false.
  • Invariant 4: static errors execute nothing, dynamic errors execute partially. PrepareStream completes all static validation in the Builder phase; on failure zero calls are dispatched. If Run fails midway, earlier batches may already have executed.
  • Invariant 5: BatchWrite is the only write path, and it is strictly validated at startup. If blockdb.batchWriteAddr is empty when BundleWrite (or the bundle profile’s TableUpserts) is registered, the process fails at startup rather than waiting for the Writer phase.
  • Invariant 6: when the Commit result is uncertain, do not call Cancel or create a replacement Job; return a non-reschedulable failure and keep the original job_id.

Code location

Core types and interfaces

  • app.Profile (internal/worker/app/app.go): Deployment / Service / WorkerRegistryPrefix / UsageService (chaintable-usage service name, fixed by the entry point) / Builders / Plugins / TuneDefaults. The actual values for the bundle entry point:
  • app.Profile (internal/coordinator/app/app.go): Deployment / Service / WorkerRegistryPrefixes []string; registerCoordinatorService decides which gRPC service to register based on Deployment.
  • Registry prefixes (api/etcd/registry.go):
    • etcdapi.LegacyBundleWorkerRegistryPrefix = /blockx/bundle-workers/
    • ProdDefaultBundleWorkerRegistryPrefix = /blockx/prod/lanes/default/bundle-workers/
    • TestDefaultBundleWorkerRegistryPrefix is the default for test environments.
    • ParseRegistryPrefix returns RegistryPrefix{Format, Environment, Lane, WorkerKind}.
  • adapters.BundleCoordinatorServer (internal/coordinator/adapters/bundle_grpc_server.go): only converts messages and calls CoordinatorServer.ReserveWorkerSlot.
  • bundlescan.BundleScanBuilderDecl / BundleSource (internal/plugin/callbuilder/bundlescan/types.go): the on-wire functionCallConfig.config.
    • Bundle location field: blockBundle.number
    • Source fields: triggerSources[].tableId|bundleBucket|operator|function|code|param|forceResultCache
    • Missing-file marker: NoBundleFile = "NO_BUNDLE_FILE"
  • bundlescan.BundleScanBuilder (bundlescan.go / stream.go): implements both types.CallBuilder and types.StreamingCallBuilder; SetStreamLimits injects the batch boundaries at startup.
  • types.StreamingCallBuilder / CallStreamProducer / BatchEmit / ErrProductionStopped (internal/plugin/callbuilder/types/types.go): the two-phase streaming contract.
  • bundlescan.DuckDBParquetReader / DuckDBParquetReaderConfig (duckdb.go):
    • StreamBundleWithPlan(ctx, objectURI, bundleNumber, plan, yield) streams a bundle.
    • Filters are pushed down into read_parquet(...) WHERE; dedup runs row by row on the Go side.
    • S3 credentials are initialized lazily. On ExpiredToken before anything has been yielded, the reader refreshes the secret and retries once.
  • bundlescan.BundleRowYield (parquet.go): the borrowed-row contract — the row and the string/[]byte values inside it are valid only during the callback.
  • borrowed.Pool / borrowed.ScanResult (internal/duckdb/borrowed/): connection pool and chunk iteration.
  • icebergsdk.ResolveDataFilesReq / IcebergIOAdapter / Planner.PlanDataFiles (internal/sdk/iceberg/):
    • Resolves all live files in the table’s current snapshot in one pass and encodes them as a bundle index.
    • LookupBundleFileIndex(data, bundle) selects a file from the index. See Backend Adapter for details.
  • batchwrite.Writer.Submit(ctx, taskIO, initReq, outputs, columns) (Result, error) (internal/plugin/event/batchwrite/writer.go):
    • Runs in this order: plan → Init → PUT → Commit.
    • Returns Result{JobID, RowCount, ContentBytes}.
    • commitStatusUnknownError always satisfies Retryable() == false.
  • bundlewrite.BundleWritePlugin / BundleWriteConfig{TargetTable, BlockBundle}; tableupserts.NewPlugin (block) and tableupserts.NewBatchPlugin (bundle).

Data flow / execution flow

1

Reserve + Submit

The caller first invokes BundleCoordinatorService.ReserveWorkerSlot on the bundle coordinator. After receiving worker_addr / slot_id, it submits SubmitTask to the target worker.Scheduling, slot TTL, and heartbeat match the block cluster. See Coordinator.
2

Builder phase: PrepareStream (under builderSem, short)

BundleScanBuilder.PrepareStream deserializes BundleScanBuilderDecl. For each source, it calls prepareBundleSource to perform static preparation:
  • Resolve the data source: resolveBundleSourceBucket uses bundleBucket directly when it is non-empty. Otherwise, it reads logical-types to identify an event or state table. State tables map to <tableId>._archive, then ResolveDataFilesReq resolves the unique parquet URI.
  • Handle missing data: when the result is NO_BUNDLE_FILE or the snapshot does not contain the bundle, it skips the source and produces 0 calls.
  • Normalize the call: normalize inline code. For archived state tables, also remap id in the operator to original_id.
  • Validate the scan plan: dry-run validateDuckDBReadPlan and ToFilterSQLArgs, then prebuild ParquetJSONEncoder.
  • Choose the cache policy: markProvablyUniqueSources decides which sources’ calls set NoResultCache.
Failure semantics: any static preparation failure becomes BUILDER_FAILED, and no calls are dispatched.
3

Calls phase: Run (under scanSem, long)

The Orchestrator starts runCallProduction under scanSem:
  • Scan concurrently: bundleScanProducer.Run uses an errgroup to scan at most 3 sources concurrently.
  • Build calls: bundleRowCallBuilder.buildNext encodes each row into a Call. Row args are written directly into the argspool buffer with ArgsPooled=true.
  • Split batches: bundleSourceBatchAppender runs flush and emit when either maxRows or maxArgsBytes reaches its limit.
  • Apply backpressure: emit blocks on MaxOutstandingBatches credits, limiting the number of unfinished batches.
Failure semantics: args for a single row that exceed the byte limit return a production error. If emit returns ErrProductionStopped, the worker stopped in a controlled fast-fail path; this is not a builder failure.
4

Dispatch / Execute

This is the shared path. See Call execution subsystem and Plugin system.
5

Writer phase: BatchWrite

After the Calls converge successfully, BundleWritePlugin.Execute validates targetTable / blockBundle, reads the column definitions, and calls batchwrite.Writer.Submit:
  1. Plan: planDelimitedWriteRows walks outputs without retaining them and computes the exact row and byte counts. More than 10,000,000 rows or 5,000,000,000 bytes fails before Init.
  2. Init: InitWriteJob creates a Job and returns job_id and data_url.
  3. PUT: the encoder streams through io.Pipe in one HTTP PUT to the presigned URL. The request uses an exact Content-Length and does not follow redirects.
  4. Commit: after a successful upload, call CommitWriteJob(row_count).
  5. Cancel: if PUT fails or ctx is canceled before Commit, call CancelWriteJob on a best-effort basis. This call uses context.WithoutCancel and an independent 5s timeout.
On success, it returns written_rows / finish_time / job_id. DebugMode only previews output and does not create a Job.
TableUpsertsResultHandler uses different write paths on the two worker types:
  • bundle worker: reuses batchwrite.Writer with NormalTableUpsertStrategy{Condition, UpdateColumns}.
  • Validation before Init: planNormalUpsertDelimitedWriteRows requires every row to have the same valid-field set, a non-empty id, and at least one non-ID column. It writes the update_columns derived from the first row explicitly into the request.
  • block worker: the handler with the same name still calls TableWriteService.UpsertRows(sync=false).
  • Configuration boundary: the profile fixes the write path at assembly time. The task configuration has no writeApi.

State and lifecycle

  • Registry ownership: at worker startup, Profile.Validate runs, then validateWorkerRegistryPrefix runs again after config loading, guaranteeing that a bundle deployment can only write to a bundle-workers-kind prefix (legacy or v2). On the coordinator side, validateRegistryPrefixes does the same and also rejects duplicate prefixes.
  • Two-phase stream build:
    • The Builder phase holds only builderSem and runs PrepareStream.
    • The Calls phase holds scanSem and executorSem while production runs. It releases scanSem when production finishes or fails.
    • One scanSem covers one task’s production process, which scans at most 3 sources concurrently. The worker-level DuckDB scan concurrency limit is about ScanSlots × 3.

BatchWrite failure classification

Classification lives in batchwrite/classified_error.go and presigned_upload.go. The plugin passes the classification through as PluginError.Retryable.

Iceberg resolution cache

ResolveDataFilesReq.CacheKey() uses only the logical table ID. Requests go through the Worker system cache. The default TTL is 20 minutes and is controlled by SystemIOCacheTTLms. When the cached index’s high-water mark covers RequiredBundle, AcceptCachedRead reuses it. Otherwise, it refreshes the full table index once.

Configuration

The following configuration is most relevant to the bundle worker. Struct fields come from internal/worker/app/config.go, internal/worker/core/config.go, and internal/worker/adapters/orchestrator.go. Configuration precedence is: built-in defaults → TuneDefaultsWORKER_CONFIG file → environment variables. bundle coordinator: COORDINATOR_REGISTRY_PREFIXES (a comma-separated exact list) replaces the profile default [/blockx/bundle-workers/, /blockx/prod/lanes/default/bundle-workers/] as a whole; all other configuration is the same as the block coordinator.

Extension points

  • Tuning bundle profile defaults: change TuneDefaults in cmd/bundle_worker/main.go; it may only set defaults and must not post-process the final config (an operator’s explicit value must be able to override). Update cmd/bundle_worker/profile_test.go and internal/worker/app/profile_test.go accordingly.
  • Adding or removing a Builder or Plugin for the bundle worker:
    • Change the profile’s Builders / Plugins.
    • Add or remove the corresponding case in newBuilderRegistry / newPluginRegistry in internal/worker/app/app.go.
    • If a new plugin depends on BatchWrite, also add it to validateProfileRuntimeConfig.
  • Changing bundlescan source semantics (new fields, new resolution paths): bundlescan/types.go (decl) → source_resolution.go (resolution) → prepareBundleSource in bundlescan.go (all static validation must live here, shared by both paths) → source_calls.go (call assembly). Do not put validation into Run, or you break “static failure executes nothing”.
  • Changing DuckDB reads: SQL assembly, S3 secrets, filter pushdown, and dedup are in bundlescan/duckdb.go; connection pool / row decoding / type support are in internal/duckdb/borrowed/ (no business policy there).
  • Changing the BatchWrite lifecycle (retries, upload method, capacity caps): internal/plugin/event/batchwrite/. The BlockDB SDK keeps only the three atomic requests InitWriteJobReq / CommitWriteJobReq / CancelWriteJobReq; do not push orchestration down into the SDK.
  • Adding a registry prefix format: ParseRegistryPrefix in api/etcd/registry.go; Validate on both sides picks it up automatically.
  • Adding a bundle coordinator RPC: change api/grpc/bundlecoordinator/bundle_coordinator.protomake (see the proto target in the Makefile) → internal/coordinator/adapters/bundle_grpc_server.go only does conversion; scheduling logic stays in the shared CoordinatorServer.

Testing

  • internal/plugin/callbuilder/bundlescan/*_test.go:
    • bundlescan_test.go: Build path, source resolution, archive remapping.
    • stream_test.go / TestBundleScanStream_*: MaxRows / MaxArgsBytes boundaries, single row over the limit, zero emits on static errors, zero emits from sibling sources, emit rejection stopping the scan, ctx cancellation.
    • duckdb_test.go: reader configuration, handle release, connection pool cancellation.
    • parquet_test.go / json_encoder_benchmark_test.go: parquet encoding and performance coverage.
  • internal/plugin/event/bundlewrite/bundle_write_test.go and batchwrite/*_test.go: Init/PUT/Commit/Cancel state machine, error classification, Content-Length validation; tableupserts/table_upserts_test.go covers both modes.
  • cmd/bundle_worker/bundle_worker_process_test.go:
    • TestBundleWorkerProcess_RejectsMissingBatchWriteAddr requires startup to fail without BLOCKDB_BATCH_WRITE_ADDR.
    • TestBundleWorkerProcess_BootsWithBundleProfile covers startup, readiness, and registration of bundle-profile capabilities only.
    • profile_test.go pins deployment / service / registry values. cmd/bundle_coordinator/*_test.go covers the corresponding coordinator behavior.
  • internal/coordinator/adapters/bundle_grpc_server_test.go: the bundle adapter reuses shared scheduling and passes gRPC status through.
  • See Testing for an overview of test organization.
See cmd/bundle-loadgen/README.md in the repository for full cmd/bundle-loadgen usage.
  • --coordinator points to the bundle coordinator.
  • --input-uri points to a parquet fixture with a verified unique column.
  • --workload noop|cpu|blockdb|function selects the workload.
  • --result-cache disabled|enabled and --result-shape are control variables for CallCache / output-encoding A/B tests.
  • --result-handler-type BlockBundleWriteResultHandler connects a real writer, but you must use an isolated test table.
  • The worker must set STREAM_BUILD_ENABLED=true. The blockdb / function scenarios also depend on worker stubs such as STUB_BLOCKDB_DELAY_MS.
docs/specs/ in the blockx repo:
  • docs/specs/2026-07-21-block-bundle-clusters.md — block / bundle deployment split: dual entry points, dual registries, system invariants (key).
  • docs/specs/2026-07-28-registry-prefix-migration.md — legacy → v2 registry prefix format, WORKER_REGISTRY_PREFIX / COORDINATOR_REGISTRY_PREFIXES override rules.
  • docs/specs/2026-07-23-bundle-coordinator-api.md — the decision on a standalone BundleCoordinatorService and its rollout order.
  • docs/specs/2026-07-30-bundle-write-batch-api.md — the BatchWrite write path’s execution flow, contract boundaries, failure state machine (normative).
  • docs/specs/plugin-system.md §6.1 (StreamingCallBuilder contract, the four NoResultCache conditions), §10 (BlockBundleCallConfig / BlockBundleWriteResultHandler / TableUpsertsResultHandler contracts).
  • docs/specs/2026-07-30-ec2-worker-profiling.md — sampling plan for the two EC2 Worker clusters, block and bundle.
On this site: