diff --git a/Makefile b/Makefile index af3b8aa3..d96e158d 100644 --- a/Makefile +++ b/Makefile @@ -336,7 +336,7 @@ local-stovepipe-gateway-start: build-stovepipe-gateway-linux ## Start Stovepipe mocks: ## Generate mock files using mockgen @echo "Generating mocks..." - @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./extension/counter/... ./extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/core/consumer/... ./submitqueue/core/changeset/... + @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./extension/counter/... ./extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/enumerator/... ./submitqueue/extension/speculation/selector/... ./submitqueue/core/consumer/... ./submitqueue/core/changeset/... @echo "Mocks generated successfully!" proto: ## Generate protobuf files from .proto definitions diff --git a/doc/rfc/index.md b/doc/rfc/index.md index 6fec9e6d..689727c6 100644 --- a/doc/rfc/index.md +++ b/doc/rfc/index.md @@ -10,8 +10,9 @@ Design documents and technical proposals, grouped by scope. Shared/cross-cutting - [Orchestrator Workflow](submitqueue/workflow.md) - Queue-driven controller pipeline from gateway entry through batching, scoring, build, merge, and conclude - [Build Runner](submitqueue/build-runner.md) - Vendor-agnostic BuildRunner interface, provider-neutral BuildStatus lifecycle, and how the orchestrator wires it into the build stage +- [Build Runner](submitqueue/build-runner.md) - Vendor-agnostic BuildRunner interface, provider-neutral BuildStatus lifecycle, and how the orchestrator wires it into the build stage - [Extension Contract](submitqueue/extension-contract.md) - When extensions take orchestrator identity (request/batch) and resolve granular content themselves vs. take controller-resolved data; revises the BuildRunner base/head contract - +- [Speculation](submitqueue/speculation.md) - Why SubmitQueue speculates, the path/tree model, and the two pluggable seams: speculation-tree enumeration and path selection ## Stovepipe diff --git a/doc/rfc/submitqueue/speculation.md b/doc/rfc/submitqueue/speculation.md new file mode 100644 index 00000000..275a7467 --- /dev/null +++ b/doc/rfc/submitqueue/speculation.md @@ -0,0 +1,234 @@ +# Speculation + +How SubmitQueue speculates: why it does, the path/tree model, where it sits in the orchestrator pipeline, and the seams it is built from. Speculation is two layers: **decision seams** — enumeration and scoring *describe* the tree of possible bets, selection and prioritization *act* on it — and **limit policies** that decide *how much* to allow at each step, scaling with the build system's resources. A path's score is not fixed at enumeration; it is recomputed as the batch's world changes, so choices always reflect the latest reality. + +This document captures the concept and the design decisions. + +## Problem: why speculate at all + +SubmitQueue lands batches of changes onto a target branch. Batches that touch overlapping targets conflict, so they form a **dependency DAG**: if batch `B` conflicts with an earlier batch `A`, then `B` must land after `A`. + +The naive policy serializes the DAG: build `A`, wait for it to pass, merge it, *then* build `B` on the new branch tip, and so on. Every batch waits for all of its predecessors to fully validate and merge before its own build can even start. With multi-minute builds and a deep queue, end-to-end latency grows with queue depth and throughput collapses. + +**Speculation removes the wait by betting on the likely outcome.** Instead of waiting for predecessors to merge, the orchestrator *assumes* they will pass and builds a dependent batch now, on top of an assumed-good prefix of those predecessors. If the bet holds — the predecessors pass and merge — the dependent batch has already been validated against the exact tree it will land on, so it merges immediately. Builds for the whole chain run in parallel instead of in series. + +The bet can be wrong. If a predecessor fails, every build stacked on top of it is invalid and the orchestrator must **re-speculate**: discard the broken assumption and fall back to a path that survives (for example, build the dependent batch without the failed predecessor in its base). Because each predecessor is an independent "will it pass?" bet, a batch has *many* possible speculation paths. Enumerating them, choosing among them, and rationing them against finite build capacity — that is what this design is about. + +## Vocabulary + +| Term | Meaning | +|---|---| +| **Batch** | A group of land requests that land together. The unit of speculation. | +| **Dependency DAG** | Conflict graph over batches. `B` depends on `A` ⇒ `B` lands after `A`. | +| **Active dependency** | A dependency batch that is still in flight (not yet in a terminal state). Landed/failed dependencies are no longer active. | +| **Speculation path** | One bet: an ordered **Base** of predecessor batches assumed to pass, plus a **Head** — the batch being verified. Built by applying Base then Head on the target branch and validating. | +| **Speculation tree** | The set of all candidate paths for one batch — its possible bets — each carrying a score and a status. | +| **Score** | A predicted-success number for a path; how good a bet it is. Computed by the scorer and **recomputed as the batch's state changes** — not fixed at enumeration. | +| **Status** | The observed lifecycle state of a path (candidate, building, passed, …). Written only by the controller. | +| **Action** | What the selector asks the controller to do for a path (build, cancel). | +| **Limit** | A "how much" bound produced by a signal-driven policy: the dependency limit, selection limit, and prioritization limit. Scales with build resources (and other signals). | + +The Base/Head shape is the key modelling choice: it maps one-to-one onto the build stage, where **Base** becomes the assumed-good changes to apply first and **Head** becomes the changes under validation. A path validates only the targets changed by its **Head** on top of the assumed-good **Base** — the targets changed by the base batches are covered by *their own* paths, so no path re-validates its base. + +## Where speculation sits in the pipeline + +Speculation is one stage in the orchestrator's queue-driven pipeline (see the [Orchestrator Workflow](workflow.md) for the full picture). It is the hub of two cycles: the build feedback loop `speculate → build → buildsignal → speculate`, and the advance loop `merge → speculate`. + +``` + score ──BatchID──▶┌───────────────── speculate ──────────────────┐──BatchID──▶ merge ──▶ conclude + │ 0. gate on dependency limit 1. enumerate tree │ │ + │ 2. persist 3. reconcile status + rescore paths│◀──BatchID────┘ + │ 4. select 5. enact + write status & score │ (a merge advances + └───┬─────────────────────────────▲─────────────┘ the next batch) + per path │ BatchID │ build result + ▼ │ + build ───Build───▶ buildsignal + (prioritize, then (poll build status) + trigger admitted builds) +``` + +Each speculative path becomes its own build; build results flow back through `buildsignal` into `speculate`, which re-evaluates against the new reality. The controller is a **thin driver**: it gates a batch on the dependency limit, asks the enumerator for the tree structure over the batch's active dependencies, persists it, reconciles each path's status from the latest builds and dependency states, **asks the scorer to recompute each path's score for that new state**, asks the selector which paths to build, then enacts those actions and writes the resulting statuses and scores back to the store. **Prioritization** — rationing the queue's selected builds against its build budget — happens downstream, queue-wide, at the build stage where all of the queue's paths converge. (The pipeline's `score` stage sets the per-*batch* `Batch.Score`; the path scorer inside `speculate` consumes those to score whole *paths*, and reruns as state changes.) + +## Limits and decisions + +Speculation splits into two layers. + +**Decision seams** split into *describing* the tree and *acting* on it. **Enumeration** mechanically lists what futures are possible (the structure); **scoring** predicts how good each is right now (recomputed as state changes). Then a **selector** — the per-batch policy — chooses which of a batch's paths are worth building, and a **prioritizer** — the queue-wide policy — chooses which of all the queue's selected builds actually run right now. Enumeration is deliberately dumb; the intelligence lives in scoring, selection, and prioritization. + +**Limit policies** answer *how much*: the **dependency limit** bounds how many in-flight predecessors a batch speculates over, the **selection limit** bounds how many paths a batch builds in parallel, and the **prioritization limit** bounds how many builds the queue runs at once. These are the resource-aware knobs. Their primary input is the build system's available capacity, but they are not restricted to it — a limit may also weigh historical pass rates, cost budgets, time of day, or an experiment toggle. There is no fixed constant and no prescribed static config: a limit is whatever its policy computes from its signals. + +The two layers compose by **dependency injection**: a decision seam that is bounded by a limit is constructed with that limit policy and calls it itself. The selector holds the selection limit; the prioritizer holds the prioritization limit. The dependency limit is the exception — it gates whether a batch is eligible to enumerate at all, which is controller orchestration, so the controller holds and applies it (and the enumerator stays pure). + +``` + dependency limit ─▶ controller gate ─▶ enumerator.Enumerate(batchID, activeDeps) ─▶ tree structure (status = candidate) + │ + ┌── controller reconciles status, then scorer.Score(tree, deps) each pass ◀───────┘ + ▼ + selector.Select(tree) ──Build / Cancel per path (capped by its selection limit)──▶ controller enacts, + writes status + score, + dispatches builds + │ + prioritizer.Prioritize(queue's pending builds) ──admitted subset (capped by its prioritization limit)──▶ builds run +``` + +### Enumeration + +Enumeration is deliberately **dumb**, and purely **structural**: given a batch and its ordered list of active dependencies, it mechanically lists the candidate paths — the Base/Head splits — and nothing else. It does not score, set status, decide what to build, or decide eligibility. It is **pure and deterministic** — the same inputs always yield the same structure — so the controller can re-enumerate freely whenever the batch's active dependency *set* changes, without the enumerator holding state. Keeping enumeration tractable for a wide dependency list is its only real concern. + +### Scoring + +A path's score is a **prediction** — "how likely is this bet to pay off?" — and predictions must move as evidence arrives. The **scorer** computes each path's score from the current state: the per-batch success probabilities of the path's base batches (`Batch.Score` from the score stage), which of those dependencies have already landed or had their build pass (resolved assumptions raise confidence), and optionally other signals (how long the batch has waited, historical pass rates). Because it is a prediction over live state, the scorer is **re-run on every respeculate**, right after the controller reconciles status — so when a dependency lands, its build passes, or a sibling path fails, the surviving paths' scores are recomputed against the new reality before anything is selected or prioritized. The controller drives *when* to rescore (it is part of reconciliation) and persists the result; the scorer owns the *formula*. The score is the common currency the selector and prioritizer both rank on, so keeping it current is what makes both act on the latest reality. + +### Selection + +Selection is the per-batch **policy** — the part that decides which of a batch's paths are worth building. Given the tree (with each path's controller-stamped status), the selector returns an **action** per path: which to build and which to drop. Strategies span a spectrum: only the single optimistic path (cheapest — bet on the happy case), every candidate (maximum parallelism), or a top-K subset in between. Because it is re-run on every build signal, a strategy can start narrow — build the optimistic path first — and widen later, committing more paths only once earlier bets resolve. + +How *many* paths a batch may build in parallel is not the selector's judgement call but its **selection limit**, a signal-driven policy the selector is constructed with and calls itself. This keeps "which" (the selector's ranking) separate from "how much" (the limit), and lets the limit scale with build resources without touching selector logic. The selector reads the tree and emits actions only; it never reads storage, builds, or scores directly, and it never writes status. It does **not** decide merging — see [Path state](#path-state-status-vs-action). + +### Prioritization + +Selection is per batch and cannot see across batches, so it cannot ration a shared build budget: if every batch in a queue independently selected generously, their combined demand could swamp CI. **Prioritization** is the queue-wide policy that closes that gap. It sees every selected build across all of the queue's in-flight batches, ranks them (by each build's score, plus any fairness or tie-break policy), and admits only the top few that fit the queue's **prioritization limit** — the queue's concurrent-build budget, another signal-driven, resource-scaled policy the prioritizer is constructed with and calls itself. + +Prioritization lives at the **build stage**, where all of the queue's selected paths converge and the build budget is known — not in `speculate`, which is partitioned per batch. The likely implementation is lightweight: each build request carries its path score as a **priority**, and priority-ordered consumption under a concurrency cap makes "top-N by score" emerge naturally; an explicit admit-top-N component is the fallback if preemption or fairness needs more than ordering. Because it is the one place that sees the whole queue, the prioritizer is the queue-wide **enforcer**: selection expresses *desire* per batch, prioritization reconciles that desire against *supply*. + +## Path state: status vs action + +Each path carries two distinct things with two distinct owners: + +- **Status** — the *observed* lifecycle state of a path. Written **only by the controller**, into the speculation tree store, and read by the selector as input. +- **Action** — what the selector wants done next. The **selector's only output**: recomputed on every run, never persisted. + +The selector reads status and emits actions; the controller enacts an action, which produces the next status it writes. The selector never writes status; the controller never asks the selector to persist anything. + +The controller persists the **entire** tree — every enumerated path together with its current status and its latest score — not just the actionable ones. So each `Select` run reads the up-to-date status *and* freshly recomputed score of *all* paths (including ones already `selected` or `building`) and can return `Cancel` for a path it earlier asked to build. Score, like status, is controller-persisted dynamic state; the difference is only in who computes the value — the controller derives status directly from builds and dependency states, and calls the scorer for the score. + +``` + Status (controller-written, persisted) + + candidate ──▶ selected ──▶ building ──▶ passed + │ │ │ └─▶ failed + │ └────────────┴────────▶ cancelled + └─────────────────────────────────▶ cancelled +``` + +| Transition | Trigger (all written by the controller) | +|---|---| +| → `candidate` | enumerator produced the path; controller persists it | +| `candidate → selected` | selector returned `Build`; controller **sent** the path to the build controller (no `BuildID` yet) | +| `selected → building` | a **build signal** confirms the build is running; controller records `BuildID` | +| `building → passed` / `failed` | build result arrives via `buildsignal` | +| `selected → cancelled` | selector returned `Cancel` before any build started, or the build never started | +| `building → cancelled` | the build was cancelled | +| `candidate → cancelled` | the path's base broke before it was ever sent | + +Actions the selector can emit: `Build` (send this path to the build controller) or `Cancel` (drop it; cancel any build in flight). The selector leaves a path as-is by simply omitting it from its decisions. Note there is no merge/finalize action: **merging is the controller's job, not the selector's.** A path becomes mergeable when its build `passed` *and* its base matches what actually landed — that is deterministic, not a policy choice, so the controller finalizes it on its own (the existing `tryFinalize` → `merge` reconciliation). The selector only decides where to spend build resources; the prioritizer decides which of those actually run. + +Why `selected` is distinct from `building`: the selector only *sends* a path to the build controller. The build is then subject to prioritization and resources and may not start immediately. So `Build` moves the path to `selected`; speculate does not assert `building` itself — it learns a build actually started only from a build signal, and only then records `building` and the `BuildID`. Between the two, the path is sent but unconfirmed, and the selector treats `selected` as "already sent — don't re-send, but still cancellable." "Base invalid" is not a status — it is one of the *triggers* that sends a path to `cancelled`. + +## Dependency limit + +A batch can sit deep in a chain of dependencies. The **dependency limit** bounds how many **active** (in-flight, non-terminal) dependencies a batch may speculate over. It is an *eligibility gate*, not a trimming step: a batch becomes eligible to speculate only when its count of active dependencies is at or below the limit; otherwise it waits. Nothing is dropped — as dependencies land, they leave the active set, the count shrinks, and the batch becomes eligible. + +The limit applies even to the fully-stacked happy path: on a very long chain, applying every predecessor serially is itself slow, so the limit caps how much of the chain is speculated at once rather than always speculating it in full. + +### Gating, by example + +Consider a chain `q1 ← q2 ← q3 ← q4`, ordered by arrival, where each batch depends (transitively) on all earlier ones, with a dependency limit of 1: + +``` + batch active deps eligible at limit=1? path speculated + q1 — yes (0 active) [] → q1 + q2 q1 yes (1 active) [q1] → q2 + q3 q1, q2 no (2 active) → waits — + q4 q1, q2, q3 no (3 active) → waits — +``` + +When `q1` lands, it leaves the active set. `q3`'s active dependencies shrink to `{q2}` — one active — so `q3` becomes eligible and speculates `[q2] → q3` (with `q1` now part of the real branch tip, not the speculative base). The gate is re-evaluated on every respeculate, so a landing predecessor naturally admits the batches waiting behind it. + +### Owned by the controller, decided by a policy + +Applying the gate is the **controller's** job: it reconciles each dependency's state, counts the active ones, compares against the limit, and — when eligible — hands the active dependencies to the enumerator in order (the DAG order is reconstructed here; the enumerator receives a ready-ordered list). This keeps the connected-set walk and state reconciliation out of the dumb enumerator. + +The limit's *value* is decided by a signal-driven policy, not a fixed constant. It is meant to scale up and down with build resources (and other signals), so a period of CI pressure can shrink how deep the queue speculates. Because the value is dynamic, a change to the limit — not only a landing dependency or a DAG change — can newly admit a waiting batch, so it is one of the triggers the controller re-evaluates on. + +## Paths and trees, by example + +Consider queue `q` with batches `q1`, `q2`, `q3`, where both `q2` and `q3` depend on `q1` (and not on each other): + +``` + Dependency DAG Speculation tree for q2 (depends on q1) + + q1 Base Head Score* + / \ [q1] q2 0.27 ← bet: q1 passes, build q2 on it + q2 q3 [] q2 0.90 ← fallback: build q2 alone + + *Scores are illustrative and dynamic; the exact + formula is a scorer concern. +``` + +`q1`, having no predecessors, has a single path `[]→q1`. Each dependent batch has two: build on the assumed-good predecessor, or build alone. + +### A bet, and its recovery + +The selector runs an optimistic top-1 policy: it returns `Build` for `[q1]→q2` (betting `q1` passes). The controller sends that path to the build controller, so it moves to `selected`; once a build signal confirms the build started, the controller marks it `building`: + +``` + q2 tree (q1 still building) q1 FAILS → q2 tree (after controller reconciles) + + Base Head Status Score Base Head Status Score + [q1] q2 building 0.27 [q1] q2 cancelled 0.27 ← base broke + [] q2 candidate 0.90 [] q2 candidate 0.90 ← selector now returns Build +``` + +- **Bet holds** — `q1` passes and merges: the build of `[q1]→q2` ran against exactly the tree `q2` will land on, so `q2` is mergeable and the controller finalizes it (publishes to `merge`) — no selector action involved. `q1` and `q2` were validated in parallel — the latency win. The now-redundant `[]→q2` fallback is dropped: the selector returns `Cancel` for it and the controller cancels any build still in flight. +- **Bet fails** — `q1` fails: the `[q1]→q2` path's base is broken, so the controller stamps it `cancelled`. Re-running the selector over the updated tree returns `Build` for the surviving `[]→q2` candidate; `q2` still lands, just without the head start. + +Re-speculation needs no special undo path: the controller refreshes statuses, and the selector simply re-runs over the updated tree. + +## Interfaces + +The seams are vendor-agnostic extensions, each in its own package; the exact Go signatures live in the source. All are per-queue: the system hands a `Factory` the queue identity, and the factory builds the seam for that queue — so the queue is bound at construction and never re-passed to a method. + +**Decision seams:** + +- **Enumerator** (`extension/speculation/enumerator`) — given a batch ID and its ordered active dependencies, returns the batch's speculation tree *structure*: the candidate paths, each a Base/Head split. Pure and deterministic; sets no score and no status. +- **Scorer** (`extension/speculation/scorer`) — given the speculation tree and the current dependency batches, returns each path's predicted-success score. Called by the controller on every respeculate (during reconciliation) so scores track the live state — dependencies landing, dependency builds passing, siblings failing. Owns the score formula; combines the base batches' `Batch.Score` and their resolved/unresolved state (and optionally other signals). +- **Selector** (`extension/speculation/selector`) — given a speculation tree (with each path's controller-stamped status and freshly recomputed score), returns a per-path action (`Build` or `Cancel`) for the paths it chooses to act on. It reads status and score and emits actions only; paths it leaves alone are omitted. It is constructed with its **selection limit** and calls it to cap how many paths it builds in parallel. +- **Prioritizer** (`extension/speculation/prioritizer`) — given the queue's pending build candidates, returns the subset admitted to run, ranked by score plus any fairness policy. It is constructed with its **prioritization limit** and applies it itself. Operates queue-wide, across all of the queue's in-flight batches. + +**Limit policies** — each a signal-driven "how much" seam returning a bound from build-resource and other signals: + +- **Dependency limit** — the max active dependencies a batch may speculate over. Consulted and applied by the controller as the eligibility gate. +- **Selection limit** — the max paths a batch may build in parallel. Injected into and called by the selector. +- **Prioritization limit** — the max concurrent builds for the queue. Injected into and called by the prioritizer. + +The scorer, prioritizer, and the three limit policies are design-level here — the enumerator and selector exist in source (with the enumerator's scoring responsibility slated to move into the scorer); the rest are not yet implemented. + +## Design decisions + +**Two layers: decisions and limits.** Decision seams (enumerator, scorer, selector, prioritizer) — enumeration and scoring *describe* the tree, selection and prioritization *act* on it; limit policies (dependency, selection, prioritization) decide *how much*. *Why:* the "which" is qualitative policy that is stable, while the "how much" must scale with volatile build resources; separating them lets the resource-aware knobs move independently of the decision logic, and lets each be tested in isolation. *Rejected:* baking counts into each decision seam as constants — it hard-codes a policy that needs to breathe with CI capacity. + +**Limits are signal-driven, and resources are the primary but not the only signal.** A limit is whatever its policy computes — from available capacity, and optionally historical pass rates, cost, time, or experiment flags. *Why:* speculation aggression should rise and fall with the build system, and the design should not foreclose other inputs. *Rejected:* a single fixed constant, or a static per-queue config value — neither can react to load. + +**Limits are injected into the seam that uses them and called there — never a method parameter.** The selector holds its selection limit; the prioritizer holds its prioritization limit. *Why:* it follows the repo's extension-contract pattern (dependencies injected at the `Factory`), keeps the interfaces limit-free and stable, and keeps "which" and "how much" swappable independently. *Exception:* the dependency limit gates eligibility *before* enumeration and needs active-dependency reconciliation, which is controller orchestration — so the controller holds it, and the enumerator stays pure. + +**Status is the controller's; Action is the selector's.** Status is observed reality, written only by the controller into the store; the selector reads it and returns only actions, which the controller enacts and turns into the next status. *Why:* one writer for persisted state keeps the lifecycle coherent and the selector a pure function of its input, exercisable against a literal tree with no store, builds, or scorer. *Rejected:* letting the selector mutate status — it couples policy to storage and gives status two writers. + +**The dependency limit is an eligibility gate, not a base trim.** A batch waits until its active-dependency count fits the limit; landed dependencies leave the active set and admit it. *Why:* a batch's base must include every still-in-flight predecessor it conflicts with — you cannot skip one — so the honest control is *when* to speculate, not *which* ancestors to drop. *Rejected:* trimming the oldest ancestors and speculating on a nearest-N subset — it would build a base that omits a live conflicting predecessor. + +**Prioritization is cross-batch, at the build stage, and is the queue-wide enforcer.** Selection is per batch and blind to other batches; only the build stage sees all of the queue's selected paths and the build budget. *Why:* rationing a shared budget requires a single vantage point; putting it in per-batch `speculate` would let batches collectively over-commit. *Rejected:* making the selector resource-aware across batches — it cannot see them. + +**Scoring is separate from enumeration, and dynamic.** Enumeration produces stable *structure*; the scorer produces a *prediction* that is recomputed on every respeculate. *Why:* a path's odds change as dependencies land, dependency builds pass, and siblings fail — a score frozen at enumeration would misrank the selector's and prioritizer's choices; separating them lets the score refresh in place each pass without re-structuring, and lets the scorer see live state (build outcomes, wait time) that the pure structural enumerator should not. *Rejected:* folding scoring into enumeration (its earlier form) — it froze the prediction and would force a full re-enumerate plus status-merge just to refresh a number. + +**Scoring takes dependency `Batch` values; enumeration needs only ordered IDs.** The scorer combines the base batches' `Batch.Score` and their current state, so it takes the dependency `Batch` values. Enumeration only needs the ordered dependency identities to build the Base/Head structure. In both, the head is passed as an ID: its own score is constant across all of its paths, and passing an ID avoids handing a seam the head's full dependency list, which would tempt it to bypass the controller's gate. + +**A path is a Base/Head split, not a flat list.** Base is the assumed-good prefix; Head is the single batch under verification. *Why:* it maps one-to-one to the build stage (apply Base, validate Head), lets a build backend cache a shared base prefix, and lets failure be attributed to base vs head. *Rejected:* a flat ordered list — the consumer would have to re-derive which portion is assumed-good. + +## Open questions + +Named here for context; not settled by this design: + +- **Rescoring while queued.** The scorer already recomputes path scores as dependencies land, fail, or have their builds pass. An open extension: should it also decay or boost a batch's standing the longer it waits without a build — feeding wait time in as another scorer signal, or rescoring the per-batch `Batch.Score` upstream? Open which layer owns it. +- **Cross-queue capacity.** Prioritization here is queue-wide. If build capacity is ever shared *across* queues, a further arbitration layer is needed — with its own questions of fairness and starvation across queues, and score comparability between them. +- **Concrete implementations.** The limit policies (single fixed value, resource-tracking, adaptive), enumerators (single-path, exhaustive, top-K by structure), scorers (independent-product of base scores, discounted-by-depth, evidence-weighted), selectors (top-K, optimistic-first, shadow A/B), and prioritizers (priority-ordered consumption vs explicit admit-top-N, with or without preemption). +- **Signal sourcing.** Which signals each limit policy weighs, and how they are surfaced (a capacity feed, historical metrics, config) to the wiring layer that constructs the policies. diff --git a/submitqueue/entity/build.go b/submitqueue/entity/build.go index 86039f43..87400774 100644 --- a/submitqueue/entity/build.go +++ b/submitqueue/entity/build.go @@ -53,12 +53,6 @@ func (s BuildStatus) IsTerminal() bool { return s == BuildStatusSucceeded || s == BuildStatusFailed || s == BuildStatusCancelled } -// SpeculationPathInfo represents the base and head commits of a speculation path used in a build. -type SpeculationPathInfo struct { - // Base is a list of batchIDs(in order) that form the base of this speculation path. - Base []string -} - // Build represents a build scheduled for a batch along a specific speculation path. // All fields except the Status are immutable after creation. type Build struct { @@ -69,8 +63,9 @@ type Build struct { BatchID string // SpeculationPath is the speculation path that represents this build. For // a given batch this path is crafted from the graph that is generated from the - // dependencies of this batch. - SpeculationPath SpeculationPathInfo + // dependencies of this batch. Its Head is the batch being verified (equal to + // BatchID) and its Base is the assumed-good prefix of predecessor batches. + SpeculationPath SpeculationPath // Score represents the build prediction score for this speculation path. Score float32 // Status represents the state of the build lifecycle this build is in. diff --git a/submitqueue/entity/build_test.go b/submitqueue/entity/build_test.go index 97de0193..ce33095e 100644 --- a/submitqueue/entity/build_test.go +++ b/submitqueue/entity/build_test.go @@ -70,8 +70,9 @@ func TestBuild_ToBytes(t *testing.T) { build := Build{ ID: "build-1", BatchID: "batch-1", - SpeculationPath: SpeculationPathInfo{ + SpeculationPath: SpeculationPath{ Base: []string{"batch-0", "batch-prev"}, + Head: "batch-1", }, Score: 0.85, Status: BuildStatusAccepted, @@ -92,8 +93,9 @@ func TestBuildFromBytes(t *testing.T) { original := Build{ ID: "build-42", BatchID: "batch-7", - SpeculationPath: SpeculationPathInfo{ + SpeculationPath: SpeculationPath{ Base: []string{"batch-5", "batch-6"}, + Head: "batch-7", }, Score: 0.92, Status: BuildStatusAccepted, @@ -145,8 +147,9 @@ func TestBuild_SerializationRoundTrip(t *testing.T) { build: Build{ ID: "build-100", BatchID: "batch-50", - SpeculationPath: SpeculationPathInfo{ + SpeculationPath: SpeculationPath{ Base: []string{"batch-48", "batch-49"}, + Head: "batch-50", }, Score: 0.75, Status: BuildStatusAccepted, @@ -166,8 +169,9 @@ func TestBuild_SerializationRoundTrip(t *testing.T) { build: Build{ ID: "build-300", BatchID: "batch-70", - SpeculationPath: SpeculationPathInfo{ + SpeculationPath: SpeculationPath{ Base: []string{"batch-65"}, + Head: "batch-70", }, Score: 0, Status: BuildStatusFailed, diff --git a/submitqueue/entity/speculation_tree.go b/submitqueue/entity/speculation_tree.go index d6bb1750..74baf61e 100644 --- a/submitqueue/entity/speculation_tree.go +++ b/submitqueue/entity/speculation_tree.go @@ -14,38 +14,112 @@ package entity -// SpeculationPathAction defines the possible actions for a speculation path. +// SpeculationPath is a single speculation path: an assumed-good prefix of +// predecessor batches (Base) on top of which the batch under verification +// (Head) is built and validated. +// +// This is the unit the build stage consumes: Base maps to the build runner's +// base changes (an assumed-good prefix to apply) and Head maps to the changes +// being validated. +type SpeculationPath struct { + // Base is the ordered list of predecessor batch IDs assumed to have passed. + // Empty means the path builds the head batch directly on the target branch. + Base []string + // Head is the batch ID being verified by this path. + Head string +} + +// SpeculationPathStatus is the observed lifecycle state of a speculation path. +// It is written only by the orchestrator's speculate controller (into the +// speculation tree store) and read by the path selector as input; enumerators +// and selectors never write it. +type SpeculationPathStatus string + +const ( + // SpeculationPathStatusUnknown is the unreachable zero value, set by default + // on init. A persisted path always carries a real status (candidate onward), + // so this should never be seen in the store. + SpeculationPathStatusUnknown SpeculationPathStatus = "" + // SpeculationPathStatusCandidate is a freshly enumerated path the controller + // has persisted but not yet sent to build. + SpeculationPathStatusCandidate SpeculationPathStatus = "candidate" + // SpeculationPathStatusSelected is a path the controller has sent to the build + // controller (in response to a selector Build action) but for which no build + // signal has arrived yet — the build system may not have started it + // (resource-gated), so whether it is actually building is not yet known. + SpeculationPathStatusSelected SpeculationPathStatus = "selected" + // SpeculationPathStatusBuilding is a path a build signal has confirmed is in + // flight; its BuildID is known. + SpeculationPathStatusBuilding SpeculationPathStatus = "building" + // SpeculationPathStatusPassed is a path whose build succeeded. + SpeculationPathStatusPassed SpeculationPathStatus = "passed" + // SpeculationPathStatusFailed is a path whose build failed. + SpeculationPathStatusFailed SpeculationPathStatus = "failed" + // SpeculationPathStatusCancelled is a path that is no longer pursued — its + // base was invalidated, its build was cancelled, or the selector dropped it. + SpeculationPathStatusCancelled SpeculationPathStatus = "cancelled" +) + +// SpeculationPathAction is the action a path selector asks the controller to +// take for a path. It is the selector's only output: ephemeral (recomputed +// every time the selector runs) and never persisted. The controller enacts it +// and records the resulting SpeculationPathStatus. type SpeculationPathAction string const ( - // SpeculationPathActionUnknown is the default zero value for SpeculationPathAction. + // SpeculationPathActionUnknown is the unreachable zero value. A real decision + // always carries Build or Cancel; the selector expresses "leave this path + // as-is" by omitting it from its decisions, not by returning this. SpeculationPathActionUnknown SpeculationPathAction = "" - // TODO: Add comprehensive list of actions + // SpeculationPathActionBuild asks the controller to send this path to the + // build controller (which triggers a build subject to resources). The path moves + // to Selected on send, then Building once a build signal confirms it. + SpeculationPathActionBuild SpeculationPathAction = "build" + // SpeculationPathActionCancel asks the controller to drop this path and + // cancel any build in flight for it. + SpeculationPathActionCancel SpeculationPathAction = "cancel" ) -// SpeculationInfo represents metadata about a single speculation path, including the path through the dependency graph, its current state, and the predicted build score. -type SpeculationInfo struct { - // Path represents the speculation path; which is an ordered list of batches. - Path []string - // Action is a state that this path is in. - Action SpeculationPathAction - // Score is score for this speculation path. +// SpeculationPathInfo is the per-path entry in a speculation tree: a path, the +// enumerator's predicted score for it, its controller-owned status, and a link +// to the build dispatched for it (if any). +type SpeculationPathInfo struct { + // Path is the Base/Head split this entry covers. + Path SpeculationPath + // Score is the enumerator's predicted success score for this path. Score float32 + // Status is the observed lifecycle state of the path. Written only by the + // controller; read by the selector. + Status SpeculationPathStatus + // BuildID links this path to its build. Empty until a build signal confirms + // the build and the controller records it (Selected -> Building); the + // controller never knows the ID at send time. + BuildID string +} + +// SpeculationPathDecision is a path selector's decision for a single path: the +// action the controller should take for it. It is the selector's output and is +// not persisted. +type SpeculationPathDecision struct { + // Path identifies the speculation path the action applies to. + Path SpeculationPath + // Action is what the controller should do for the path. + Action SpeculationPathAction } -// SpeculationTree represents the set of speculation paths constructed for a batch based on its dependency graph. +// SpeculationTree is the set of candidate speculation paths for a batch, built +// from its dependency graph. type SpeculationTree struct { // BatchID is the batch for which this speculation tree is constructed. BatchID string - // Speculations is a list of speculation paths for this batch based on a graph of its - // dependents. + // Paths is the candidate speculation paths for this batch, derived from a + // graph of its dependencies. // // For e.g - Consider batches - queueA/batch/1, queueA/batch/2, queueA/batch/3 // such that - queueA/batch/2 and queueA/batch/3 depend on queueA/batch/1 // - // Speculations for queueA/batch/1 - [{Path: []string{"queueA/batch/1"}, State: "scheduled", Score: 0.1}] - // Speculations for queueA/batch/2 - [{Path: []string{"queueA/batch/2"}, State: "scheduled", Score: 0.9}, {Path: []string{"queueA/batch/1", "queueA/batch/2"}, State: "scheduled", Score: 0.3}] - // Speculations for queueA/batch/3 - [{Path: []string{"queueA/batch/3"}, State: "scheduled", Score: 0.9}, {Path: []string{"queueA/batch/1", "queueA/batch/3"}, State: "scheduled", Score: 0.3}] + // Paths for queueA/batch/2 - [{Path: {Base: [], Head: "queueA/batch/2"}, Score: 0.9, Status: "candidate"}, {Path: {Base: ["queueA/batch/1"], Head: "queueA/batch/2"}, Score: 0.3, Status: "candidate"}] + // Paths for queueA/batch/3 - [{Path: {Base: [], Head: "queueA/batch/3"}, Score: 0.9, Status: "candidate"}, {Path: {Base: ["queueA/batch/1"], Head: "queueA/batch/3"}, Score: 0.3, Status: "candidate"}] // - Speculations []SpeculationInfo + Paths []SpeculationPathInfo } diff --git a/submitqueue/extension/speculation/enumerator/BUILD.bazel b/submitqueue/extension/speculation/enumerator/BUILD.bazel new file mode 100644 index 00000000..00f293f2 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "enumerator", + srcs = ["enumerator.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator", + visibility = ["//visibility:public"], + deps = ["//submitqueue/entity"], +) diff --git a/submitqueue/extension/speculation/enumerator/README.md b/submitqueue/extension/speculation/enumerator/README.md new file mode 100644 index 00000000..a435cfad --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/README.md @@ -0,0 +1,19 @@ +# Speculation Tree Enumerator + +Vendor-agnostic interface for enumerating the **speculation tree** of a batch — the set of candidate speculation paths the orchestrator may build, each scored with its predicted probability of success. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how enumeration fits into the orchestrator pipeline. + +## Enumerator + +An enumerator is deliberately **dumb**: *given a batch and its dependency batches, it mechanically lists the candidate paths and scores them.* It does **not** decide which paths to build — that is the [selector](../selector)'s job — it does **not** set path status, and it does **not** decide how far back to speculate. Speculation depth is the controller's responsibility: the controller trims the dependency list before calling the enumerator, which then enumerates over exactly the list it is handed. + +Each candidate is a path: an assumed-good prefix of predecessor batches (the base) on top of which the batch under verification (the head) is built. The base maps directly onto the build stage's base changes and the head onto the changes being validated. + +Enumeration is **pure and deterministic**: the same batch and dependency list always produce the same tree. This lets the controller regenerate a tree whenever the dependency graph changes without tracking incremental state in the enumerator. Keeping enumeration tractable for a very wide dependency list is the enumerator's only real concern. + +Scores ride in on the inputs. Each dependency is passed as a full `entity.Batch`, which already carries its per-batch success probability (`Batch.Score`) from the score stage; the enumerator combines the scores of a path's base batches into the path's score. No separate scoring backend or injected probability source is needed, and tests just set `.Score` on literal batches. The head is passed as an ID — its score is constant across all of its own paths. + +## Factory + +`Factory.For(Config) (Enumerator, error)` returns the enumerator for a queue, following the repo's extension contract (`conflict.Analyzer` is the reference shape). `Config` carries only the queue identity (`QueueName`); the system hands the factory nothing else. Everything an implementation needs — including behavioral knobs like speculation depth — is injected at construction by the integrator in the wiring layer, which resolves per-queue settings through `queueconfig`. `Enumerate` itself stays config-free. diff --git a/submitqueue/extension/speculation/enumerator/enumerator.go b/submitqueue/extension/speculation/enumerator/enumerator.go new file mode 100644 index 00000000..beb35587 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/enumerator.go @@ -0,0 +1,65 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package enumerator + +//go:generate mockgen -source=enumerator.go -destination=mock/enumerator_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" +) + +// Enumerator builds the speculation tree for a batch: the set of candidate +// speculation paths to consider, each scored with its predicted success +// probability. +// +// Enumeration answers "what futures are possible" for a batch. It is +// deliberately dumb: it mechanically lists candidate paths from the dependency +// batches it is handed and attaches a Score to each. It does not decide which +// paths to build — that is the selector's job (see +// extension/speculation/selector) — and it does not decide how far back to +// speculate: the controller trims the dependency list by speculation depth +// before calling Enumerate. +type Enumerator interface { + // Enumerate returns the speculation tree for the batch identified by batchID, + // given its dependency batches in arrival order. Each returned path carries a + // Base/Head split and a predicted success Score; the returned paths leave + // Status unset (the controller stamps it on persist). + // + // Path scores are derived from the dependency batches' Score field (the + // per-batch success probability set by the score stage), so no separate + // scoring backend is needed. The combination formula is the implementation's + // concern. + // + // Enumeration is pure and deterministic: the same (batchID, deps) always + // yields the same tree, so callers may regenerate safely. + Enumerate(ctx context.Context, batchID string, deps []entity.Batch) (entity.SpeculationTree, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything an implementation needs (including behavioral +// knobs such as speculation depth) is injected at construction by the integrator. +type Config struct { + // QueueName identifies the queue this Enumerator serves. + QueueName string +} + +// Factory builds the Enumerator for a queue. Implementations are provided by +// integrators (and tests) and inject whatever they need at construction. +type Factory interface { + // For returns the Enumerator for the given queue. + For(cfg Config) (Enumerator, error) +} diff --git a/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel b/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel new file mode 100644 index 00000000..a44e299a --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "mock", + srcs = ["enumerator_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/speculation/enumerator", + "@org_uber_go_mock//gomock", + ], +) diff --git a/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go b/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go new file mode 100644 index 00000000..6ad1cc75 --- /dev/null +++ b/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: enumerator.go +// +// Generated by this command: +// +// mockgen -source=enumerator.go -destination=mock/enumerator_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/submitqueue/entity" + enumerator "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator" + gomock "go.uber.org/mock/gomock" +) + +// MockEnumerator is a mock of Enumerator interface. +type MockEnumerator struct { + ctrl *gomock.Controller + recorder *MockEnumeratorMockRecorder + isgomock struct{} +} + +// MockEnumeratorMockRecorder is the mock recorder for MockEnumerator. +type MockEnumeratorMockRecorder struct { + mock *MockEnumerator +} + +// NewMockEnumerator creates a new mock instance. +func NewMockEnumerator(ctrl *gomock.Controller) *MockEnumerator { + mock := &MockEnumerator{ctrl: ctrl} + mock.recorder = &MockEnumeratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockEnumerator) EXPECT() *MockEnumeratorMockRecorder { + return m.recorder +} + +// Enumerate mocks base method. +func (m *MockEnumerator) Enumerate(ctx context.Context, batchID string, deps []entity.Batch) (entity.SpeculationTree, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Enumerate", ctx, batchID, deps) + ret0, _ := ret[0].(entity.SpeculationTree) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Enumerate indicates an expected call of Enumerate. +func (mr *MockEnumeratorMockRecorder) Enumerate(ctx, batchID, deps any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enumerate", reflect.TypeOf((*MockEnumerator)(nil).Enumerate), ctx, batchID, deps) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg enumerator.Config) (enumerator.Enumerator, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(enumerator.Enumerator) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/speculation/selector/BUILD.bazel b/submitqueue/extension/speculation/selector/BUILD.bazel new file mode 100644 index 00000000..90b25fec --- /dev/null +++ b/submitqueue/extension/speculation/selector/BUILD.bazel @@ -0,0 +1,9 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "selector", + srcs = ["selector.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector", + visibility = ["//visibility:public"], + deps = ["//submitqueue/entity"], +) diff --git a/submitqueue/extension/speculation/selector/README.md b/submitqueue/extension/speculation/selector/README.md new file mode 100644 index 00000000..1b304326 --- /dev/null +++ b/submitqueue/extension/speculation/selector/README.md @@ -0,0 +1,19 @@ +# Speculation Path Selector + +Vendor-agnostic interface for deciding what the orchestrator should do with each path in a batch's enumerated speculation tree. + +See the [Speculation RFC](../../../../doc/rfc/submitqueue/speculation.md) for the end-to-end design and how selection fits into the orchestrator pipeline. + +## Selector + +A selector is the **policy** — the part that decides how aggressively to spend build resources. *Given the candidate paths in a tree and their current status, what should we do with each, right now?* It consumes the tree produced by an [enumerator](../enumerator) and returns an **action** per path — `Build` or `Cancel`. Strategies span a spectrum: build only the single optimistic path (cheapest — bet on the happy case), build every candidate (maximum parallelism, maximum build cost), or a top-K / budget-bounded subset in between. + +The selector decides only where to spend build resources. It does **not** decide merging: a path becomes mergeable when its build passed and its base matches what actually landed, which is deterministic, not a policy choice — so the controller finalizes it on its own. + +The selector's only output is actions; it **never** writes status. The controller owns every status write into the store — it reconciles each path's status (candidate, building, passed, failed, cancelled) from the latest builds and dependency states, then feeds the up-to-date tree back in. So the tree is the selector's **complete input**: it never reads storage, builds, or scores directly. This keeps it a pure, deterministic policy that is trivial to test against a literal tree. + +Because it is re-run on every build signal, a selector can start narrow — build the optimistic path first — and widen later, committing more paths only once earlier bets resolve. Returning no action for a path leaves it as-is. Policy parameters — a top-K cap, a build budget, an experiment toggle — are configured when the selector is constructed rather than passed through this contract. + +## Factory + +`Factory.For(Config) (Selector, error)` returns the selector for a queue, following the repo's extension contract (`conflict.Analyzer` is the reference shape). `Config` carries only the queue identity (`QueueName`); the system hands the factory nothing else. Policy knobs — a top-K cap, a build budget, an experiment toggle — are injected at construction by the integrator in the wiring layer, which resolves per-queue settings through `queueconfig`. `Select` itself stays config-free. diff --git a/submitqueue/extension/speculation/selector/mock/BUILD.bazel b/submitqueue/extension/speculation/selector/mock/BUILD.bazel new file mode 100644 index 00000000..4b2a5546 --- /dev/null +++ b/submitqueue/extension/speculation/selector/mock/BUILD.bazel @@ -0,0 +1,13 @@ +load("@rules_go//go:def.bzl", "go_library") + +go_library( + name = "mock", + srcs = ["selector_mock.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector/mock", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity", + "//submitqueue/extension/speculation/selector", + "@org_uber_go_mock//gomock", + ], +) diff --git a/submitqueue/extension/speculation/selector/mock/selector_mock.go b/submitqueue/extension/speculation/selector/mock/selector_mock.go new file mode 100644 index 00000000..755c1dbc --- /dev/null +++ b/submitqueue/extension/speculation/selector/mock/selector_mock.go @@ -0,0 +1,97 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: selector.go +// +// Generated by this command: +// +// mockgen -source=selector.go -destination=mock/selector_mock.go -package=mock +// + +// Package mock is a generated GoMock package. +package mock + +import ( + context "context" + reflect "reflect" + + entity "github.com/uber/submitqueue/submitqueue/entity" + selector "github.com/uber/submitqueue/submitqueue/extension/speculation/selector" + gomock "go.uber.org/mock/gomock" +) + +// MockSelector is a mock of Selector interface. +type MockSelector struct { + ctrl *gomock.Controller + recorder *MockSelectorMockRecorder + isgomock struct{} +} + +// MockSelectorMockRecorder is the mock recorder for MockSelector. +type MockSelectorMockRecorder struct { + mock *MockSelector +} + +// NewMockSelector creates a new mock instance. +func NewMockSelector(ctrl *gomock.Controller) *MockSelector { + mock := &MockSelector{ctrl: ctrl} + mock.recorder = &MockSelectorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSelector) EXPECT() *MockSelectorMockRecorder { + return m.recorder +} + +// Select mocks base method. +func (m *MockSelector) Select(ctx context.Context, tree entity.SpeculationTree) ([]entity.SpeculationPathDecision, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Select", ctx, tree) + ret0, _ := ret[0].([]entity.SpeculationPathDecision) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Select indicates an expected call of Select. +func (mr *MockSelectorMockRecorder) Select(ctx, tree any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Select", reflect.TypeOf((*MockSelector)(nil).Select), ctx, tree) +} + +// MockFactory is a mock of Factory interface. +type MockFactory struct { + ctrl *gomock.Controller + recorder *MockFactoryMockRecorder + isgomock struct{} +} + +// MockFactoryMockRecorder is the mock recorder for MockFactory. +type MockFactoryMockRecorder struct { + mock *MockFactory +} + +// NewMockFactory creates a new mock instance. +func NewMockFactory(ctrl *gomock.Controller) *MockFactory { + mock := &MockFactory{ctrl: ctrl} + mock.recorder = &MockFactoryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockFactory) EXPECT() *MockFactoryMockRecorder { + return m.recorder +} + +// For mocks base method. +func (m *MockFactory) For(cfg selector.Config) (selector.Selector, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "For", cfg) + ret0, _ := ret[0].(selector.Selector) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// For indicates an expected call of For. +func (mr *MockFactoryMockRecorder) For(cfg any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "For", reflect.TypeOf((*MockFactory)(nil).For), cfg) +} diff --git a/submitqueue/extension/speculation/selector/selector.go b/submitqueue/extension/speculation/selector/selector.go new file mode 100644 index 00000000..6190268b --- /dev/null +++ b/submitqueue/extension/speculation/selector/selector.go @@ -0,0 +1,60 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package selector + +//go:generate mockgen -source=selector.go -destination=mock/selector_mock.go -package=mock + +import ( + "context" + + "github.com/uber/submitqueue/submitqueue/entity" +) + +// Selector decides what the controller should do with each path in a batch's +// speculation tree. +// +// Selection is the policy: it answers "which futures do we spend build resources +// on, and how many, right now". It reads the tree — including each path's controller-stamped +// Status (Candidate / Building / Passed / Failed / Cancelled) and Score — and +// returns an action per path it wants to act on. +// +// The selector's only output is actions; it never writes Status. The controller +// owns every Status write (into the store) and feeds the up-to-date tree back in +// on the next call, so the tree is the selector's complete input. This keeps the +// selector a pure, deterministic policy. Policy knobs such as a top-K limit or +// budget belong to the implementation's construction, not this method. +type Selector interface { + // Select returns the actions to take for the given tree. Returning multiple + // Build decisions dispatches several speculative builds in parallel; an empty + // result means nothing should be done right now. Paths the selector has no + // opinion on are simply omitted (leave-as-is). + Select(ctx context.Context, tree entity.SpeculationTree) ([]entity.SpeculationPathDecision, error) +} + +// Config carries the per-queue identity handed to a Factory. The system knows +// only the queue name; everything an implementation needs (including policy +// knobs such as a top-K cap or build budget) is injected at construction by the +// integrator. +type Config struct { + // QueueName identifies the queue this Selector serves. + QueueName string +} + +// Factory builds the Selector for a queue. Implementations are provided by +// integrators (and tests) and inject whatever they need at construction. +type Factory interface { + // For returns the Selector for the given queue. + For(cfg Config) (Selector, error) +} diff --git a/submitqueue/extension/storage/mock/speculation_tree_store_mock.go b/submitqueue/extension/storage/mock/speculation_tree_store_mock.go index e7fba22b..c5055fd6 100644 --- a/submitqueue/extension/storage/mock/speculation_tree_store_mock.go +++ b/submitqueue/extension/storage/mock/speculation_tree_store_mock.go @@ -70,16 +70,16 @@ func (mr *MockSpeculationTreeStoreMockRecorder) Get(ctx, batchID any) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSpeculationTreeStore)(nil).Get), ctx, batchID) } -// UpdateSpeculations mocks base method. -func (m *MockSpeculationTreeStore) UpdateSpeculations(ctx context.Context, batchID string, speculations []entity.SpeculationInfo) error { +// Update mocks base method. +func (m *MockSpeculationTreeStore) Update(ctx context.Context, speculationTree entity.SpeculationTree) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateSpeculations", ctx, batchID, speculations) + ret := m.ctrl.Call(m, "Update", ctx, speculationTree) ret0, _ := ret[0].(error) return ret0 } -// UpdateSpeculations indicates an expected call of UpdateSpeculations. -func (mr *MockSpeculationTreeStoreMockRecorder) UpdateSpeculations(ctx, batchID, speculations any) *gomock.Call { +// Update indicates an expected call of Update. +func (mr *MockSpeculationTreeStoreMockRecorder) Update(ctx, speculationTree any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSpeculations", reflect.TypeOf((*MockSpeculationTreeStore)(nil).UpdateSpeculations), ctx, batchID, speculations) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSpeculationTreeStore)(nil).Update), ctx, speculationTree) } diff --git a/submitqueue/extension/storage/mysql/speculation_tree_store.go b/submitqueue/extension/storage/mysql/speculation_tree_store.go index 2366dadc..96922fe2 100644 --- a/submitqueue/extension/storage/mysql/speculation_tree_store.go +++ b/submitqueue/extension/storage/mysql/speculation_tree_store.go @@ -59,7 +59,7 @@ func (s *speculationTreeStore) Get(ctx context.Context, batchID string) (ret ent return entity.SpeculationTree{}, fmt.Errorf("failed to get speculation tree entity batchID=%s from the database: %w", batchID, err) } - if err := json.Unmarshal(speculationsJSON, &st.Speculations); err != nil { + if err := json.Unmarshal(speculationsJSON, &st.Paths); err != nil { return entity.SpeculationTree{}, fmt.Errorf("failed to unmarshal speculations for speculation tree entity batchID=%s from the database: %w", batchID, err) } @@ -71,7 +71,7 @@ func (s *speculationTreeStore) Create(ctx context.Context, speculationTree entit op := metrics.Begin(s.scope, "create") defer func() { op.Complete(retErr) }() - speculationsJSON, err := json.Marshal(speculationTree.Speculations) + speculationsJSON, err := json.Marshal(speculationTree.Paths) if err != nil { return fmt.Errorf("failed to marshal speculations batchID=%s for Create speculation tree entity: %w", speculationTree.BatchID, err) } @@ -91,31 +91,32 @@ func (s *speculationTreeStore) Create(ctx context.Context, speculationTree entit return nil } -// UpdateSpeculations updates the speculations of a speculation tree. Returns ErrNotFound if the speculation tree is not found. -func (s *speculationTreeStore) UpdateSpeculations(ctx context.Context, batchID string, speculations []entity.SpeculationInfo) (retErr error) { - op := metrics.Begin(s.scope, "update_speculations") +// Update overwrites the paths of an existing speculation tree, identified by +// speculationTree.BatchID. Returns ErrNotFound if the speculation tree is not found. +func (s *speculationTreeStore) Update(ctx context.Context, speculationTree entity.SpeculationTree) (retErr error) { + op := metrics.Begin(s.scope, "update") defer func() { op.Complete(retErr) }() - speculationsJSON, err := json.Marshal(speculations) + speculationsJSON, err := json.Marshal(speculationTree.Paths) if err != nil { - return fmt.Errorf("failed to marshal speculations batchID=%s for UpdateSpeculations: %w", batchID, err) + return fmt.Errorf("failed to marshal paths batchID=%s for Update: %w", speculationTree.BatchID, err) } result, err := s.db.ExecContext(ctx, "UPDATE speculation_tree SET speculations = ? WHERE batch_id = ?", - speculationsJSON, batchID, + speculationsJSON, speculationTree.BatchID, ) if err != nil { - return fmt.Errorf("failed to update speculations for batchID=%q: %w", batchID, err) + return fmt.Errorf("failed to update speculation tree for batchID=%q: %w", speculationTree.BatchID, err) } rowsAffected, err := result.RowsAffected() if err != nil { - return fmt.Errorf("failed to get rows affected from update for batchID=%q: %w", batchID, err) + return fmt.Errorf("failed to get rows affected from update for batchID=%q: %w", speculationTree.BatchID, err) } if rowsAffected != 1 { - return storage.WrapNotFound(fmt.Errorf("speculation tree entity batchID=%s", batchID)) + return storage.WrapNotFound(fmt.Errorf("speculation tree entity batchID=%s", speculationTree.BatchID)) } return nil diff --git a/submitqueue/extension/storage/speculation_tree_store.go b/submitqueue/extension/storage/speculation_tree_store.go index 0b021bba..b282d60b 100644 --- a/submitqueue/extension/storage/speculation_tree_store.go +++ b/submitqueue/extension/storage/speculation_tree_store.go @@ -32,7 +32,7 @@ type SpeculationTreeStore interface { // Returns ErrAlreadyExists if the entry already exists. Create(ctx context.Context, speculationTree entity.SpeculationTree) error - // UpdateSpeculations updates the speculations of a speculation tree. - // Returns ErrNotFound if the speculation tree is not found. - UpdateSpeculations(ctx context.Context, batchID string, speculations []entity.SpeculationInfo) error + // Update overwrites the paths of an existing speculation tree, identified by + // speculationTree.BatchID. Returns ErrNotFound if the speculation tree is not found. + Update(ctx context.Context, speculationTree entity.SpeculationTree) error } diff --git a/submitqueue/orchestrator/controller/build/build.go b/submitqueue/orchestrator/controller/build/build.go index 9143993a..c6ca6dda 100644 --- a/submitqueue/orchestrator/controller/build/build.go +++ b/submitqueue/orchestrator/controller/build/build.go @@ -139,7 +139,7 @@ func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (r build := entity.Build{ ID: buildID.ID, BatchID: batch.ID, - SpeculationPath: entity.SpeculationPathInfo{Base: append([]string{}, batch.Dependencies...)}, + SpeculationPath: entity.SpeculationPath{Base: append([]string{}, batch.Dependencies...), Head: batch.ID}, Status: entity.BuildStatusAccepted, }