ReserveWorkerSlot(taskId), it picks a candidate from the Worker view rebuilt from etcd heartbeats, calls RequestTaskSlot on that Worker, and hands workerAddr + slotId back to the Client. From then on, the Client submits the task directly to the Worker and the Coordinator is no longer involved. See Architecture overview for where it sits in the overall architecture, and Task lifecycle for the main path.
Responsibilities and boundaries
Responsible for:- Watching Worker registrations and heartbeats in etcd and maintaining a local, disposable
WorkerView. - For each
ReserveWorkerSlot, performing validation, the readiness check, candidate filtering and ranking, and then trying a small number of candidates serially. - Passing the
slotIdreturned by the Worker through to the Client as-is, without interpreting its internal meaning. - Applying local penalties and short circuit-breaking to Workers based on recent failures.
- Parsing the task payload, generating
taskId, or receivingSubmitTaskorTaskResult. - Maintaining a centralized slot ledger, a pending queue, or global
taskIddeduplication. - Writing any data to etcd; etcd only carries Worker discovery and heartbeats.
- The Worker’s local slot state machine is the single source of truth; a Coordinator restart only loses the view cache and routing bias, and does not affect correctness.
CoordinatorCoreis a Sans-IO pure decision engine: it never reads the clock or touches the network, and every method receivesnow(UTC milliseconds) explicitly.- Before the first full Worker view is established, and after a watch outage exceeds the grace period,
ReserveWorkerSlotalways returnsUnavailable. - A single request tries candidates serially with no hedged requests; when the outcome is uncertain, it retries only against the same Worker with the same
taskId, and never switches Workers. - The same code runs as two sets of processes with different
app.Profilevalues:cmd/coordinator(block cluster) andcmd/bundle_coordinator(bundle cluster). See Bundle clusters for bundle cluster details.
Code location
Core types and interfaces
core
core.Config(internal/coordinator/core/coordinator.go): selection and protection parameters;DefaultConfig()provides the defaults, andValidate()requiresWatchGracePeriodMs <= HeartbeatTimeoutMs.core.CoordinatorCore: holdsmap[string]*WorkerView,initialized, andwatchDisconnectedAtMs. Event entry points:ApplyFullWorkerList,ApplyWorkerUpdate,ApplyWorkerRemoval,MarkWatchDisconnected,MarkWatchReconnected. Decision outputs:IsReady,SelectCandidate. Feedback entry points:RecordSlotSuccess,RecordSlotRejection,RecordSlotBackpressure,RecordSlotTimeout.core.WorkerView(internal/coordinator/core/worker_view.go): embedsetcd.WorkerHeartbeatand adds three fields that exist only locally and are never written back to etcd.
WorkerView.UsedTotal()=ReservedSlots + RunningTasks;WorkerView.FreeCapacity()=TaskSlots - UsedTotal().core.CandidateResult{WorkerAddr, Err}andcore.CoordinatorError{Code, Message}: the output ofSelectCandidate;Codeis acommontypes.ErrorCode.core.ValidateReserveRequest(taskID)(internal/coordinator/core/validate.go): only checks thattaskIdis non-empty.
adapters
adapters.CoordinatorServer(grpc_server.go): implementscoordinatorpb.CoordinatorServiceServer; holdscore, aSlotReserver, and async.RWMutex.Lock/Unlockare exposed so the watcher can write to the core.adapters.HandlerConfig{MaxTimeoutRetries}: number of retries against the same Worker when the outcome is uncertain; defaults to1.adapters.SlotReserverinterface:RequestTaskSlot(ctx, workerAddr, req) (*commontypes.TaskSnapshot, *SlotError); replaced with a mock in tests.adapters.WorkerRPCClient(worker_rpc.go): the gRPC implementation ofSlotReserver; caches onegrpc.ClientConnperworkerAddr.grpcErrToSlotErrordecidesSlotError.MayHaveAllocated.adapters.SlotError{Code, Message, MayHaveAllocated}:MayHaveAllocated=truemeans the request may have reached the Worker, so retries must stay on the same Worker.adapters.EtcdWorkerWatcher/EtcdWatcherConfig{Endpoints, KeyPrefixes, DialTimeout, RetryDelay}(etcd_watcher.go):Run(ctx)blocks while running list+watch.adapters.BundleCoordinatorServer(bundle_grpc_server.go): a thin translation layer from the bundle proto toCoordinatorServer.
app and api
app.Profile{Deployment, Service, WorkerRegistryPrefixes}(internal/coordinator/app/app.go): the entry point’s identity.Deploymentmust be"block"or"bundle", and determines which gRPC service is registered and which registry kind is allowed.app.Run(p Profile): wires obs, core,WorkerRPCClient,CoordinatorServer, the etcd client, the watcher, and the gRPC server, then blocks until SIGINT/SIGTERM.app.CoordinatorRuntimeConfig(config.go): the process-level configuration aggregate;LoadCoordinatorRuntimeConfigFromEnv(p)applies environment variable overrides and callsValidate().etcd.WorkerHeartbeat(api/etcd/types.go): the JSON value the Worker writes to etcd, with fieldsworkerAddr,taskSlots,reservedSlots,runningTasks,cpuPercent,memoryPercent,memoryAvailableBytes,memoryTotalBytes,processRssBytes,errorRate, andlastHeartbeatMs.etcd.ParseRegistryPrefixand the constantsLegacyBlockWorkerRegistryPrefix(/blockx/workers/),LegacyBundleWorkerRegistryPrefix(/blockx/bundle-workers/),ProdDefaultBlockWorkerRegistryPrefix,ProdDefaultBundleWorkerRegistryPrefix,TestDefaultBundleWorkerRegistryPrefix(api/etcd/registry.go).
Data flow / execution flow
The etcd key is<prefix> + workerAddr, and the value is the WorkerHeartbeat JSON. The Worker side refreshes it periodically with a leased Put; the write logic lives in internal/worker/adapters/etcd_publisher.go (lease TTL defaults to 10s; see the Worker configuration HeartbeatIntervalMs for the heartbeat interval). The Coordinator is read-only.
The orchestration logic of CoordinatorServer.ReserveWorkerSlot runs in the following order (internal/coordinator/adapters/grpc_server.go):
1
Validation and readiness check
core.ValidateReserveRequest rejects an empty taskId; when core.IsReady(now) is false, it returns Unavailable immediately, without sending a request to any Worker.2
Candidate selection
Calls
core.SelectCandidate under the read lock. The hard filters, in order: already in excludeAddrs, heartbeat timed out (now - LastHeartbeatMs > HeartbeatTimeoutMs), CPUPercent/MemoryPercent/ErrorRate above the red line, circuit breaker not yet expired, FreeCapacity() <= 0. The remaining Workers are ranked by candidateRanksBefore: larger FreeCapacity first, then more recent LastHeartbeatMs, then smaller Penalty, and finally lexicographic order of WorkerAddr.3
Reserving on the Worker
WorkerRPCClient.RequestTaskSlot calls the Worker with WorkerRPCTimeout. grpcErrToSlotError splits gRPC status codes into two classes: InvalidArgument/ResourceExhausted/FailedPrecondition/NotFound are explicit rejections; DeadlineExceeded/Canceled/Aborted/Internal/Unknown plus timeout-style Unavailable are marked MayHaveAllocated=true. A successful response with an empty SlotID is also treated as uncertain.4
Feedback and convergence
On success,
RecordSlotSuccess resets the failure count. On an explicit rejection: a Worker ResourceExhausted (mapped to ErrNoSlot) goes through RecordSlotBackpressure and does not count toward the circuit breaker; other rejections go through RecordSlotRejection. An uncertain outcome is first retried on the same Worker; if it is still uncertain, RecordSlotTimeout is called and Aborted is returned. An explicit rejection received during the retries moves on to the next candidate.CoordinatorCore has no locking of its own; CoordinatorServer.mu is the only synchronization point. The watcher writes to the core via srv.Lock()/Unlock(), and the handler selects candidates under the read lock and records feedback under the write lock.
Public error codes
The public protocol is gRPC. Business error codes are mapped to gRPC status codes incoordCodeToGRPC:
When the reservation is still unconfirmed after retries, the Coordinator returns gRPC
Aborted; coordCodeToGRPC maps ErrSlotUncertain to Aborted, and the test utility internal/testutil/rpc_types.go interprets it back as ErrSlotUncertain. Callers should treat Aborted as “possibly reserved; wait at least one slot TTL before retrying” and Unavailable as “safe to retry immediately”.State and lifecycle
CoordinatorCore has only two global state flags and no explicit state machine:
initialized: becomes true after the firstApplyFullWorkerListcall and never reverts. The watcher calls it only after every configured prefix has completed its initial list.watchDisconnectedAtMs: when the watch on any prefix disconnects,MarkWatchDisconnected(now)records the time of the first disconnect;IsReadyandSelectCandidatereturnUnavailableoncenow - watchDisconnectedAtMs > WatchGracePeriodMs. After every prefix has re-listed successfully and all are connected again,MarkWatchReconnectedclears it.
RecentFailureCountandPenalty:RecordSlotRejection/RecordSlotTimeouteach increment by one, andPenalty = float64(RecentFailureCount).CircuitBreakerDeadlineMs: set tonow + CircuitBreakerCooldownMs(default 10s) whenRecentFailureCount >= CircuitBreakerThreshold(default 3); the Worker is hard-filtered for that period.RecordSlotSuccessclears all three together;RecordSlotBackpressureleaves all three untouched.ApplyFullWorkerListpreserves these three fields for surviving Workers; a Worker that reappears after being deleted byApplyWorkerRemovalstarts from zero.
- The slot TTL (
SlotTTLMs, default 5s) is the reservation window the Coordinator passes to the Worker inRequestTaskSlot; it is not a task execution timeout, and the Coordinator does not interpret it further. - A single
ReserveWorkerSlothas no separate overall time budget; the upper bound is determined jointly byMaxCandidates × (1 + MaxTimeoutRetries) × WorkerRPCTimeoutand the caller’s ctx, roughly3 × 2 × 3sin the default worst case. - Heartbeat freshness is computed by subtracting the Worker-written
lastHeartbeatMsfrom the Coordinator’s local clock, so clock skew between machines directly affects the filtering result.
Configuration
All configuration comes frominternal/coordinator/app/config.go; defaults come from DefaultCoordinatorRuntimeConfig, core.DefaultConfig, adapters.DefaultHandlerConfig, and adapters.DefaultEtcdWatcherConfig.
The observability-related
BLOCKX_PROMETHEUS_LISTEN_ADDR, BLOCKX_PROMETHEUS_PATH, CHAINTABLE_LOG_BROKERS, and CHAINTABLE_LOG_TOPIC are handled centrally by internal/obs; see Observability. COORDINATOR_CPU_PROFILE / COORDINATOR_TRACE make app.Run write pprof / runtime trace files for the entire process lifetime, and are only for performance troubleshooting.
Profile default registries:
cmd/coordinator:/blockx/workers/.cmd/bundle_coordinator:/blockx/bundle-workers/and/blockx/prod/lanes/default/bundle-workers/.- Test clusters replace them entirely with
COORDINATOR_REGISTRY_PREFIXES=/blockx/test/lanes/default/bundle-workers/.
EtcdWorkerWatcher merges by WorkerAddr: the entry with the newer LastHeartbeatMs wins; on a tie, the v2 format beats legacy; on a further tie, the prefix strings are compared. A Worker is removed from the view only after it has disappeared from every prefix. See docs/specs/2026-07-28-registry-prefix-migration.md in the blockx repo for details.
Extension points
- Changing the selection policy: touch only
SelectCandidateandcandidateRanksBeforeininternal/coordinator/core/coordinator.go, and addTestSelectCandidate_*cases incoordinator_test.go. The core has no IO, so it can be tested as pure functions. Ranking is currently deterministic; the absolute memory field in the heartbeat (memoryAvailableBytes) is only parsed and takes no part in filtering or ranking. - Adding a Worker feedback type: add a
RecordSlotXxxto the core and dispatch onSlotError.CodeinrecordSlotOutcomeinadapters/grpc_server.go. That is howRecordSlotBackpressurewas added. - Changing Worker error classification:
grpcErrToSlotErrorinadapters/worker_rpc.goandworkerCodeToGRPCon the Worker side (internal/worker/adapters/wire.go) must remain inverses of each other; if you change one, check the other (see Worker). - Adding a deployment form: add a
cmd/<name>/main.gothat declares anapp.Profile, add branches inapp.registerCoordinatorServiceandvalidateRegistryPrefixes, and allow the new worker kind inParseRegistryPrefixinapi/etcd/registry.go. - Replacing the Worker RPC implementation: implement the
adapters.SlotReserverinterface;CoordinatorServerdoes not depend on a concrete client. The currentWorkerRPCClientopens exactly onegrpc.ClientConnperworkerAddrand does not use the multi-connection balancer frominternal/sdk/grpcclient: a point-to-point client targeting a single Worker does not enable a multi-connection strategy. Seedocs/specs/2026-07-23-grpc-client-balancing.mdin the blockx repo for background. - Adding a configuration item: add the field and its
Validaterule incore.Config/adapters.HandlerConfig/adapters.EtcdWatcherConfig, then add environment variable parsing inLoadCoordinatorRuntimeConfigFromEnvinapp/config.go, and extendconfig_test.go.
Testing
internal/coordinator/core/coordinator_test.go: everySelectCandidatefilter condition and ranking rule, circuit breaker trip/recovery, the grace period semantics ofIsReady,ApplyFullWorkerListpreserving local fields.internal/coordinator/adapters/handler_test.go: covers the orchestration loop with a mockSlotReserver(switching candidates after a timeout, returningAbortedwhen retries still time out, returningResourceExhaustedwhen all reject, retrying on an emptySlotID), plus the error codes and compression behavior of the real gRPC server.internal/coordinator/adapters/worker_rpc_test.go:MayHaveAllocatedclassification ingrpcErrToSlotError(connection refused vs timeout/cancel/Aborted).internal/coordinator/adapters/etcd_watcher_test.go: list+watch on embedded etcd, multi-prefix merge and deduplication, readiness state across disconnect/reconnect, skipping unparseable values.internal/coordinator/app/config_test.go,profile_test.go: environment variable parsing andProfilevalidation.e2e/contract/coordinator-etcd/: when etcd starts after the Coordinator, firstUnavailableand then recovery; routing still works when the watch recovers within the grace period; becomesUnavailablebeyond the grace period.e2e/contract/coordinator-worker/: an explicit transport failure switches to the next Worker, aRequestTaskSlottimeout returnsSlotUncertain(Aborted),SubmitTaskis rejected after the slot expires, an oldslotIdis invalid after a Worker restart, and rejection when the unified slot pool is full.
StartCoordinator / StartBundleCoordinator in internal/testutil, using COORDINATOR_* environment variables to shorten the timing parameters. See Testing for the overall test organization.
Related docs
Specs in the blockx repo:docs/specs/task-resource-coordinator.md: detailed Coordinator design; protocol, error semantics, selection rules, consistency boundaries.docs/specs/architecture.md§4.1: the control plane’s position in the overall architecture and its three boundaries.docs/specs/2026-07-28-registry-prefix-migration.md: legacy / v2 registry prefixes, multi-prefix merging, and rollout order.docs/specs/2026-07-23-bundle-coordinator-api.md: why the bundle Coordinator has a separate service identity.docs/specs/2026-07-23-grpc-client-balancing.md: the scope of the multi-connection balancer.docs/specs/worker.md: the authoritative definition ofRequestTaskSlotand the slot state machine.
- Worker: the Worker-side implementation of
RequestTaskSlotand etcd heartbeat publishing. - Bundle clusters: bundle clusters and
cmd/bundle_coordinator. - Protocols and interfaces: gRPC / etcd protocol summary.
- Task lifecycle: the full path from
ReserveWorkerSlottoSubmitTask. - Observability: how metrics, logs, and tracing are wired in.