Skip to content

Design decisions

The choices that shaped Conveyor, with the reasoning, so they are not relitigated by accident. Dates refer to when the decision was made or measured.

Acks are the durability boundary

Bazel's protocol lets the server acknowledge events at any time. Conveyor acknowledges only after the commit, because the alternative (ack on receipt, persist later) turns every crash into silent data loss that Bazel cannot detect. The cost is latency between event and ack; the design keeps it low with group commits and was measured at p99 146 ms with 1,000 concurrent streams.

Fencing instead of cluster locks

A build's stream can land on any node, and a retried stream can land on a different node while the first worker is still alive. Rather than a distributed lock, every commit is a compare-and-set on the invocation's last_event_seq. The stale worker's commit fails, it exits, and Bazel's retry continues on the live one. This works with no cluster membership at all, so a partitioned cluster degrades to slower live updates, not to corrupted builds.

PostgreSQL only

No Kafka, no Redis, no search engine. Everything is in one PostgreSQL database with daily partitions for the bulk data. Operators get one thing to back up and scale, and the write path stays simple enough to profile. The measured limit is Postgres CPU at roughly 10k events/s per vCPU, which is far beyond real build event rates.

Raw events are kept, briefly

The full BEP stream is stored compressed for RETENTION_RAW_DAYS (14 by default) so that any question the UI does not answer today can be answered later, and so the Events tab and downloads are exact. Normalized rows stay for RETENTION_DAYS (90).

With the raw archive on, a finished build's raw events and log move to the blob store a day later, one object each, written behind the acknowledgement: every ack is still a PostgreSQL commit (raw bytes skipping the database would need the store or a log in the ack path), and one object per finished build keeps request costs at two PUTs per build where one object per flush would cost twenty times more. The database is not sharded: one database carries tens of thousands of events/s, so history size, not commit rate, was the first limit (docs/spec/Archive.tla checks the handover).

Tag counts are eventually consistent

Facet counts are hints for autocomplete and ordering. Updating them inside the ingest transaction serialized every writer on the same rows (measured: over 90 % of active backends waiting on those locks). They are now counted by one process per node and written once a second, and rebuilt after bulk deletes.

Structured command lines are not copied

Bazel sends the canonical and original structured command lines, about 70 KB per build together, and they are shown nowhere the UI could not derive from the parsed options. They remain in the raw event stream. Dropping the copy cut per-build storage by 40 %.

No index on columns the per-batch update touches

An index on last_event_at made every one of the roughly three updates per build non-HOT, rewriting six indexes each time. It was never queried. The rule is recorded in the handoff notes for contributors.

The log never crosses the WebSocket

Buildkite-style tabs crash on large logs because the whole log lives in the DOM or as JavaScript strings. Conveyor streams the log over HTTP into a Web Worker that keeps it as UTF-8 pages plus line offsets, applies terminal emulation on bytes, and hands the page only the lines on screen. Live appends splice by byte offset. A 50 MB log loads in 0.2 s and filters in 9 ms.

Two ports

gRPC (HTTP/2, 1985) and HTTP (4000) are separate listeners rather than one multiplexed port. Balancers and ingresses handle gRPC differently from browser traffic (protocol annotations, idle timeouts, body sizes), and separate ports keep each simple.

Scheduler busy-wait off

The BEAM spins schedulers waiting for work, which showed as over 100 % CPU at under 1 % real utilization with 1,000 idle-ish streams. The release runs with +sbwt none; on a 250 ms ack budget the microseconds it costs are irrelevant, and CPU-based autoscaling would otherwise scale on nothing.

Original implementation

Conveyor was written from the Bazel protocol definitions and documentation. No code from other Build Event Service implementations was read or reused; the vendored .proto files carry their Apache-2.0 notices.

Blobs are not shared across projects

A content-addressed store could keep one copy of identical content for every project. Conveyor keys blobs by (project_id, digest) and stores them under a per-project prefix instead. Separation wins over deduplication: one project's data is one prefix that an operator can list, expire, move or delete without touching another, FindMissingBlobs answers per project, and a project's retention frees its own bytes. Identical profiles and test outputs across projects are rare; the cost is a few duplicate objects.

Input sets expand as map unions

Execution logs intern files and input sets, and sets reference other sets: a DAG in which a test's runfiles reach a library's headers through every dependent. Expanding by concatenating lists repeats every shared file once per path, exponentially in depth; on the AWS trial a 429-spawn log walked 26 million list elements and never finished inside the job's rescue window. The expansion is a map keyed by path, memoized per set, and a union costs the smaller side. A lattice test keeps it linear.

Instance replacement follows the node, not readiness

/health/ready says "this node can serve": it checks the database and turns 503 while draining. That is what a balancer needs, and the wrong signal for replacing instances: a slow database made an auto-scaling group replace healthy nodes and make things worse. The group uses EC2 health; a crashed container is Docker's restart policy's job; routing stays the balancer's decision.

Still open

  • Reference hardware for the published performance envelope.
  • A minimum supported Bazel version (7 is the tested floor).