Skip to content

feat(speculation): route builds through the tree via prioritize#365

Draft
behinddwalls wants to merge 1 commit into
preetam/int/speculate-treefrom
preetam/int/tree-driven-builds
Draft

feat(speculation): route builds through the tree via prioritize#365
behinddwalls wants to merge 1 commit into
preetam/int/speculate-treefrom
preetam/int/tree-driven-builds

Conversation

@behinddwalls

@behinddwalls behinddwalls commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Why?

The speculate controller previously drove a batch's own build directly (publish to build, CAS to Speculating) while the speculation tree it maintained was pure shadow-mode bookkeeping nobody read. To make the tree the actual source of truth for what gets built, the pipeline needs to flip: speculate hands off to prioritize, and prioritize's admission decisions (Prioritized paths) are what the build stage acts on.

This also completes the ownership rule this stack is building toward: the build stage becomes the sole owner of runner interaction for path-level work. Other stages (prioritize's preemptive eviction, speculate's reconcile for dead paths, both landing in a later commit) only ever record a cancel decision as intent (SpeculationPathStatusCancelling) on the tree; nothing but the build stage calls BuildRunner.Cancel.

What?

speculate.go: speculateBatch no longer publishes to build. It CASes Created/Scored batches to Speculating, then publishes the queue to prioritize on every pass (after the CAS, so a concurrent prioritize round never observes a batch as not-yet-Speculating with no later wake-up). The legacy tryFinalize step is otherwise unchanged and still only runs once a batch has reached Speculating on this or a prior pass, so merge timing is unchanged for now — tightening it to gate on the tree's own path outcome is deferred to a later commit in this stack.

build.go is replaced with its tree-driven form: it loads the batch's speculation tree and, per path, either triggers a build for a Prioritized path with no build yet, or enacts a persisted Cancelling intent. Trigger dedup is on the path->build mapping (SpeculationPathBuildStore), checked before Trigger runs since it is readable up front unlike a Build row. The write order is Trigger -> Build.Create -> mapping.Create -> publish to buildsignal; a crash between Trigger and the mapping write is recovered by redelivery re-running the same path, and a lost mapping-create race defers to the winner (republishing buildsignal for its build) rather than erroring. A Prioritized path whose mapping already resolves to a non-terminal build republishes buildsignal to close the crash window between the original Create and its original publish; a terminal build is a no-op, since reconcile is what will fold that outcome into the path's status.

The new Cancelling arm resolves the path's build via the same mapping, calls runner.Cancel if the build is not already terminal, and republishes buildsignal so the poll loop reacts promptly. Every lookup miss (no mapping yet, or a mapping pointing at a missing build row) is tolerated as a no-op rather than an error, leaving the intent for a later reconcile pass to settle. A missing speculation tree, by contrast, is an invariant violation — the tree is created before the batch is ever published to prioritize and is never deleted — so it surfaces as a plain error and dead-letters, failing the batch loudly instead of retrying against broken state.

buildsignal.go picks up its RFC-final wording (build's ID doubling as the runner handle) plus a corrected error-classification doc paragraph — classification is per error cause via the wired classifier walk, not the per-call-site policy the old text described — with no behavior change; the test file is byte-identical to before.

Test Plan

go build ./submitqueue/... ./service/...
go vet ./submitqueue/... ./service/...
bazel test //submitqueue/... //service/... — 56/56 targets pass, including new build.go tests for the Cancelling arm (non-terminal enacts + republishes, terminal no-ops, no-mapping/dangling-mapping skip, runner error surfaces with no silent ack).
make gazelle / make fmt — no diffs beyond the intended source changes.
make e2e-test — 2/2 targets pass (stovepipe, submitqueue), confirming parity: prioritize still admits the single chain path and exactly one build runs per batch.

Issues

Stack

  1. feat(speculate): write the speculation tree in shadow mode #353
  2. @ feat(speculation): route builds through the tree via prioritize #365
  3. feat(speculation): reconcile dead paths and finalize from the tree #371

## Summary

### Why?

The speculate controller previously drove a batch's own build directly (publish to build, CAS to Speculating) while the speculation tree it maintained was pure shadow-mode bookkeeping nobody read. To make the tree the actual source of truth for what gets built, the pipeline needs to flip: speculate hands off to prioritize, and prioritize's admission decisions (Prioritized paths) are what the build stage acts on.

This also completes the ownership rule this stack is building toward: the build stage becomes the sole owner of runner interaction for path-level work. Other stages (prioritize's preemptive eviction, speculate's reconcile for dead paths, both landing in a later commit) only ever record a cancel decision as intent (SpeculationPathStatusCancelling) on the tree; nothing but the build stage calls BuildRunner.Cancel.

### What?

speculate.go: speculateBatch no longer publishes to build. It CASes Created/Scored batches to Speculating, then publishes the queue to prioritize on every pass (after the CAS, so a concurrent prioritize round never observes a batch as not-yet-Speculating with no later wake-up). The legacy tryFinalize step is otherwise unchanged and still only runs once a batch has reached Speculating on this or a prior pass, so merge timing is unchanged for now — tightening it to gate on the tree's own path outcome is deferred to a later commit in this stack.

build.go is replaced with its tree-driven form: it loads the batch's speculation tree and, per path, either triggers a build for a Prioritized path with no build yet, or enacts a persisted Cancelling intent. Trigger dedup is on the path->build mapping (SpeculationPathBuildStore), checked before Trigger runs since it is readable up front unlike a Build row. The write order is Trigger -> Build.Create -> mapping.Create -> publish to buildsignal; a crash between Trigger and the mapping write is recovered by redelivery re-running the same path, and a lost mapping-create race defers to the winner (republishing buildsignal for its build) rather than erroring. A Prioritized path whose mapping already resolves to a non-terminal build republishes buildsignal to close the crash window between the original Create and its original publish; a terminal build is a no-op, since reconcile is what will fold that outcome into the path's status.

The new Cancelling arm resolves the path's build via the same mapping, calls runner.Cancel if the build is not already terminal, and republishes buildsignal so the poll loop reacts promptly. Every lookup miss (no mapping yet, or a mapping pointing at a missing build row) is tolerated as a no-op rather than an error, leaving the intent for a later reconcile pass to settle. A missing speculation tree, by contrast, is an invariant violation — the tree is created before the batch is ever published to prioritize and is never deleted — so it surfaces as a plain error and dead-letters, failing the batch loudly instead of retrying against broken state.

buildsignal.go picks up its RFC-final wording (build's ID doubling as the runner handle) plus a corrected error-classification doc paragraph — classification is per error cause via the wired classifier walk, not the per-call-site policy the old text described — with no behavior change; the test file is byte-identical to before.

## Test Plan

✅ `go build ./submitqueue/... ./service/...`
✅ `go vet ./submitqueue/... ./service/...`
✅ `bazel test //submitqueue/... //service/...` — 56/56 targets pass, including new build.go tests for the Cancelling arm (non-terminal enacts + republishes, terminal no-ops, no-mapping/dangling-mapping skip, runner error surfaces with no silent ack).
✅ `make gazelle` / `make fmt` — no diffs beyond the intended source changes.
✅ `make e2e-test` — 2/2 targets pass (stovepipe, submitqueue), confirming parity: prioritize still admits the single chain path and exactly one build runs per batch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant