TaskIOScope) nest inside the Worker-level shared resources (WorkerIOScope), and the request lifecycle nests inside the task lifecycle.
Responsibilities and boundaries
Responsible for:- Providing the single synchronous entry point
TaskIOScope.Read / Write, shared by the Builder, the Executor IO relay, and plugins. - A fixed request path: Worker-level read cache → task-local singleflight → task IO window → backend admission → adapter.
- Holding the Worker-level shared read cache (IO cache) and a separate system cache.
- Bounded retries for requests that carry a Builder / Result retry scope.
- Binding the request lifecycle to the task: once the scope closes, in-flight requests are canceled, new requests are rejected, and late results are discarded.
- A unified error model (
model.IOError) and per-task IO statistics.
- Deciding whether a call gets dispatched (that belongs to the Dispatcher; see Call execution subsystem).
- The concrete logic of real protocol calls, connection pools, and error classification — these live in the adapters under
internal/sdk/*; see Backend Adapter. - The call result cache (owned by the Call execution subsystem).
- Cross-task write idempotency or transaction recovery.
- Each backend kind has exactly one admission controller, wrapped unconditionally by
assembly.Build(adaptive.WrapBackend); there is no second shared quota layer in front ofTaskIOScope. - Write requests are never cached and never go through singleflight, but they do pass through the task window and backend admission.
req.Timeout()only bounds the real adapter call after a permit is obtained; waiting for the task window and admission has no local timeout and is bounded only by the request context and the scope lifecycle.- An empty
CacheKey()means “not cacheable”: the cache and singleflight are skipped and the request goes straight to the window + adapter. TaskIOScope.Closeis idempotent; after closing, nothing may be written back into the cache or singleflight.- IO cache keys consist of a
(backend, operation)namespace plus a normalized request key, so different backends / interfaces never collide.
Code location
Core types and interfaces
model.BackendAdapter(internal/io/core/model/adapter.go): the minimal interface an adapter must implement.Writereturns(*WriteResult, error).
model.ReadReq/model.WriteReq(same file): the request interfaces.ReadReqhasBackend() BackendKind,Operation() string,CacheKey() string, andTimeout() time.Duration;WriteReqhas noCacheKey. Concrete request types are defined by the individual SDK packages (e.g.internal/sdk/blockdb/types.go); requests relayed from the Executor are implemented byexecutor.IORequest.model.ErrorClassifier: adapters may optionally implementClassifyError(err) *IOError; without it, all unknown errors are classified asretryable_io.model.CachedReadValidator: an optional capability for cacheable reads,AcceptCachedRead(data []byte) bool; returning false bypasses the cached value and joins the singleflight refresh for the same key (the current user isinternal/sdk/iceberg/io_adapter.go).model.IOError/model.IOErrorKind: the unified error model.Kindis one ofsystem_error,param_error,retryable_io,non_retryable,timeout,scope_closed;Codeis a fine-grained observability code (e.g.io:no_adapter_registered,context:timeout);StructuredError()produces theerrorKind / retryable / detailCodereturned to the Python SDK.model.BackendRegistration:Kind,Adapter,SystemCache bool(reads go through the system cache),ReadOnly bool(Writeis rejected outright withio:write_not_supported).core.WorkerIOScope(worker_scope.go):NewWorkerIOScope(WorkerIOScopeParams),NewTaskScope(*commontypes.TaskCtx),Close(),Snapshot().WorkerIOScopeParams.DisableSharedReadCachelets the Sync Invoker turn off cross-call result reuse.core.TaskIOScope(task_scope.go):Read,Write,Close(reason),Snapshot(),IOStats(),BackendHits(),AddCallIO / FinalizeCallIO,BackendIOStats(),DrainIOErrorClusters().core.RetryScope/core.WithRetryScope(retry.go): only the two framework tagsRetryScopeBuilderandRetryScopeResultenable IO-local retry; unknown tags fail closed.adaptive.Backend/adaptive.WrapBackend(next, Config)(adaptive/backend.go): wraps any adapter with a dedicatedLimiter; it also implementsErrorClassifier, mapping admission errors to the canonicalcontext:timeout/context:canceled/io:temporarily_unavailable.adaptive.Limiter/adaptive.Permit/adaptive.Outcome(adaptive/limiter.go):Acquire(ctx) (*Permit, error),Permit.Done(outcome)(idempotent);Outcomehas three values,OutcomeSuccess / OutcomeOverloaded / OutcomeIgnore.adaptive.OutcomeClassifier: adapters may optionally implementClassifyAdaptiveOutcome(err) Outcome; without it, success is recorded asSuccessand errors asIgnore. Implementations:internal/sdk/noderpc/adaptive.go,internal/sdk/blockdb/adaptive.go,internal/sdk/logicaltypes/adaptive.go,internal/sdk/meta/adaptive.go.adaptive.AIMDConfig(adaptive/config.go): operator-facing AIMD configuration;ResolveAdmission(initialLimit)degrades toFixedConfigwhenEnabled=false.assembly.Module/assembly.Build(assembly/assembly.go): see “Extension points”.capabilitywire.DecodeBinaryRequest(kind, req) (method, payload, error): see “Extension points”.
Data flow / execution flow
A read request coming from the Executor (Python SDK → UDS → Worker → backend): Key points:- The entry point is
processWaitRequestininternal/worker/adapters/executor/handlers.go: it builds anexecutor.IORequestfrom theMode / Operation / Backend / CacheKey / TimeoutMs / Requestfields ofCallWaitingPayload(usingNewIORequestWithBinarywhen a binary sidecar is present, and using the sidecar bytes directly as the cache key whenDeriveCacheKeyFromBinary=true), then callsIOHandler.HandleIO. Orchestrator.HandleIO(orchestrator_dispatch.go) finds thetaskRuntimebytaskID, writes the instance id into the ctx (for fair lanes), rejects Executor-originatedblockdbwrites here, then callsrt.io.Read / Write; on success it recordsBackendLatencyMsinto the per-call IO statistics.- Return path:
ResumeCall.BudgetUsedMstakesReadResult.BudgetChargedMs()— 0 for cache / singleflight hits, and the backend duration measured by the adapter for real reads; admission and window queueing time are not charged to the budget. For requests that arrived through the binary sidecar, the resultDatagoes back through the sidecar as well (binary-in → binary-out). - The Builder and Writer do not go through UDS; they hold the
TaskIOScopedirectly (the Builder gets acbtypes.TaskIOReader, plugins get atypes.TaskIO), with the ctx carryingRetryScopeBuilder/RetryScopeResultrespectively. - The write path differs from the read path only in that it skips the cache and singleflight and checks
ReadOnlyfirst; everything else (window, admission, retry, error classification) is the same.
Cache and namespaces
WorkerIOScope holds two cache.Cache[ioCacheKey] instances:
- IO cache: 4096 entries / 16 MiB by default, fixed TTL of 1 minute (
workerIOCacheTTL), shared across tasks, purged withPurgewhen the Worker shuts down. - system cache: 64 entries / 512 MiB / 20 minutes by default; it only serves backends registered with
SystemCache: true(currentlyiceberg’sresolve_data_files).
ioCacheKey{namespace, request}, namespace is the numeric ID of (backend, operation): the known operations of the two BlockDB kinds, logicalTypes, meta, router, localtestservice, and iceberg have compile-time IDs in knownIOCacheNamespace; NodeRPC JSON-RPC methods are an open set and, together with other unknown combinations, go through a bounded (4096) dynamic registry; when the registry is exhausted, the request degrades to a non-cacheable read. The third kind of cache, the call result cache, is not part of this subsystem.
Adaptive admission
adaptive.Backend is the single process-level admission gate of each backend. Limiter implements windowed AIMD:
- Within each sampling window (default 1s), the first
Overloadedtriggers an immediate multiplicative decrease (BackoffRatiodefault 0.5, floored atMinLimit); a further decrease within the same window requires the new-generation sample count to reachRepeatBackoffMinSamplesand the overload ratio to reachRepeatBackoffOverloadRatio, without exceedingMaxDecreasesPerWindow. - When the
Ignoreratio reachesIgnoreRatioThreshold(0.10), the window loses its eligibility for growth; when it reachesIgnoreBackoffRatioThreshold(0.30), the limit shrinks once when the window settles. - Growth only happens at the end of a window and requires no overload, enough samples, saturation (max in-flight reached the limit), and
IncreaseAfterHealthyWindowsconsecutive healthy windows; the step isIncreaseStep(0 means 1% of the initial limit, at least 1). - Above the learned
softLimit, growth turns into probing: if the probe generation overloads, the limit falls back exactly toprobeBaseLimit, and consecutive failures back off exponentially in window count (capped atProbeBackoffMaxWindows). - When the limit drops below in-flight, existing requests are not canceled; the difference is repaid as shrink debt.
- No goroutines are started; windows advance lazily on
Acquire / Doneevents.
Config.LaneKeyFromCtx is non-nil, fair lane mode is enabled (adaptive/lanes.go): waiters are split into lanes by key, and freed capacity goes to the lane with the fewest in-flight requests; the AIMD policy is unchanged. The Worker enables it for specific backends through the IOFairQueueBackends configuration, with the key taken from clientid.FromContext.
observed.WrapBackendRegistration wraps the backend and, at the same time, registers blockx_io_adaptive_limit / _pressure / _overloads_total / _ignored_total / _limit_transitions_total / _admission_waits_total / _admission_wait_seconds_total by BackendKind. The remaining IO-layer metrics are blockx_task_io_ops_total, blockx_task_io_backend_duration_milliseconds, blockx_task_io_cache_hits_total, and blockx_task_io_singleflight_hits_total, defined in internal/obs/metrics.go and internal/obs/metrics_io_adaptive.go.
State and lifecycle
TaskIOScope has only two states, “active” and “closing”:
- Creation: on task activation, the
OrchestratorcallsioScope.NewTaskScope(ioTaskCtx)(orchestrator_phases.go); a failure is recorded astask_activation_failed. The scope holds ascopeCtxderived from the task ctx. - Requests: each
Read / Writederives anioCtx, andcontext.AfterFunc(scopeCtx, ioCancel)lets a scope close cancel in-flight adapter calls; each attempt does its ownAcquire / Releaseof the task window, and neither the window nor a permit is held during backoff. - Close: on task terminal state,
rt.io.Close("task_terminal").CloseusesCompareAndSwapto guarantee idempotency, then, in order: cancelsscopeCtx, callssfTable.DrainAll(scope_closed)to wake all waiters, and removes itself fromWorkerIOScope.activeScopes. - Late results: after
runIORetryreturns successfully, the leader checksclosingonce more; if already closed, it returnsscope_closedwithout writing to the cache or broadcasting;sflight.Table.CompleteandDrainAllperform an idempotent close under the same lock to avoid double-close panics. - Worker shutdown:
WorkerIOScope.Closefirst closes all active task scopes, purges both caches withPurge, then callsadapter.Close()one by one (the adaptive wrapper first closes the limiter to wake waiters, then closes the raw adapter).
- Wait-phase errors are normalized by
classifyWaitContextError: scope already closing →scope_closed/io:temporarily_unavailable;DeadlineExceeded→timeout/context:timeout;Canceled→scope_closed/context:canceled; anything else →system_error. There is no separate “queue timeout” error. - Backend errors are normalized by
classifyBackendError: a*IOErrorpasses through as-is → the adapter’sClassifyError→ otherwiseretryable_io. shouldRetryIOonly allows a retry whenioErr.Retryable(),RetryMaxRetriesis not exhausted, the ctx carries a known retry scope, and the ctx is not done. Backoff usesbackoff/v5: by default three windows of 250–500ms, 500ms–1s, and 1–2s, capped at 3s per wait.- Write retry is at-least-once: IO core simply replays the current typed write request. All BlockDB APIs (including BundleWrite’s
InitWriteJob / CommitWriteJob / CancelWriteJob) guarantee idempotency, so BlockDB writes inside the Result scope remain retryable; the earlierWithoutRetryScopeopt-out mechanism was removed in commitaae6b77a. When onboarding a write API that does not satisfy the idempotency contract, adjust the retry scope or the backend contract first. - The singleflight leader owns the entire retry loop; waiters only wait for the final result; the cache is only backfilled with the final successful value.
Configuration
Worker-side IO configuration lives inWorkerFullConfig.IO (type iocore.WorkerIOConfig, defined in internal/io/core/model/adapter.go; internal/io/core/types.go only aliases it; defaults come from DefaultIOConfig in internal/worker/app/config.go):
Backend admission configuration lives at the top level of
WorkerFullConfig:
The backend kinds registered by the Worker and Sync Invoker assembly tables are
rpc, blockdb, blockdb_executor, logicalTypes, iceberg, localtestservice, and router; internal/sdk/meta defines BackendMeta, but neither assembly table registers it.
The Sync Invoker (cmd/syncinvoker/io.go) only sets TaskMaxInflightIO: 1024 and constructs WorkerIOScope with DisableSharedReadCache: true.
Extension points
Adding a capability backend
Useinternal/sdk/localtestservice/ as the template (docs/capability-backend-guide.md is the file-by-file guide); no platform code changes are needed. On the Go side you need to:
1
Define the kind and wire contract
Under
internal/sdk/<kind>/, declare const Backend<X> iocore.BackendKind = "<kind>" (never reuse an existing kind), write proto/<kind>.proto as the gRPC service definition, and generate code into gen/.2
Implement the service and adapter
service.go implements the generated server interface; all business logic lives there. adapter.go implements model.BackendAdapter: Read uses capabilitywire.DecodeBinaryRequest(string(Backend<X>), req) to extract (grpc_method, protobuf bytes), calls the service after proto.Unmarshal, and marshals the response with proto.Marshal into ReadResult.Data; unknown methods return IOErrParam + <kind>:unsupported_method, and a malformed envelope / proto returns <kind>:bad_request. Write of a read-only capability is never called (the module declares ReadOnly: true). Backends with external dependencies should also implement model.ErrorClassifier and adaptive.OutcomeClassifier.3
Provide an assembly.Module
Export
Module() (optionally taking dependencies as parameters) returning assembly.Module{Kind, ReadOnly, Build, Stub, Admission}. Lightweight in-process backends use adaptive.FixedConfig(n); backends with external dependencies use AIMDConfig.ResolveAdmission, with Enabled checking whether the configuration is complete and Stub providing the dev fallback (without one, startup is refused when unconfigured). To borrow another backend, inject the raw adapter the way localtestservice.BlockDBReader does: borrowing does not own it (Close does not close it), admission is the borrower’s own responsibility, and errors caused by the local ctx ending are passed through as-is before delegating classification to the dependency.4
Register in both binaries
Add a switch field in
internal/worker/app/config.go, add the env override in internal/worker/app/app.go and backendModules = append(backendModules, <kind>.Module(...)); append likewise in cmd/syncinvoker/io.go. Add a line to deploy/env/worker.env.example.5
Test
adapter_test.go covers: the happy path, parameter defaults, unknown operation, malformed envelope, malformed proto, Write rejection, and the Module() declaration; composite backends additionally cover typed request forwarding, clean failure when the dependency is not injected, and pass-through of the dependency’s error classification. For e2e, in cmd/worker/ follow worker_process_localtestservice_test.go to write a positive case (task SUCCEEDED under FUNCTION_CODE_AUDIT_MODE=enforce) and a <KIND>_DISABLED=true negative case (task FAILED); devstub functions are registered in internal/worker/app/function_code.go.connection.py channel getter, the executor BridgeChannel wiring, and the five table additions in python/blockx_audit/tables.py), see Python Executor and docs/capability-backend-guide.md §3–§4.
The fields of assembly.Module:
OuterWrap is the only layer allowed outside admission (currently only the NodeRPC semantic batching noderpc.NewBatchingBackend, gated by NODE_RPC_SEMANTIC_BATCH_MODE, default off).
Other common changes
- Adding a compile-time cache namespace for a hot
(backend, operation): modifyknownIOCacheNamespaceand the corresponding enum inworker_scope.go, and updateTestKnownIOCacheNamespacesAreDistinctEnums. - Tuning a backend’s AIMD feedback: modify
adaptive.go(ClassifyAdaptiveOutcome) in that SDK package; do not touchinternal/io/adaptive; useadaptive.IsTransportFailure / IsTransportFailureMessageto detect transport-layer errors. - Adding a cacheable read that needs per-request validation: have the request type implement
model.CachedReadValidator.
Testing
internal/io/core/core_test.go: the request-path backbone — cache hits / cross-task sharing (TestWorkerScope_CacheIsSharedAcrossTaskLifetimes), singleflight (TestTaskScope_ReadSingleflight), task window acquire / release (TestTaskScope_TaskWindowWaitsUntilCapacityIsReleased,TestTaskScope_IOBackendAttemptReleasesTaskWindow), scope close and late results (TestTaskScope_ActiveBackendContextLifecycle,TestTaskScope_ReadAfterClose,TestTaskScope_CloseIdempotent,TestWorkerScope_CloseCascades), write rejection on read-only backends, and empty cacheKey bypass (task_scope_bypass_test.go).internal/io/core/retry_test.go: retry scope gating, a single leader owning the retry, resource release between attempts, backoff boundaries.internal/io/core/task_scope_drain_test.go: concurrentCompleteandDrainAlldo not double-close.internal/io/adaptive/limiter_test.go,lanes_test.go,backend_test.go,backend_budget_test.go: each AIMD rule, probing, fair lanes, admission queueing not charged to the budget, timeout starting only after admission.internal/io/assembly/assembly_test.go: stub fallback, duplicate kind, closing already-built adapters on failure,OuterWrap.internal/io/capabilitywire/wire_test.go: three-way envelope binding, shape errors failing closed.internal/io/sflight/sflight_test.go,internal/io/cache/cache_test.go: standalone unit tests for singleflight and the cache.- Benchmarks:
internal/io/core/bench_test.go,adaptive/lanes_bench_test.go. - Process-level e2e tests that involve the Executor live in
cmd/worker/worker_process_*_test.go(e.g.worker_process_blockdb_bridge_test.go,worker_process_localtestservice_test.go). See Test organization and commands for more.
Related docs
Specs and design docs in the blockx repo:docs/specs/io-subsystem.md: the behavioral spec of this subsystem (Scoped Resource Context, adapter responsibilities, AIMD feedback classification, Close semantics, cache policy and fork risks, configuration items).docs/io-backend-module-design.md: design motivation and invariants of the backend module assembly layer.docs/capability-backend-guide.md: file-by-file guide and checklist for adding a capability backend.docs/specs/architecture.md§4.2.5 / §4.2.6: where the IO access subsystem and the shared state cache sit in the overall architecture.docs/specs/noderpc-semantic-batching.md: NodeRPC semantic batching (the user ofOuterWrap).docs/deploy.md: deployment notes for the IO / AIMD-related environment variables.
Worker
Host of WorkerIOScope; when task scopes are created and closed.
Backend Adapter
Concrete implementations and error classification of the BlockDB / NodeRPC / logical-types / Iceberg adapters.
Python Executor
How IO requests are intercepted on the Python side and relayed back over UDS.
Call execution subsystem
The Orchestrator that hosts HandleIO, and the call result cache.