Generation Barriers — Lockstep Worker Synchronization¶
See also: Documentation Index, PBT Core Components, Workload Orchestrator, Performance Evaluation, ADR-003
Why barriers exist¶
When N workers evaluate in parallel on one host, their critical paths inevitably diverge: one finishes pg_reload_conf() quickly while another spins through a slow restart; one warms up its buffer cache from disk while another already has data resident. If the workers' measurement windows don't overlap, the late one measures under lighter contention than the early one — and the score difference reflects scheduling artefacts, not configuration quality.
The GenerationBarrier enforces lockstep evaluation: every worker waits at every sub-step until all workers have arrived. This guarantees that the measurement window (B9) of every worker overlaps the measurement window of every other worker in the same generation. The score difference between workers within a generation then reflects only their knob configurations.
This is the single piece of machinery that makes "8 workers in parallel on one host" methodologically defensible. Without it the parallel speedup would come at the cost of measurement bias.
Table of Contents¶
- Barrier table (B1–B17)
- API
- Graceful degradation
- Why no timeout
- Sequential mode
- Integration with the orchestrator
- Design decisions
- Related documentation
Barrier table (B1–B17)¶
Every call into WorkloadOrchestrator.evaluate_worker() passes through 17 ordered sub-steps. Each one ends with barriers.wait(name, worker_id) that blocks until all N workers in the generation have arrived.
| # | Name (BARRIER_NAMES) |
Sub-step | What just completed |
|---|---|---|---|
| B1 | connected |
Connect | TCP/psycopg2 connection established. |
| B2 | config_applied |
Apply config | ALTER SYSTEM SET … + pg_reload_conf() for sighup knobs. |
| B3 | restarted |
Restart (or skip) | Restart triggered by should_restart finished. Workers that didn't need a restart pass through immediately and wait here for those that did. |
| B4 | reconnected |
Reconnect | Post-restart re-connection succeeded. |
| B5 | config_verified |
Verify | KnobApplicator.verify() read-back; quantised values merged into worker.knob_config. |
| B6 | pre_stats_captured |
Pre-stats snapshot | pg_stat_database / pg_stat_bgwriter / pg_stat_user_tables snapshot taken. |
| B7 | benchmark_ready |
Benchmark prepare | Schema validated; data loaded if first run. |
| B8 | warmup_done |
Warmup | Executor's warmup phase complete. |
| B9 | measurement_done |
Measurement | Timed measurement window complete. This is the only window whose metrics enter the score. |
| B10 | post_stats_captured |
Post-stats snapshot | Second pg_stat_* snapshot. |
| B11 | io_computed |
I/O delta | I/O delta + buffer stats derived from pre/post snapshots. |
| B12 | system_metrics_collected |
System metrics | Memory + cache + scan metrics via metric_instrumentation. |
| B13 | memory_pressure_computed |
Memory pressure | Derived memory-pressure signal computed. |
| B14 | reliability_gated |
Reliability gate | Failure / degradation classification applied to the gate G ∈ [0, 1]. |
| B15 | vacuum_done |
Vacuum analyze | Post-DML VACUUM ANALYZE (bounded by vacuum_analyze_timeout_seconds). |
| B16 | score_computed |
Score | CompositeScorer.score() → ScoreBreakdown. |
| B17 | disconnected |
Disconnect | Connection closed. |
The canonical list is the BARRIER_NAMES constant. Any code that adds or removes a sub-step must update this list and the orchestrator's call sites together.
API¶
Location: src/tuners/engine/barriers.py
class GenerationBarrier:
def __init__(self, num_workers: int, enabled: bool = True) -> None: ...
@property
def enabled(self) -> bool: ...
@property
def broken(self) -> bool: ...
def wait(self, name: str, worker_id: int) -> None: ...
def drain_remaining(self, start_from: str, worker_id: int) -> None: ...
def abort(self) -> None: ...
def reset(self) -> None: ...
def next_barrier_name(self, current: str) -> Optional[str]: ...
wait(name, worker_id)¶
Blocks until all num_workers workers have called wait() with the same name. Logs the wait under the per-worker WorkerBarrier logger context. Raises ValueError if name is not in BARRIER_NAMES. Returns immediately if the barrier set is disabled or broken.
drain_remaining(start_from, worker_id)¶
Releases all barriers from start_from onward (inclusive). Called by a worker's exception handler: when worker k fails at sub-step j, it calls drain_remaining(name_at_j, k) so it still "arrives" at every barrier from j onward, letting peers proceed instead of deadlocking at the next barrier.
Internally this is a loop of barrier.wait() calls — it does not break the barriers, only contributes this worker's missing arrival.
abort()¶
Instantly breaks every barrier (raises BrokenBarrierError on all current and future waiters) and marks subsequent wait() calls as no-ops. In the PBT path it is called from exactly one place: the except clause around future.result() in Population.evaluate_generation() — i.e. when a worker thread's evaluation raises. Unlike drain_remaining, abort() does not try to honour the protocol — it tears down the whole synchronization mechanism for the generation.
reset()¶
Recreates the barriers for the next generation. Called once per generation by Population.train_generation().
next_barrier_name(current)¶
Returns the name of the barrier immediately after current, or None if current is the last barrier. Used by orchestrator error paths to construct the correct start_from argument for drain_remaining().
Graceful degradation¶
A live worker thread that crashes mid-evaluation must not deadlock the other threads at the next barrier. Two exception-driven safeguards make this work; a worker that hangs without raising is the one case they cannot reach.
┌────────────────────────────────────┐
│ Worker thread runs evaluation │
└──────────────────┬─────────────────┘
│
┌──────────────┬────────────────┼─────────────────┬────────────────────┐
│ │ │ │ │
succeeded raises, caught raises, reaches hangs without raising
│ in the worker the population (no timeout fires)
│ │ future.result() │
▼ ▼ │ ▼
wait() through drain_remaining ▼ nothing can trip
B1..B17 (next barrier population calls abort(); peers
normally after failure), barriers.abort() block at the next
then re-raises → breaks every barrier forever
│ │ barrier │
└──────────────┴────────────────┘ deliberately accepted
▼ residual — see
Peers proceed past every barrier "Why no timeout"
(real arrivals, or drained / aborted).
Path 1 — clean completion¶
Every worker calls wait("connected", id) … wait("disconnected", id) in order. The orchestrator's normal control flow handles this.
Path 2 — caught exception (drain_remaining)¶
The orchestrator wraps each sub-step in a try/except. If, say, sub-step B7 raises:
Now this worker has "arrived" at B7, B8, B9, …, B17 from the barrier's point of view. Peers can finish their generation; the failing worker propagates the exception up to the population layer, which logs it and lets rescue_dead_workers() handle the recovery on the next generation boundary.
Path 3 — raised exception reaches the population (abort)¶
When a worker's evaluation raises and the exception propagates out of evaluate_worker to the population's future.result(), the population calls barriers.abort(). Every waiter immediately receives BrokenBarrierError, the orchestrator catches it, every per-worker thread exits its evaluation, and the generation ends with a rescue_dead_workers() call.
Exception-driven by design — and what that leaves uncovered. Both escape paths fire only on a raised exception, and that is deliberate. The layers around the barrier already convert the failures that matter into exceptions: connection attempts are bounded by connect_timeout, the external benchmarks bound their own work (TPC-H sets a failsafe statement_timeout; sysbench runs under a subprocess communicate(timeout=…)), B15's VACUUM ANALYZE is bounded by vacuum_analyze_timeout_seconds, and the environment lifecycle operations (verify_instances, _wait_until_connectable, recover_instance / rebuild_worker_instance, Docker's SDK operation timeouts) either return or raise rather than block forever. A crashed, refused, dropped, or unresponsive instance therefore surfaces as an exception that trips drain_remaining (Path 2) or abort() (Path 3). Liveness is not polled on a background thread — there is no health-check thread and no DatabaseEnvironment.is_alive(), and neither has ever existed in the codebase.
The one case neither path reaches is a worker that hangs without raising: a synchronous call on its main thread that neither returns nor raises and is bounded by no timeout — for example a query on an already-established connection to a server wedged at the socket-read level, where connect_timeout bounds only the initial handshake and no statement_timeout is set (the internal WorkloadExecutor sets none on its measurement queries). Nothing outside that thread can trip the abort, so its peers block at the next barrier indefinitely. This residual is accepted on purpose: the only ways to bound it are a per-barrier timeout (rejected — see Why no timeout) or a genuine out-of-band liveness thread (not implemented). It is a bounded, understood trade-off, not an unnoticed hole.
Why no timeout¶
threading.Barrier accepts a per-wait timeout argument. We deliberately don't use it.
The problem. A timeout that's short enough to detect a true hang (e.g. 60 s) will fire spuriously on legitimately slow operations:
- A TPC-H Q21 on a small-RAM container can take 5–10 minutes.
- A first-time
dbgendata load can take many minutes. - A
restart_instance()after apostmasterknob change can take 30–60 s on its own; coupled with a slow disk, the B3 barrier might legitimately need to wait several minutes.
Any timeout small enough to be useful for hangs is small enough to false-positive on these. False positives convert "everything is fine, just slow" into a broken-barrier event that triggers rescue_dead_workers() for all workers — losing the generation.
The solution. Wait indefinitely and let liveness surface as an exception from the layers around the barrier. Connection attempts (connect_timeout), the external benchmarks (TPC-H's failsafe statement_timeout, sysbench's subprocess communicate(timeout=…)), the bounded B15 VACUUM ANALYZE, and the environment lifecycle operations (verify_instances, _wait_until_connectable, recover_instance / rebuild_worker_instance, Docker's SDK operation timeouts) each bound their own work and raise on a dead or unreachable instance — telling "PostgreSQL is busy" (a slow call that eventually returns) apart from "PostgreSQL is dead" (a call that errors). A raised exception then trips drain_remaining or abort(); the barriers themselves never time out.
These probes are synchronous, not a background poller: they surface a failure only at a call that actually returns or raises. A worker wedged in a call that does neither — the residual described under Graceful degradation — is not bounded by them. Accepting that narrow residual is the price of never false-positiving on a legitimately slow generation.
Sequential mode¶
GenerationBarrier(num_workers=N, enabled=False) produces a no-op barrier object. wait() returns immediately; drain_remaining() and abort() are also no-ops. This is used in:
- single-worker debugging (
--population 1plus--no-sync), - unit tests that exercise the orchestrator's body without setting up real concurrency,
- the BO baseline's parallel path, which manages its own ask-tell synchronization and doesn't need lockstep barriers.
Code that consumes the barrier object never has to branch on the enabled flag — the no-op API is identical.
Integration with the orchestrator¶
Population.train_generation()
│
├─► barriers.reset() # fresh barriers for this generation
│
├─► ThreadPoolExecutor(max_workers=N)
│ │
│ │ for each worker:
│ │ orchestrator.evaluate_worker(worker)
│ │ │
│ │ ├─► connect()
│ │ ├─► barriers.wait("connected", worker_id) # B1
│ │ ├─► apply_configuration()
│ │ ├─► barriers.wait("config_applied", worker_id) # B2
│ │ ├─► maybe_restart()
│ │ ├─► barriers.wait("restarted", worker_id) # B3
│ │ │ ... (B4–B17) ...
│ │ └─► return (metrics, score_breakdown)
│ │
│ │ on exception in orchestrator:
│ │ barriers.drain_remaining(failed_sub_step, worker_id)
│ │ re-raise
│ │
│ │ population, on exception from future.result():
│ │ barriers.abort() # the only abort call site
│ │ re-raise
│
├─► rescue_dead_workers()
├─► update_metric_ranges_if_needed() / _finalize_scores()
├─► record_generation()
└─► execute_exploit_explore() / env.clone_instances() / should_stop()
The orchestrator owns the try/except + drain_remaining plumbing; the population owns reset and abort. The barrier object itself owns no state beyond the threading.Barrier instances and a single _broken flag.
Design decisions¶
1. One barrier per sub-step, not one per phase¶
We could coarsen to 3–4 barriers (e.g. apply_done, measurement_done, score_done). We don't, because the measurement-window overlap guarantee is what matters scientifically, and that requires sub-step-level alignment around B7–B10. The B1–B17 granularity is a cheap-to-pay generalisation of that need to the whole evaluation.
2. Fixed barrier names, not dynamic¶
BARRIER_NAMES is a hard-coded list. Adding a barrier means editing both the list and the call sites. This makes the protocol auditable: the doc table above is enforced by the type system, not a convention. A dynamic registry would risk silent name drift between orchestrator and barrier object.
3. No timeout¶
See Why no timeout. The cost of a false positive (a lost generation) is high and certain; the cost of the uncovered case (a worker that hangs without ever raising) is a rare, deliberately accepted residual, because every failure that raises is already caught by the exception-driven drain_remaining / abort() paths.
4. Graceful degradation has two paths, not one¶
drain_remaining and abort are distinct because they describe different failures. drain_remaining is for "this worker is failing cleanly and propagating an exception" — peers still trust the protocol. abort is for "the protocol is broken; tear it down" — peers no longer trust the protocol. Coalescing them would lose that distinction and prevent the orchestrator from cleanly logging which workers failed for which reasons.
5. Barrier object owns minimal state¶
The barrier object only knows about num_workers, BARRIER_NAMES, and a _broken flag. It does not know about generations, workers, or scoring. Population owns the lifecycle (reset, abort); orchestrator owns the call sites (wait, drain_remaining). This keeps the file small and testable.
Related documentation¶
- PBT Core Components — how the population drives the barriers each generation.
- Workload Orchestrator — the orchestrator's body and where each
wait()call sits. - Performance Evaluation — the measurement window the barriers protect.
- Environment Backends —
verify_instances()and therecover_instance/rebuild_worker_instancerecovery ladder. - ADR-003 — Lockstep generation barriers — the design decision record.
File locations¶
GenerationBarrier,BARRIER_NAMES: src/tuners/engine/barriers.py- Orchestrator call sites: src/tuners/engine/orchestrator.py
- Tests: tests/unit/tuners/engine/test_barriers.py, tests/unit/tuners/pbt/test_dead_rescue_convergence_and_restart.py