diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 73d1e9d9..04bb8a48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -54,7 +54,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -75,7 +75,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -96,7 +96,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -111,7 +111,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -126,7 +126,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -144,7 +144,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -159,7 +159,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -174,7 +174,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -192,7 +192,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # This job executes untrusted PR code (make build/test/lint). Don't # leave the GITHUB_TOKEN in the workspace git config while it runs. @@ -216,7 +216,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false - name: actionlint diff --git a/.github/workflows/rebase-stack.yml b/.github/workflows/rebase-stack.yml index 5f726a51..69177b4c 100644 --- a/.github/workflows/rebase-stack.yml +++ b/.github/workflows/rebase-stack.yml @@ -88,7 +88,7 @@ jobs: # so the secret cannot be used from any other ref/context. environment: stack-rebase steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # Fetch full history so rebase --onto works correctly. fetch-depth: 0 diff --git a/.github/workflows/tag-go-pseudoversion.yml b/.github/workflows/tag-go-pseudoversion.yml index fa471348..1488cbd7 100644 --- a/.github/workflows/tag-go-pseudoversion.yml +++ b/.github/workflows/tag-go-pseudoversion.yml @@ -24,7 +24,7 @@ jobs: name: Create and push pseudo-version tag runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 diff --git a/Makefile b/Makefile index d80bd17e..a0178520 100644 --- a/Makefile +++ b/Makefile @@ -364,7 +364,7 @@ local-stovepipe-stop: ## Stop the Stovepipe service mocks: ## Generate mock files using mockgen @echo "Generating mocks..." - @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/speculation/enumerator/... ./submitqueue/extension/speculation/dependencylimit/... ./submitqueue/extension/speculation/selector/... ./submitqueue/extension/speculation/selectionlimit/... ./submitqueue/extension/speculation/prioritizer/... ./submitqueue/extension/speculation/prioritizationlimit/... ./submitqueue/extension/validator/... ./submitqueue/extension/speculation/pathscorer/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/... + @$(BAZEL) run @rules_go//go -- generate ./submitqueue/extension/storage/... ./submitqueue/extension/buildrunner/... ./submitqueue/extension/changeprovider/... ./platform/extension/counter/... ./platform/extension/messagequeue/... ./submitqueue/extension/queueconfig/... ./submitqueue/extension/mergechecker/... ./submitqueue/extension/pusher/... ./submitqueue/extension/scorer/... ./submitqueue/extension/conflict/... ./submitqueue/extension/validator/... ./platform/consumer/... ./stovepipe/extension/storage/... ./stovepipe/extension/sourcecontrol/... @echo "Mocks generated successfully!" proto: ## Generate protobuf files from .proto definitions diff --git a/doc/rfc/index.md b/doc/rfc/index.md index e6aebb3a..a740750c 100644 --- a/doc/rfc/index.md +++ b/doc/rfc/index.md @@ -16,7 +16,6 @@ Design documents and technical proposals, grouped by scope. Shared/cross-cutting - [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 - [Gateway Status and List APIs](submitqueue/status-list-api.md) - Gateway-owned request context, materialized current status, sqid or change-URI status lookup, and queue admission listing -- [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 index 97fad456..e69de29b 100644 --- a/doc/rfc/submitqueue/speculation.md +++ b/doc/rfc/submitqueue/speculation.md @@ -1,236 +0,0 @@ -# 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. Not fixed — it moves as the batch's state changes. | -| **Status** | The observed lifecycle state of a path (candidate, building, passed, …). | -| **Action** | What to do with a path next: build it, or cancel it. | -| **Limit** | A "how much" bound: 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` succeeds on its own (its `[]→q1` build passes and it 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. Because `q1` landed, the `[]→q2` fallback is now invalid — `q2` must include `q1` — so it 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. Persisted paths carry a controller-assigned identity, and every seam output names a path by that ID rather than restating its Base/Head split — each seam returns only its verdict, and everything else about a path stays controller-owned. - -**Decision seams:** - -- **Enumerator** (`extension/speculation/enumerator`) — given a batch and its ordered active dependencies, returns the batch's candidate paths, each a Base/Head split — structure only. Pure and deterministic; the controller assembles the persisted tree, assigning each path's identity and stamping its status. -- **Scorer** (`extension/speculation/pathscorer`) — given the speculation tree and the current dependency batches, returns each path's predicted-success score, keyed by path identity. Scores are probabilities in [0, 1]; the controller enforces the range when it merges them into the tree. 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, each decision naming its path by identity, at most one decision per path. 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 - -**Persisted paths are referenced by assigned identity.** Each path entry in a persisted tree gets a controller-assigned, immutable, opaque ID when it is first written; scores, decisions, and durable links (such as the path→build mapping) all name paths by it. *Why:* seam outputs stay minimal — an ID plus a verdict — instead of restating structure, and cross-entity links stay valid however the tree is re-derived. *Rejected:* structural reference (restating Base/Head in every output) — couples every consumer to path structure and forces ordered-slice comparison everywhere; an ID derived from the structure — an ID that encodes structure invites parsing, and identity should be free to survive re-enumeration on the controller's terms. - -**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/service/submitqueue/orchestrator/server/BUILD.bazel b/service/submitqueue/orchestrator/server/BUILD.bazel index 2ccb3493..3bd852c8 100644 --- a/service/submitqueue/orchestrator/server/BUILD.bazel +++ b/service/submitqueue/orchestrator/server/BUILD.bazel @@ -41,9 +41,6 @@ go_library( "//submitqueue/extension/scorer/composite:go_default_library", "//submitqueue/extension/scorer/fake:go_default_library", "//submitqueue/extension/scorer/heuristic:go_default_library", - "//submitqueue/extension/speculation/prioritizationlimit/static:go_default_library", - "//submitqueue/extension/speculation/prioritizer:go_default_library", - "//submitqueue/extension/speculation/prioritizer/sticky:go_default_library", "//submitqueue/extension/storage:go_default_library", "//submitqueue/extension/storage/mysql:go_default_library", "//submitqueue/extension/validator/fake:go_default_library", @@ -57,7 +54,6 @@ go_library( "//submitqueue/orchestrator/controller/merge:go_default_library", "//submitqueue/orchestrator/controller/mergeconflictsignal:go_default_library", "//submitqueue/orchestrator/controller/mergesignal:go_default_library", - "//submitqueue/orchestrator/controller/prioritize:go_default_library", "//submitqueue/orchestrator/controller/score:go_default_library", "//submitqueue/orchestrator/controller/speculate:go_default_library", "//submitqueue/orchestrator/controller/start:go_default_library", diff --git a/service/submitqueue/orchestrator/server/main.go b/service/submitqueue/orchestrator/server/main.go index eb1ce9ab..9c3f367b 100644 --- a/service/submitqueue/orchestrator/server/main.go +++ b/service/submitqueue/orchestrator/server/main.go @@ -61,9 +61,6 @@ import ( "github.com/uber/submitqueue/submitqueue/extension/scorer/composite" scorerfake "github.com/uber/submitqueue/submitqueue/extension/scorer/fake" "github.com/uber/submitqueue/submitqueue/extension/scorer/heuristic" - prioritizationlimitstatic "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit/static" - "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer" - "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer/sticky" "github.com/uber/submitqueue/submitqueue/extension/storage" mysqlstorage "github.com/uber/submitqueue/submitqueue/extension/storage/mysql" validatorfake "github.com/uber/submitqueue/submitqueue/extension/validator/fake" @@ -77,7 +74,6 @@ import ( "github.com/uber/submitqueue/submitqueue/orchestrator/controller/merge" "github.com/uber/submitqueue/submitqueue/orchestrator/controller/mergeconflictsignal" "github.com/uber/submitqueue/submitqueue/orchestrator/controller/mergesignal" - "github.com/uber/submitqueue/submitqueue/orchestrator/controller/prioritize" "github.com/uber/submitqueue/submitqueue/orchestrator/controller/score" "github.com/uber/submitqueue/submitqueue/orchestrator/controller/speculate" "github.com/uber/submitqueue/submitqueue/orchestrator/controller/start" @@ -248,10 +244,9 @@ func run() error { brf := buildRunnerFactory{queues} scf := scorerFactory{queues} cof := analyzerFactory{queues} - prf := prioritizerFactory{queues} // Register controllers - primaryCount, err := registerPrimaryControllers(primaryConsumer, logger.Sugar(), scope, registry, cpf, brf, scf, cof, prf, cnt, store) + primaryCount, err := registerPrimaryControllers(primaryConsumer, logger.Sugar(), scope, registry, cpf, brf, scf, cof, cnt, store) if err != nil { return err } @@ -385,7 +380,6 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe {topickey.TopicKeyBatch, "batch", "orchestrator-batch"}, {topickey.TopicKeyScore, "score", "orchestrator-score"}, {topickey.TopicKeySpeculate, "speculate", "orchestrator-speculate"}, - {topickey.TopicKeyPrioritize, "prioritize", "orchestrator-prioritize"}, {topickey.TopicKeyBuild, "build", "orchestrator-build"}, {topickey.TopicKeyBuildSignal, "buildsignal", "orchestrator-buildsignal"}, {topickey.TopicKeyMerge, "submitqueue-merge", "orchestrator-merge"}, @@ -464,13 +458,6 @@ func newTopicRegistry(q extqueue.Queue, subscriberName string) (consumer.TopicRe // merge-conflict-check queue (⇢); runway performs the merge attempt and // publishes the result to merge-conflict-check-signal, which mergeconflictsignal // consumes before fanning the request out to batch. -// -// prioritize sits alongside this per-batch flow rather than in its line: it -// is queue-wide, not batch-scoped. Its message carries only a queue name; on -// each invocation it loads every Speculating batch's speculation tree for -// that queue, ranks the queue-wide candidate paths against the queue's build -// budget, applies the resulting decisions, and republishes to build for any -// path newly (or still) cleared to run. // TODO(wiring abstraction): queueExtensions + queueRegistry currently live here // as example-local wiring. Evaluate promoting them into a defined abstraction in @@ -493,7 +480,6 @@ type queueExtensions struct { buildRunner buildrunner.BuildRunner scorer scorer.Scorer analyzer conflict.Analyzer - prioritizer prioritizer.Prioritizer } // queueRegistry maps a queue name to its extensions, falling back to a default @@ -539,13 +525,7 @@ func (f analyzerFactory) For(cfg conflict.Config) (conflict.Analyzer, error) { return f.reg.get(cfg.QueueName).analyzer, nil } -type prioritizerFactory struct{ reg queueRegistry } - -func (f prioritizerFactory) For(cfg prioritizer.Config) (prioritizer.Prioritizer, error) { - return f.reg.get(cfg.QueueName).prioritizer, nil -} - -func registerPrimaryControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, cpf changeprovider.Factory, brf buildrunner.Factory, scf scorer.Factory, cof conflict.Factory, prf prioritizer.Factory, cnt counter.Counter, store storage.Storage) (int, error) { +func registerPrimaryControllers(c consumer.Consumer, logger *zap.SugaredLogger, scope tally.Scope, registry consumer.TopicRegistry, cpf changeprovider.Factory, brf buildrunner.Factory, scf scorer.Factory, cof conflict.Factory, cnt counter.Counter, store storage.Storage) (int, error) { var count int requestController := start.NewController( logger, @@ -644,20 +624,6 @@ func registerPrimaryControllers(c consumer.Consumer, logger *zap.SugaredLogger, } count++ - prioritizeController := prioritize.NewController( - logger, - scope, - store, - prf, - registry, - topickey.TopicKeyPrioritize, - "orchestrator-prioritize", - ) - if err := c.Register(prioritizeController); err != nil { - return count, fmt.Errorf("failed to register prioritize controller: %w", err) - } - count++ - buildController := build.NewController( logger, scope, @@ -746,7 +712,6 @@ func registerDLQControllers(c consumer.Consumer, logger *zap.SugaredLogger, scop {"batch_dlq", dlq.NewDLQRequestController(logger, dlqScope, store, registry, dlq.DecodeRequestID, dlq.TopicKey(topickey.TopicKeyBatch), "orchestrator-batch-dlq")}, {"score_dlq", dlq.NewDLQBatchController(logger, dlqScope, store, registry, dlq.TopicKey(topickey.TopicKeyScore), "orchestrator-score-dlq")}, {"speculate_dlq", dlq.NewDLQBatchController(logger, dlqScope, store, registry, dlq.TopicKey(topickey.TopicKeySpeculate), "orchestrator-speculate-dlq")}, - {"prioritize_dlq", dlq.NewDLQQueueController(logger, dlqScope, registry, dlq.TopicKey(topickey.TopicKeyPrioritize), "orchestrator-prioritize-dlq")}, {"build_dlq", dlq.NewDLQBatchController(logger, dlqScope, store, registry, dlq.TopicKey(topickey.TopicKeyBuild), "orchestrator-build-dlq")}, {"buildsignal_dlq", dlq.NewDLQBuildSignalController(logger, dlqScope, store, registry, dlq.TopicKey(topickey.TopicKeyBuildSignal), "orchestrator-buildsignal-dlq")}, {"merge_dlq", dlq.NewDLQBatchController(logger, dlqScope, store, registry, dlq.TopicKey(topickey.TopicKeyMerge), "orchestrator-merge-dlq")}, @@ -881,11 +846,6 @@ func newPhabChangeProvider(logger *zap.Logger, scope tally.Scope) (changeprovide }), nil } -// defaultPrioritizationLimit is the baseline queue-wide concurrent-build -// budget handed to the sticky prioritizer. It is a parity default — -// effectively admit-all — until per-queue budgets are configured. -const defaultPrioritizationLimit = 1000 - // newQueueRegistry builds the per-queue extension profiles for the example. // Edge integrations (change provider) and the build // runner form a shared baseline; each per-queue profile starts from that @@ -907,7 +867,7 @@ func newQueueRegistry(logger *zap.Logger, scope tally.Scope, resolver changeset. // Baseline profile: shared edge integrations + a fake build runner (every // build succeeds unless a head URI carries a failure marker), plus permissive - // defaults for scorer, conflict, and prioritization. The build runner + // defaults for scorer and conflict. The build runner // instance is shared by the build and buildsignal controllers (same // profile, same instance) so a build's recorded outcome survives across // their separate factory lookups. @@ -917,9 +877,7 @@ func newQueueRegistry(logger *zap.Logger, scope tally.Scope, resolver changeset. // passthrough otherwise. The analyzer is wrapped by conflictfake with a nil // predicate (passthrough) — swap the predicate (e.g. conflictfake.FailAlways) // on a queue to exercise the analyzer error path, as e2e-conflict-error-queue - // below does. The prioritizer is sticky over a static budget: it never - // preempts a running build and admits Selected candidates by score until - // defaultPrioritizationLimit concurrent builds are in flight. + // below does. base := queueExtensions{ changeProvider: cp, buildRunner: buildfake.New(resolver), @@ -930,8 +888,7 @@ func newQueueRegistry(logger *zap.Logger, scope tally.Scope, resolver changeset. )), // TODO: replace the delegate with a real analyzer (e.g. Tango target // analysis). "all" serializes the queue conservatively. - analyzer: conflictfake.New(all.New(), nil), - prioritizer: sticky.New(prioritizationlimitstatic.New(defaultPrioritizationLimit)), + analyzer: conflictfake.New(all.New(), nil), } // test-queue: bucketed heuristic scorer; conservative (serialized) conflicts diff --git a/submitqueue/core/topickey/topickey.go b/submitqueue/core/topickey/topickey.go index a7d93c3b..352f5e0d 100644 --- a/submitqueue/core/topickey/topickey.go +++ b/submitqueue/core/topickey/topickey.go @@ -33,14 +33,6 @@ const ( TopicKeyScore TopicKey = "score" // TopicKeySpeculate is the pipeline stage where scored batches are published for speculation. TopicKeySpeculate TopicKey = "speculate" - // TopicKeyPrioritize is the queue-wide reconcile stage that rations the - // build budget across every in-flight batch of a queue. Each message - // carries a QueueID; the consumer loads every Speculating batch's tree, - // runs the queue's Prioritizer over the candidate paths, applies the - // resulting decisions — promoting paths into the build budget, or - // cancelling in-flight paths a preemptive policy evicts — and republishes - // to TopicKeyBuild for any path cleared to run. - TopicKeyPrioritize TopicKey = "prioritize" // TopicKeyBuild is the pipeline stage where speculated batches are published for builds. TopicKeyBuild TopicKey = "build" // TopicKeyBuildSignal is the polling stage for triggered builds. Each diff --git a/submitqueue/entity/BUILD.bazel b/submitqueue/entity/BUILD.bazel index b39a26ef..2ffad326 100644 --- a/submitqueue/entity/BUILD.bazel +++ b/submitqueue/entity/BUILD.bazel @@ -15,14 +15,11 @@ go_library( "list.go", "merge_result.go", "push_result.go", - "queue.go", "queue_config.go", "request.go", "request_history.go", "request_log.go", "request_summary.go", - "speculation_path_build.go", - "speculation_tree.go", ], importpath = "github.com/uber/submitqueue/submitqueue/entity", visibility = ["//visibility:public"], @@ -39,10 +36,8 @@ go_test( "build_test.go", "cancel_test.go", "land_test.go", - "queue_test.go", "request_log_test.go", "request_test.go", - "speculation_tree_test.go", ], embed = [":go_default_library"], deps = [ diff --git a/submitqueue/entity/build.go b/submitqueue/entity/build.go index 3cf5bf39..11a62c18 100644 --- a/submitqueue/entity/build.go +++ b/submitqueue/entity/build.go @@ -61,13 +61,6 @@ type Build struct { ID string // BatchID is the batch for which this build is scheduled. BatchID string - // SpeculationPathID is the ID of the speculation-tree path this build - // verifies (SpeculationPathInfo.ID). The path's structure (Base/Head) is - // not embedded here — it lives on the tree entry and is looked up via the - // tree (SpeculationPathInfo.Path). This field enables the reverse lookup - // from a build row to its path; the forward lookup (path->build) lives in - // the separate SpeculationPathBuild mapping (see speculation_path_build.go). - SpeculationPathID string // Status represents the state of the build lifecycle this build is in. Status BuildStatus } diff --git a/submitqueue/entity/build_test.go b/submitqueue/entity/build_test.go index 48cc6764..67db1cd4 100644 --- a/submitqueue/entity/build_test.go +++ b/submitqueue/entity/build_test.go @@ -68,10 +68,9 @@ func TestBuildStatus_IsTerminal(t *testing.T) { func TestBuild_ToBytes(t *testing.T) { build := Build{ - ID: "build-1", - BatchID: "batch-1", - Status: BuildStatusAccepted, - SpeculationPathID: "path-1", + ID: "build-1", + BatchID: "batch-1", + Status: BuildStatusAccepted, } data, err := build.ToBytes() @@ -83,15 +82,13 @@ func TestBuild_ToBytes(t *testing.T) { assert.Contains(t, jsonStr, "build-1") assert.Contains(t, jsonStr, "batch-1") assert.Contains(t, jsonStr, "accepted") - assert.Contains(t, jsonStr, "path-1") } func TestBuildFromBytes(t *testing.T) { original := Build{ - ID: "build-42", - BatchID: "batch-7", - Status: BuildStatusAccepted, - SpeculationPathID: "path-42", + ID: "build-42", + BatchID: "batch-7", + Status: BuildStatusAccepted, } // Serialize @@ -106,7 +103,6 @@ func TestBuildFromBytes(t *testing.T) { assert.Equal(t, original.ID, deserialized.ID) assert.Equal(t, original.BatchID, deserialized.BatchID) assert.Equal(t, original.Status, deserialized.Status) - assert.Equal(t, original.SpeculationPathID, deserialized.SpeculationPathID) } func TestBuildFromBytes_InvalidJSON(t *testing.T) { @@ -134,16 +130,15 @@ func TestBuild_SerializationRoundTrip(t *testing.T) { build Build }{ { - name: "accepted build with speculation path id", + name: "accepted build", build: Build{ - ID: "build-100", - BatchID: "batch-50", - Status: BuildStatusAccepted, - SpeculationPathID: "path-100", + ID: "build-100", + BatchID: "batch-50", + Status: BuildStatusAccepted, }, }, { - name: "succeeded build with no speculation path id", + name: "succeeded build", build: Build{ ID: "build-200", BatchID: "batch-60", @@ -153,10 +148,9 @@ func TestBuild_SerializationRoundTrip(t *testing.T) { { name: "failed build", build: Build{ - ID: "build-300", - BatchID: "batch-70", - Status: BuildStatusFailed, - SpeculationPathID: "path-300", + ID: "build-300", + BatchID: "batch-70", + Status: BuildStatusFailed, }, }, } diff --git a/submitqueue/entity/queue.go b/submitqueue/entity/queue.go deleted file mode 100644 index 26409afc..00000000 --- a/submitqueue/entity/queue.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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 entity - -import "encoding/json" - -// QueueID is the queue-message payload for queue-scoped pipeline stages. It -// carries only the queue name. -type QueueID struct { - // Name is the merge-queue name the message targets. - Name string `json:"name"` -} - -// ToBytes serializes the QueueID to JSON bytes for queue message payload. -func (q QueueID) ToBytes() ([]byte, error) { - return json.Marshal(q) -} - -// QueueIDFromBytes deserializes a QueueID from JSON bytes. -func QueueIDFromBytes(data []byte) (QueueID, error) { - var qid QueueID - err := json.Unmarshal(data, &qid) - return qid, err -} diff --git a/submitqueue/entity/queue_test.go b/submitqueue/entity/queue_test.go deleted file mode 100644 index 543ea07b..00000000 --- a/submitqueue/entity/queue_test.go +++ /dev/null @@ -1,72 +0,0 @@ -// 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 entity - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestQueueID_SerializationRoundTrip(t *testing.T) { - tests := []struct { - name string - queueID QueueID - }{ - { - name: "simple queue name", - queueID: QueueID{Name: "queueA"}, - }, - { - name: "another queue name", - queueID: QueueID{Name: "queueB"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - data, err := tt.queueID.ToBytes() - require.NoError(t, err) - - deserialized, err := QueueIDFromBytes(data) - require.NoError(t, err) - - assert.Equal(t, tt.queueID, deserialized) - }) - } -} - -func TestQueueIDFromBytes_InvalidJSON(t *testing.T) { - _, err := QueueIDFromBytes([]byte(`{"invalid": json"}`)) - assert.Error(t, err) -} - -func TestQueueIDFromBytes_EmptyJSON(t *testing.T) { - queueID, err := QueueIDFromBytes([]byte(`{}`)) - require.NoError(t, err) - - assert.Empty(t, queueID.Name) -} - -func TestQueueIDFromBytes_EmptyBytes(t *testing.T) { - _, err := QueueIDFromBytes([]byte{}) - assert.Error(t, err) -} - -func TestQueueIDFromBytes_NilBytes(t *testing.T) { - _, err := QueueIDFromBytes(nil) - assert.Error(t, err) -} diff --git a/submitqueue/entity/speculation_path_build.go b/submitqueue/entity/speculation_path_build.go deleted file mode 100644 index 634b0159..00000000 --- a/submitqueue/entity/speculation_path_build.go +++ /dev/null @@ -1,39 +0,0 @@ -// 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 entity - -// SpeculationPathBuild is the path->build mapping: given a speculation path's -// ID, it records the build that path resolved to. This is the forward lookup -// (path->build); the reverse lookup (build->path) is Build.SpeculationPathID. -type SpeculationPathBuild struct { - // PathID is the speculation path's ID (SpeculationPathInfo.ID). It is the - // primary key of this mapping and is globally unique — see - // SpeculationPathInfo.ID for the uniqueness contract — so the mapping - // needs no additional scoping key. - PathID string - // BuildID is the runner-minted build ID (Build.ID) this path resolved to. - BuildID string - // BatchID is the batch whose speculation tree contains this path. It - // makes the row self-describing without parsing PathID's internal format. - BatchID string - // CreatedAt is the creation time of this mapping, in milliseconds since - // epoch. - CreatedAt int64 - // Version is the version of the object. It is used for optimistic locking: - // updates are conditional on the persisted version matching the caller's - // expected version. Versioning starts at 1 and is incremented for each - // change to the object. - Version int32 -} diff --git a/submitqueue/entity/speculation_tree.go b/submitqueue/entity/speculation_tree.go deleted file mode 100644 index 85f74473..00000000 --- a/submitqueue/entity/speculation_tree.go +++ /dev/null @@ -1,185 +0,0 @@ -// 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 entity - -import "slices" - -// 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. -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 -} - -// Equal reports whether p and other are structurally the same speculation -// path. It is true iff Head matches and Base has the same elements in the same -// order — Base order is the build order and is significant. Structural -// equality identifies a path that has no assigned ID yet; a persisted path is -// referenced by its ID (SpeculationPathInfo.ID). -func (p SpeculationPath) Equal(other SpeculationPath) bool { - return p.Head == other.Head && slices.Equal(p.Base, other.Base) -} - -// SpeculationPathStatus is the observed lifecycle state of a speculation path. -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, persisted but - // not yet acted on. - SpeculationPathStatusCandidate SpeculationPathStatus = "candidate" - // SpeculationPathStatusSelected is a path promoted by per-batch selection — - // a desire to build it — that queue-wide prioritization has not yet cleared. - // It is not built while Selected: it waits on the build budget until it is - // prioritized (or dropped). - SpeculationPathStatusSelected SpeculationPathStatus = "selected" - // SpeculationPathStatusPrioritized is a path admitted under the queue's - // build budget: it is cleared to build. - SpeculationPathStatusPrioritized SpeculationPathStatus = "prioritized" - // SpeculationPathStatusBuilding is a path whose build is confirmed 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" - // SpeculationPathStatusCancelling is a path whose in-flight build has been - // asked to stop but whose cancellation is not yet confirmed. It mirrors the - // batch-level cancelling intent (BatchStateCancelling). A path with no build - // in flight is dropped straight to Cancelled instead. - SpeculationPathStatusCancelling SpeculationPathStatus = "cancelling" - // SpeculationPathStatusCancelled is the terminal state for a path that is no - // longer pursued — its in-flight build was confirmed stopped, or the path was - // abandoned before a build started. - SpeculationPathStatusCancelled SpeculationPathStatus = "cancelled" -) - -// SpeculationPathAction is a requested action for a single speculation path. -// It names the decision, not its effect — applying it yields the corresponding -// SpeculationPathStatus transition — and it is ephemeral: recomputed each time -// decisions are made, never persisted. -type SpeculationPathAction string - -const ( - // SpeculationPathActionUnknown is the unreachable zero value. "Leave this - // path as-is" is expressed by omitting the path from the decision set, not - // by this value. - SpeculationPathActionUnknown SpeculationPathAction = "" - // SpeculationPathActionPromote advances the path one stage toward building: - // to Selected when decided per batch (selection), to Prioritized — cleared - // to build — when decided queue-wide (prioritization). - SpeculationPathActionPromote SpeculationPathAction = "promote" - // SpeculationPathActionCancel stops pursuing the path: it moves to - // Cancelling if a build is in flight, or straight to Cancelled if no build - // has started. - SpeculationPathActionCancel SpeculationPathAction = "cancel" -) - -// SpeculationPathInfo is the per-path entry in a speculation tree: a path, its -// latest predicted-success score, its status, and a link to the build -// dispatched for it (if any). ID and Path are immutable once the entry is -// persisted; Score, Status, and BuildID are updateable under the tree's -// Version optimistic lock. -type SpeculationPathInfo struct { - // ID identifies this path. It is assigned when the path entry is first - // persisted, immutable thereafter, and globally unique — not merely unique - // within its tree, because other entities key rows by it alone - // (SpeculationPathBuild.PathID is a primary key with no extra scoping - // column). Its format carries no meaning — never parse it. Everything - // outside the tree names a path by this ID (PathScore, PathDecision, - // SpeculationPathBuild) rather than restating the Base/Head split. - ID string - // Path is the Base/Head split this entry covers. Immutable: it identifies - // the entry and never changes after the path is first persisted. - Path SpeculationPath - // Score is the path's predicted-success score. Updateable: it is recomputed - // as the world changes (dependencies land, dependency builds pass, sibling - // paths fail), so it tracks the latest state rather than a figure frozen at - // enumeration (0 until first scored). - Score float32 - // Status is the observed lifecycle state of the path. Updateable. - Status SpeculationPathStatus - // BuildID holds the runner-minted build identifier (also the build store's - // primary key) for this path. Updateable: it is empty until a build exists - // for this path. - BuildID string -} - -// PathScore is a freshly computed predicted-success score for a single path, -// named by its ID. Like PathDecision, it is ephemeral and never persisted; the -// score it carries lands in the tree entry (SpeculationPathInfo.Score). -type PathScore struct { - // PathID identifies the scored path (SpeculationPathInfo.ID). - PathID string - // Score is the path's predicted-success probability, in [0, 1]. - Score float32 -} - -// PathDecision is a requested action for a single speculation path, named by -// its ID. It is ephemeral and never persisted. A decision set covers only the -// paths to act on — omitted paths are left as-is — and carries at most one -// decision per path. -type PathDecision struct { - // PathID identifies the speculation path the action applies to - // (SpeculationPathInfo.ID). - PathID string - // Action is the requested action for the path. - Action SpeculationPathAction -} - -// SpeculationTree is the set of candidate speculation paths for a batch, built -// from its dependency graph. BatchID is immutable; Paths is updateable — -// overwritten wholesale on re-speculation — guarded by Version. -type SpeculationTree struct { - // BatchID is the batch for which this speculation tree is constructed. - // Immutable: it identifies the tree. - BatchID string - // Paths is the candidate speculation paths for this batch, derived from a - // graph of its dependencies. Each entry's per-path dynamic state (Score, - // Status, BuildID) is documented on SpeculationPathInfo. - // - // 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. - // Each dependent batch gets two paths: build alone, or build on the - // assumed-good predecessor. Just after enumeration every path is a candidate: - // - // Paths for queueA/batch/2 - [{Path: {Base: [], Head: "queueA/batch/2"}, Status: "candidate"}, {Path: {Base: ["queueA/batch/1"], Head: "queueA/batch/2"}, Status: "candidate"}] - // Paths for queueA/batch/3 - [{Path: {Base: [], Head: "queueA/batch/3"}, Status: "candidate"}, {Path: {Base: ["queueA/batch/1"], Head: "queueA/batch/3"}, Status: "candidate"}] - // - Paths []SpeculationPathInfo - // Version is the version of the object. It is used for optimistic locking: - // updates are conditional on the persisted version matching the caller's - // expected version. Versioning starts at 1 and is incremented for each - // change to the object. - Version int32 -} - -// PathIndex returns the index of the entry in t.Paths whose ID is id, or -1 -// if none is. -func (t SpeculationTree) PathIndex(id string) int { - for i, p := range t.Paths { - if p.ID == id { - return i - } - } - return -1 -} diff --git a/submitqueue/entity/speculation_tree_test.go b/submitqueue/entity/speculation_tree_test.go deleted file mode 100644 index c50314b7..00000000 --- a/submitqueue/entity/speculation_tree_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// 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 entity - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestSpeculationPath_Equal(t *testing.T) { - tests := []struct { - name string - path SpeculationPath - other SpeculationPath - equal bool - }{ - { - name: "equal paths", - path: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"}, - other: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"}, - equal: true, - }, - { - name: "different head", - path: SpeculationPath{Base: []string{"q/batch/1"}, Head: "q/batch/2"}, - other: SpeculationPath{Base: []string{"q/batch/1"}, Head: "q/batch/3"}, - equal: false, - }, - { - name: "different base order", - path: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"}, - other: SpeculationPath{Base: []string{"q/batch/2", "q/batch/1"}, Head: "q/batch/3"}, - equal: false, - }, - { - name: "different base length", - path: SpeculationPath{Base: []string{"q/batch/1"}, Head: "q/batch/3"}, - other: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"}, - equal: false, - }, - { - name: "both empty", - path: SpeculationPath{}, - other: SpeculationPath{}, - equal: true, - }, - { - name: "nil base equals empty base", - path: SpeculationPath{Base: nil, Head: "q/batch/1"}, - other: SpeculationPath{Base: []string{}, Head: "q/batch/1"}, - equal: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.equal, tt.path.Equal(tt.other)) - // Equal must be symmetric. - assert.Equal(t, tt.equal, tt.other.Equal(tt.path)) - }) - } -} - -func TestSpeculationTree_PathIndex(t *testing.T) { - tree := SpeculationTree{ - BatchID: "q/batch/3", - Paths: []SpeculationPathInfo{ - {ID: "q/batch/3/path/0", Path: SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: "q/batch/3"}}, - {ID: "q/batch/3/path/1", Path: SpeculationPath{Base: []string{"q/batch/1"}, Head: "q/batch/3"}}, - {ID: "q/batch/3/path/2", Path: SpeculationPath{Head: "q/batch/3"}}, - }, - } - - tests := []struct { - name string - id string - want int - }{ - { - name: "first path found", - id: "q/batch/3/path/0", - want: 0, - }, - { - name: "last path found", - id: "q/batch/3/path/2", - want: 2, - }, - { - name: "unknown id not found", - id: "q/batch/3/path/9", - want: -1, - }, - { - name: "empty id not found", - id: "", - want: -1, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, tree.PathIndex(tt.id)) - }) - } -} - -func TestSpeculationTree_PathIndex_EmptyTree(t *testing.T) { - assert.Equal(t, -1, SpeculationTree{}.PathIndex("q/batch/1/path/0")) -} diff --git a/submitqueue/extension/speculation/dependencylimit/BUILD.bazel b/submitqueue/extension/speculation/dependencylimit/BUILD.bazel deleted file mode 100644 index 5abae99b..00000000 --- a/submitqueue/extension/speculation/dependencylimit/BUILD.bazel +++ /dev/null @@ -1,8 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["dependencylimit.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit", - visibility = ["//visibility:public"], -) diff --git a/submitqueue/extension/speculation/dependencylimit/README.md b/submitqueue/extension/speculation/dependencylimit/README.md deleted file mode 100644 index 2fe92c73..00000000 --- a/submitqueue/extension/speculation/dependencylimit/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Speculation Dependency Limit - -Vendor-agnostic "how much" policy that bounds how many **active** (in-flight, non-terminal) dependencies a batch may speculate over. - -See the [Speculation RFC](/doc/rfc/submitqueue/speculation.md) for the end-to-end design and how limits fit into the two-layer speculation model. - -## Dependency Limit - -Speculation splits into *decision seams* (what to build) and *limit policies* (how much to allow). The dependency limit is the first limit: it is the **eligibility gate** for speculation. A batch becomes eligible to enumerate only when its count of active dependencies is at or below the current limit; otherwise it waits. Nothing is dropped — as dependencies land they leave the active set, the count shrinks, and the batch is admitted. The gate applies even to the fully-stacked happy path, so a very long chain is not speculated in full at once. - -The value is **signal-driven**, not a fixed constant. Its primary input is the build system's available capacity, so a period of CI pressure can shrink how deep the queue speculates, but a policy may also weigh historical pass rates, cost budgets, time of day, or an experiment toggle. Because the value is dynamic, a change to the limit alone — not only a landing dependency or a DAG change — can newly admit a waiting batch. - -Unlike the selection and prioritization limits, the dependency limit is **not injected into a decision seam**. It gates eligibility *before* enumeration and needs active-dependency reconciliation, which is controller orchestration — so the controller holds it, consults it on every respeculate, and applies it, keeping the enumerator pure. - -## Factory - -A per-queue factory returns the limit policy for a queue, following the repo's extension contract. It is handed only the queue identity; the signals a policy weighs — a capacity feed, historical metrics, config — are injected at construction by the integrator in the wiring layer. Computing the limit itself takes no further inputs. diff --git a/submitqueue/extension/speculation/dependencylimit/dependencylimit.go b/submitqueue/extension/speculation/dependencylimit/dependencylimit.go deleted file mode 100644 index b32d47f3..00000000 --- a/submitqueue/extension/speculation/dependencylimit/dependencylimit.go +++ /dev/null @@ -1,61 +0,0 @@ -// 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 dependencylimit - -//go:generate mockgen -source=dependencylimit.go -destination=mock/dependencylimit_mock.go -package=mock - -import "context" - -// DependencyLimit is the "how much" policy that bounds how many active -// (in-flight, non-terminal) dependencies a batch may speculate over. -// -// It is the eligibility gate for speculation: a batch becomes eligible to -// enumerate only when its count of active dependencies is at or below the -// current limit; otherwise it waits, and is admitted later as predecessors land -// and leave the active set. The limit is a bound, not a trim — nothing is -// dropped from a batch's base. -// -// The value is dynamic: it may change between calls — not only when a -// dependency lands — so a change alone can newly admit a waiting batch, and the -// controller re-consults it on every respeculate rather than caching it. -// -// This limit is the exception among the speculation limits: it gates eligibility -// *before* enumeration and needs active-dependency reconciliation, which is -// controller orchestration — so the controller holds and applies it, rather than -// it being injected into a decision seam. The enumerator stays pure. -type DependencyLimit interface { - // Limit returns the current maximum number of active dependencies a batch - // may speculate over. The controller compares a batch's active-dependency - // count against this to decide eligibility. It takes no parameters; anything - // an implementation needs is injected at construction. - Limit(ctx context.Context) (int, error) -} - -// Config carries the per-queue identity handed to a Factory. The system knows -// only the queue name; everything a policy needs to compute the limit (a -// capacity feed, historical metrics, config) is injected at construction by the -// integrator. -type Config struct { - // QueueName identifies the queue this DependencyLimit serves. - QueueName string -} - -// Factory builds the DependencyLimit for a queue. Implementations are provided -// by integrators (and tests) and inject whatever signals they need at -// construction. -type Factory interface { - // For returns the DependencyLimit for the given queue. - For(cfg Config) (DependencyLimit, error) -} diff --git a/submitqueue/extension/speculation/dependencylimit/fake/BUILD.bazel b/submitqueue/extension/speculation/dependencylimit/fake/BUILD.bazel deleted file mode 100644 index 41a13937..00000000 --- a/submitqueue/extension/speculation/dependencylimit/fake/BUILD.bazel +++ /dev/null @@ -1,19 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["fake.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit/fake", - visibility = ["//visibility:public"], - deps = ["//submitqueue/extension/speculation/dependencylimit:go_default_library"], -) - -go_test( - name = "go_default_test", - srcs = ["fake_test.go"], - embed = [":go_default_library"], - deps = [ - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/dependencylimit/fake/fake.go b/submitqueue/extension/speculation/dependencylimit/fake/fake.go deleted file mode 100644 index 3045cfdd..00000000 --- a/submitqueue/extension/speculation/dependencylimit/fake/fake.go +++ /dev/null @@ -1,52 +0,0 @@ -// 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 fake provides a programmable dependencylimit.DependencyLimit for tests -// and examples. New sets the value returned by Limit; FailWith injects an error -// on every call. It is intended for examples and tests only, never production. -package fake - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit" -) - -// DependencyLimit is a programmable dependencylimit.DependencyLimit. -type DependencyLimit struct { - limit int - err error -} - -// New returns a fake DependencyLimit whose Limit returns the given value. -func New(limit int) *DependencyLimit { - return &DependencyLimit{limit: limit} -} - -// FailWith makes every Limit call return err. -func (l *DependencyLimit) FailWith(err error) *DependencyLimit { - l.err = err - return l -} - -// Limit returns the configured value, or the injected error if FailWith was set. -func (l *DependencyLimit) Limit(_ context.Context) (int, error) { - if l.err != nil { - return 0, l.err - } - return l.limit, nil -} - -// ensure the fake satisfies the interface. -var _ dependencylimit.DependencyLimit = (*DependencyLimit)(nil) diff --git a/submitqueue/extension/speculation/dependencylimit/fake/fake_test.go b/submitqueue/extension/speculation/dependencylimit/fake/fake_test.go deleted file mode 100644 index 36bcb690..00000000 --- a/submitqueue/extension/speculation/dependencylimit/fake/fake_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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 fake - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLimit_ReturnsConfiguredValue(t *testing.T) { - got, err := New(3).Limit(context.Background()) - require.NoError(t, err) - assert.Equal(t, 3, got) -} - -func TestLimit_FailWith(t *testing.T) { - sentinel := errors.New("boom") - _, err := New(3).FailWith(sentinel).Limit(context.Background()) - require.ErrorIs(t, err, sentinel) -} diff --git a/submitqueue/extension/speculation/dependencylimit/mock/BUILD.bazel b/submitqueue/extension/speculation/dependencylimit/mock/BUILD.bazel deleted file mode 100644 index 3e928a64..00000000 --- a/submitqueue/extension/speculation/dependencylimit/mock/BUILD.bazel +++ /dev/null @@ -1,12 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["dependencylimit_mock.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit/mock", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/extension/speculation/dependencylimit:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/dependencylimit/mock/dependencylimit_mock.go b/submitqueue/extension/speculation/dependencylimit/mock/dependencylimit_mock.go deleted file mode 100644 index 52c743cd..00000000 --- a/submitqueue/extension/speculation/dependencylimit/mock/dependencylimit_mock.go +++ /dev/null @@ -1,96 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: dependencylimit.go -// -// Generated by this command: -// -// mockgen -source=dependencylimit.go -destination=mock/dependencylimit_mock.go -package=mock -// - -// Package mock is a generated GoMock package. -package mock - -import ( - context "context" - reflect "reflect" - - dependencylimit "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit" - gomock "go.uber.org/mock/gomock" -) - -// MockDependencyLimit is a mock of DependencyLimit interface. -type MockDependencyLimit struct { - ctrl *gomock.Controller - recorder *MockDependencyLimitMockRecorder - isgomock struct{} -} - -// MockDependencyLimitMockRecorder is the mock recorder for MockDependencyLimit. -type MockDependencyLimitMockRecorder struct { - mock *MockDependencyLimit -} - -// NewMockDependencyLimit creates a new mock instance. -func NewMockDependencyLimit(ctrl *gomock.Controller) *MockDependencyLimit { - mock := &MockDependencyLimit{ctrl: ctrl} - mock.recorder = &MockDependencyLimitMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockDependencyLimit) EXPECT() *MockDependencyLimitMockRecorder { - return m.recorder -} - -// Limit mocks base method. -func (m *MockDependencyLimit) Limit(ctx context.Context) (int, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Limit", ctx) - ret0, _ := ret[0].(int) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Limit indicates an expected call of Limit. -func (mr *MockDependencyLimitMockRecorder) Limit(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Limit", reflect.TypeOf((*MockDependencyLimit)(nil).Limit), ctx) -} - -// 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 dependencylimit.Config) (dependencylimit.DependencyLimit, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "For", cfg) - ret0, _ := ret[0].(dependencylimit.DependencyLimit) - 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/dependencylimit/static/BUILD.bazel b/submitqueue/extension/speculation/dependencylimit/static/BUILD.bazel deleted file mode 100644 index 2fa9a906..00000000 --- a/submitqueue/extension/speculation/dependencylimit/static/BUILD.bazel +++ /dev/null @@ -1,19 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["static.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit/static", - visibility = ["//visibility:public"], - deps = ["//submitqueue/extension/speculation/dependencylimit:go_default_library"], -) - -go_test( - name = "go_default_test", - srcs = ["static_test.go"], - embed = [":go_default_library"], - deps = [ - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/dependencylimit/static/README.md b/submitqueue/extension/speculation/dependencylimit/static/README.md deleted file mode 100644 index 03595e91..00000000 --- a/submitqueue/extension/speculation/dependencylimit/static/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Static Dependency Limit - -The static `dependencylimit.DependencyLimit` is constructed with a single integer and returns it, unchanged, from every call to `Limit`. It never errors and never consults any external signal — the simplest possible dependency limit, useful for wiring tests, local development, and any queue whose eligibility bound is a fixed operational constant rather than one derived from live capacity or other signals. diff --git a/submitqueue/extension/speculation/dependencylimit/static/static.go b/submitqueue/extension/speculation/dependencylimit/static/static.go deleted file mode 100644 index 1778b1c0..00000000 --- a/submitqueue/extension/speculation/dependencylimit/static/static.go +++ /dev/null @@ -1,42 +0,0 @@ -// 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 static provides a dependencylimit.DependencyLimit that always -// returns a fixed, construction-time value. -package static - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/extension/speculation/dependencylimit" -) - -// staticLimit is a dependencylimit.DependencyLimit that always returns a -// fixed value. -type staticLimit struct { - // limit is the fixed maximum active-dependency count returned by every - // call to Limit. - limit int -} - -// New returns a dependencylimit.DependencyLimit whose Limit always returns -// limit. -func New(limit int) dependencylimit.DependencyLimit { - return staticLimit{limit: limit} -} - -// Limit returns the fixed value given to New. It never errors. -func (l staticLimit) Limit(_ context.Context) (int, error) { - return l.limit, nil -} diff --git a/submitqueue/extension/speculation/dependencylimit/static/static_test.go b/submitqueue/extension/speculation/dependencylimit/static/static_test.go deleted file mode 100644 index e3f3fa9e..00000000 --- a/submitqueue/extension/speculation/dependencylimit/static/static_test.go +++ /dev/null @@ -1,35 +0,0 @@ -// 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 static - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLimit_ReturnsConfiguredValue(t *testing.T) { - got, err := New(4).Limit(context.Background()) - require.NoError(t, err) - assert.Equal(t, 4, got) -} - -func TestLimit_ZeroValue(t *testing.T) { - got, err := New(0).Limit(context.Background()) - require.NoError(t, err) - assert.Equal(t, 0, got) -} diff --git a/submitqueue/extension/speculation/enumerator/BUILD.bazel b/submitqueue/extension/speculation/enumerator/BUILD.bazel deleted file mode 100644 index ad5c3284..00000000 --- a/submitqueue/extension/speculation/enumerator/BUILD.bazel +++ /dev/null @@ -1,9 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["enumerator.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator", - visibility = ["//visibility:public"], - deps = ["//submitqueue/entity:go_default_library"], -) diff --git a/submitqueue/extension/speculation/enumerator/README.md b/submitqueue/extension/speculation/enumerator/README.md deleted file mode 100644 index 48e75035..00000000 --- a/submitqueue/extension/speculation/enumerator/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Speculation Path Enumerator - -Vendor-agnostic interface for enumerating the candidate **speculation paths** of a batch — the raw material the orchestrator assembles into the batch's speculation tree. - -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** and purely **structural**: *given a batch and its active dependency batches, it mechanically lists the candidate paths.* It does **not** score paths — that is the [scorer](../scorer)'s job, which the controller re-runs on every respeculate — 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. The dependency limit is the controller's responsibility: the controller gates a batch on the limit and hands the enumerator exactly the active dependencies to speculate over, which it then enumerates over verbatim. - -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. The dependency batches are handed to the enumerator oldest-first (queue arrival order), and that ordering is load-bearing: it fixes which assumed-good prefixes exist and the order of predecessors within each. - -Enumeration is **pure and deterministic**: the same batch and dependency list always produce the same paths. This lets the controller regenerate the candidates 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. - -The returned paths carry structure only — a Base/Head split, nothing else. Everything else about a path is owned by the controller, which wraps each one into the persisted tree entry: it assigns the path its identity, stamps its status, and calls the scorer to fill its score. Enumeration produces none of these. - -## Factory - -A per-queue factory returns the enumerator for a queue, following the repo's extension contract. It is handed only the queue identity and nothing else; everything an implementation needs — including behavioral knobs like enumeration breadth — is injected at construction by the integrator in the wiring layer, which resolves per-queue settings through `queueconfig`. Enumeration itself stays config-free. diff --git a/submitqueue/extension/speculation/enumerator/chain/BUILD.bazel b/submitqueue/extension/speculation/enumerator/chain/BUILD.bazel deleted file mode 100644 index 3d276c10..00000000 --- a/submitqueue/extension/speculation/enumerator/chain/BUILD.bazel +++ /dev/null @@ -1,23 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["chain.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator/chain", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/enumerator:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["chain_test.go"], - embed = [":go_default_library"], - deps = [ - "//submitqueue/entity:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/enumerator/chain/README.md b/submitqueue/extension/speculation/enumerator/chain/README.md deleted file mode 100644 index 9a2d2ec3..00000000 --- a/submitqueue/extension/speculation/enumerator/chain/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# Chain Enumerator - -Given a batch and its active dependency batches in arrival order, the chain `enumerator.Enumerator` produces exactly one candidate path: the batch built directly on top of the full chain of those dependencies, in the order given. It never branches, so a batch's tree only ever contains this single path. An empty dependency list still yields one path, whose base is empty — the batch built directly on the target branch. - -This is deliberately the least interesting enumerator: it does not weigh which dependencies to include or exclude, does not consider dropping a shaky predecessor, and does not produce a "build alone" alternative alongside the chained one. It is the working, deterministic single-chain baseline — a richer enumerator (for example, one that also offers a build-alone fallback path per dependency) can replace or supplement it without changing the contract. - -As with any `enumerator.Enumerator`, the returned path carries structure only — a Base/Head split. The controller assigns its identity, stamps its status, and has the scorer fill its score. diff --git a/submitqueue/extension/speculation/enumerator/chain/chain.go b/submitqueue/extension/speculation/enumerator/chain/chain.go deleted file mode 100644 index f4a18171..00000000 --- a/submitqueue/extension/speculation/enumerator/chain/chain.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 chain provides an enumerator.Enumerator that enumerates a batch -// as exactly one path, built directly on top of the full ordered chain of -// its active dependencies. It is the single-chain baseline; a multi-path -// enumerator (e.g. one adding build-alone fallback paths) can replace or -// supplement it without changing the contract. -package chain - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator" -) - -// chainEnumerator is an enumerator.Enumerator that always enumerates a single -// path per batch. -type chainEnumerator struct{} - -// New returns an enumerator.Enumerator that enumerates exactly one path per -// batch: the path's Base is deps in the given order and its Head is the -// batch itself. -func New() enumerator.Enumerator { - return chainEnumerator{} -} - -// Enumerate returns exactly one path whose Base preserves deps' order and -// whose Head is the batch — including when deps is empty, in which case the -// single path has an empty Base (the batch builds directly on the target -// branch). The controller owns everything beyond structure: it assigns the -// path its identity, stamps its status, and has the scorer fill its score. -func (chainEnumerator) Enumerate(_ context.Context, batch entity.Batch, deps []entity.Batch) ([]entity.SpeculationPath, error) { - var base []string - for _, dep := range deps { - base = append(base, dep.ID) - } - return []entity.SpeculationPath{{Base: base, Head: batch.ID}}, nil -} diff --git a/submitqueue/extension/speculation/enumerator/chain/chain_test.go b/submitqueue/extension/speculation/enumerator/chain/chain_test.go deleted file mode 100644 index 9b96b11b..00000000 --- a/submitqueue/extension/speculation/enumerator/chain/chain_test.go +++ /dev/null @@ -1,64 +0,0 @@ -// 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 chain - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber/submitqueue/submitqueue/entity" -) - -func TestEnumerate(t *testing.T) { - tests := []struct { - name string - batchID string - deps []entity.Batch - want []entity.SpeculationPath - }{ - { - name: "no deps yields one path with an empty base", - batchID: "q/batch/1", - deps: nil, - want: []entity.SpeculationPath{{Head: "q/batch/1"}}, - }, - { - name: "deps preserve input order in base", - batchID: "q/batch/4", - deps: []entity.Batch{ - {ID: "q/batch/3"}, - {ID: "q/batch/1"}, - {ID: "q/batch/2"}, - }, - want: []entity.SpeculationPath{{ - Base: []string{"q/batch/3", "q/batch/1", "q/batch/2"}, - Head: "q/batch/4", - }}, - }, - } - - e := New() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := e.Enumerate(context.Background(), entity.Batch{ID: tt.batchID}, tt.deps) - require.NoError(t, err) - assert.Equal(t, tt.want, got) - require.Len(t, got, 1) - assert.Equal(t, tt.batchID, got[0].Head) - }) - } -} diff --git a/submitqueue/extension/speculation/enumerator/enumerator.go b/submitqueue/extension/speculation/enumerator/enumerator.go deleted file mode 100644 index 50919a13..00000000 --- a/submitqueue/extension/speculation/enumerator/enumerator.go +++ /dev/null @@ -1,67 +0,0 @@ -// 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 lists the candidate speculation paths for a batch — the raw -// material the controller assembles into the batch's speculation tree. -// -// Enumeration answers "what futures are possible" for a batch. It is -// deliberately dumb and purely structural: it mechanically lists candidate -// Base/Head paths from the dependency batches it is handed and nothing else. It -// does not score paths — that is the scorer's job (see -// extension/speculation/pathscorer), which the controller re-runs on every -// respeculate — 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 gates on the dependency limit and hands -// Enumerate exactly the active dependencies to speculate over. -// -// Its output is structure only. Everything else about a path — its identity, -// status, and score — is owned by the controller, which wraps each returned -// path into the persisted tree entry (entity.SpeculationPathInfo). -type Enumerator interface { - // Enumerate returns the candidate speculation paths for the batch, given its - // active dependency batches ordered oldest-first (queue arrival order). That - // ordering is load-bearing: a path's Base is an assumed-good prefix of - // predecessors applied in order, so the deps ordering fixes which Base - // prefixes exist and the order of batch IDs within each. - // - // Enumeration is pure and deterministic: the same (batch, deps) always - // yields the same paths, so callers may regenerate safely. Returned paths - // must not contain duplicates (same Head and same ordered Base). - Enumerate(ctx context.Context, batch entity.Batch, deps []entity.Batch) ([]entity.SpeculationPath, 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/fake/BUILD.bazel b/submitqueue/extension/speculation/enumerator/fake/BUILD.bazel deleted file mode 100644 index 14e92890..00000000 --- a/submitqueue/extension/speculation/enumerator/fake/BUILD.bazel +++ /dev/null @@ -1,23 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["fake.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator/fake", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/enumerator:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["fake_test.go"], - embed = [":go_default_library"], - deps = [ - "//submitqueue/entity:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/enumerator/fake/fake.go b/submitqueue/extension/speculation/enumerator/fake/fake.go deleted file mode 100644 index 3eae386f..00000000 --- a/submitqueue/extension/speculation/enumerator/fake/fake.go +++ /dev/null @@ -1,62 +0,0 @@ -// 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 fake provides a programmable in-memory enumerator.Enumerator for tests -// and examples. Seed the paths returned for a batch with Set, keyed by batch ID; -// an unseeded batch enumerates to no paths. FailWith injects an error on every -// call to exercise the error path. It is intended for examples and tests only, -// never production. -package fake - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator" -) - -// Enumerator is a programmable in-memory enumerator.Enumerator. -type Enumerator struct { - paths map[string][]entity.SpeculationPath - err error -} - -// New returns an empty fake Enumerator. Seed it with Set. -func New() *Enumerator { - return &Enumerator{paths: map[string][]entity.SpeculationPath{}} -} - -// Set seeds the paths returned by Enumerate for the given batch ID. -func (e *Enumerator) Set(batchID string, paths []entity.SpeculationPath) *Enumerator { - e.paths[batchID] = paths - return e -} - -// FailWith makes every Enumerate call return err. -func (e *Enumerator) FailWith(err error) *Enumerator { - e.err = err - return e -} - -// Enumerate returns the seeded paths for the batch. An unseeded batch returns -// no paths. The deps argument is ignored. -func (e *Enumerator) Enumerate(_ context.Context, batch entity.Batch, _ []entity.Batch) ([]entity.SpeculationPath, error) { - if e.err != nil { - return nil, e.err - } - return e.paths[batch.ID], nil -} - -// ensure the fake satisfies the interface. -var _ enumerator.Enumerator = (*Enumerator)(nil) diff --git a/submitqueue/extension/speculation/enumerator/fake/fake_test.go b/submitqueue/extension/speculation/enumerator/fake/fake_test.go deleted file mode 100644 index d23cd7d8..00000000 --- a/submitqueue/extension/speculation/enumerator/fake/fake_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// 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 fake - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber/submitqueue/submitqueue/entity" -) - -func TestEnumerate_SeededPaths(t *testing.T) { - paths := []entity.SpeculationPath{ - {Head: "q/batch/2"}, - {Base: []string{"q/batch/1"}, Head: "q/batch/2"}, - } - e := New().Set("q/batch/2", paths) - - got, err := e.Enumerate(context.Background(), entity.Batch{ID: "q/batch/2"}, nil) - require.NoError(t, err) - assert.Equal(t, paths, got) -} - -func TestEnumerate_UnseededReturnsNoPaths(t *testing.T) { - got, err := New().Enumerate(context.Background(), entity.Batch{ID: "q/batch/9"}, nil) - require.NoError(t, err) - assert.Empty(t, got) -} - -func TestEnumerate_FailWith(t *testing.T) { - sentinel := errors.New("boom") - _, err := New().FailWith(sentinel).Enumerate(context.Background(), entity.Batch{ID: "q/batch/1"}, nil) - require.ErrorIs(t, err, sentinel) -} diff --git a/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel b/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel deleted file mode 100644 index c6fab414..00000000 --- a/submitqueue/extension/speculation/enumerator/mock/BUILD.bazel +++ /dev/null @@ -1,13 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["enumerator_mock.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/enumerator/mock", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/enumerator:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go b/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go deleted file mode 100644 index f4ff7af7..00000000 --- a/submitqueue/extension/speculation/enumerator/mock/enumerator_mock.go +++ /dev/null @@ -1,97 +0,0 @@ -// 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, batch entity.Batch, deps []entity.Batch) ([]entity.SpeculationPath, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Enumerate", ctx, batch, deps) - ret0, _ := ret[0].([]entity.SpeculationPath) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Enumerate indicates an expected call of Enumerate. -func (mr *MockEnumeratorMockRecorder) Enumerate(ctx, batch, deps any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Enumerate", reflect.TypeOf((*MockEnumerator)(nil).Enumerate), ctx, batch, 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/pathscorer/BUILD.bazel b/submitqueue/extension/speculation/pathscorer/BUILD.bazel deleted file mode 100644 index e3be76ed..00000000 --- a/submitqueue/extension/speculation/pathscorer/BUILD.bazel +++ /dev/null @@ -1,9 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["pathscorer.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer", - visibility = ["//visibility:public"], - deps = ["//submitqueue/entity:go_default_library"], -) diff --git a/submitqueue/extension/speculation/pathscorer/README.md b/submitqueue/extension/speculation/pathscorer/README.md deleted file mode 100644 index 6258deea..00000000 --- a/submitqueue/extension/speculation/pathscorer/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Speculation Path Scorer - -Vendor-agnostic interface for scoring the paths in a batch's **speculation tree** — the predicted-success probability of each candidate bet, recomputed as the batch's world changes. - -See the [Speculation RFC](/doc/rfc/submitqueue/speculation.md) for the end-to-end design and how scoring fits into the orchestrator pipeline. - -## Scorer - -A path's score is a **prediction**: *how likely is this bet to pay off, right now?* The scorer answers it from the current state — the per-batch success probabilities of a path's base batches (`entity.Batch.Score`, set by the score stage), which of those dependencies have already landed or had their build pass (resolved assumptions raise confidence), and optionally other signals such as how long the batch has waited or historical pass rates. The score is the common currency the [selector](../selector) and prioritizer both rank on, so keeping it current is what makes both act on the latest reality. - -Because it is a prediction over live state, the scorer is **re-run on every respeculate**, right after the controller reconciles path status — so when a dependency lands, its build passes, or a sibling path fails, the surviving paths' scores are recomputed 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*. - -This is the per-**path** scorer, distinct from the per-**batch** [score stage](../../scorer), which sets `entity.Batch.Score`. The path scorer consumes those batch scores to score whole paths. The controller hands it the batch's **speculation tree** directly — the subject it scores — and any richer signal an implementation needs (the dependency batches' scores, historical pass rates) is injected at its factory, not passed in. It never writes: its only output is per-path scores, each naming a path by its ID, and the controller merges them into the tree and persists — the controller stays the single writer of tree state, and everything else about a path (structure, status) never passes through the scorer at all. Paths omitted from the result keep their last persisted score. - -Scores are **probabilities in [0, 1]** — 0 is a bet certain to lose, 1 a bet certain to pay off. That is the contract every implementation must satisfy, and the controller enforces the range when it consumes the result. The selector and prioritizer rank on these values, so implementations sharing a queue must agree on this scale. - -## Factory - -A per-queue factory returns the scorer for a queue, following the repo's extension contract. It is handed only the queue identity; scoring knobs and read access to any extra signals are injected at construction by the integrator in the wiring layer, which resolves per-queue settings through `queueconfig`. Scoring itself stays config-free. diff --git a/submitqueue/extension/speculation/pathscorer/fake/BUILD.bazel b/submitqueue/extension/speculation/pathscorer/fake/BUILD.bazel deleted file mode 100644 index a3f1b933..00000000 --- a/submitqueue/extension/speculation/pathscorer/fake/BUILD.bazel +++ /dev/null @@ -1,23 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["fake.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer/fake", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/pathscorer:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["fake_test.go"], - embed = [":go_default_library"], - deps = [ - "//submitqueue/entity:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/pathscorer/fake/fake.go b/submitqueue/extension/speculation/pathscorer/fake/fake.go deleted file mode 100644 index aa3d5519..00000000 --- a/submitqueue/extension/speculation/pathscorer/fake/fake.go +++ /dev/null @@ -1,72 +0,0 @@ -// 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 fake provides a programmable pathscorer.Scorer for tests and -// examples. By default Score echoes each input path's current score back, -// keyed by its ID; Returns overrides that with canned path scores, and -// FailWith injects an error on every call. It is intended for examples and -// tests only, never production. -package fake - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer" -) - -// Scorer is a programmable pathscorer.Scorer. -type Scorer struct { - scores []entity.PathScore - hasScores bool - err error -} - -// New returns a fake Scorer that echoes each input path's current score back. -// Override the returned scores with Returns. -func New() *Scorer { - return &Scorer{} -} - -// Returns makes every Score call return scores instead of echoing its input. -func (s *Scorer) Returns(scores []entity.PathScore) *Scorer { - s.scores = scores - s.hasScores = true - return s -} - -// FailWith makes every Score call return err. -func (s *Scorer) FailWith(err error) *Scorer { - s.err = err - return s -} - -// Score returns the canned scores if set with Returns, otherwise one PathScore -// per input path echoing the path's current score, unchanged. -func (s *Scorer) Score(_ context.Context, tree entity.SpeculationTree) ([]entity.PathScore, error) { - if s.err != nil { - return nil, s.err - } - if s.hasScores { - return s.scores, nil - } - scores := make([]entity.PathScore, 0, len(tree.Paths)) - for _, p := range tree.Paths { - scores = append(scores, entity.PathScore{PathID: p.ID, Score: p.Score}) - } - return scores, nil -} - -// ensure the fake satisfies the interface. -var _ pathscorer.Scorer = (*Scorer)(nil) diff --git a/submitqueue/extension/speculation/pathscorer/fake/fake_test.go b/submitqueue/extension/speculation/pathscorer/fake/fake_test.go deleted file mode 100644 index 09815b4c..00000000 --- a/submitqueue/extension/speculation/pathscorer/fake/fake_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 fake - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber/submitqueue/submitqueue/entity" -) - -func TestScore_EchoesInputScoresByDefault(t *testing.T) { - tree := entity.SpeculationTree{ - BatchID: "q/batch/2", - Paths: []entity.SpeculationPathInfo{ - {ID: "q/batch/2/path/0", Path: entity.SpeculationPath{Head: "q/batch/2"}, Score: 0.9}, - }, - } - got, err := New().Score(context.Background(), tree) - require.NoError(t, err) - assert.Equal(t, []entity.PathScore{{PathID: "q/batch/2/path/0", Score: 0.9}}, got) -} - -func TestScore_ReturnsCanned(t *testing.T) { - in := entity.SpeculationTree{BatchID: "q/batch/2"} - scores := []entity.PathScore{{PathID: "q/batch/2/path/0", Score: 0.75}} - got, err := New().Returns(scores).Score(context.Background(), in) - require.NoError(t, err) - assert.Equal(t, scores, got) -} - -func TestScore_FailWith(t *testing.T) { - sentinel := errors.New("boom") - _, err := New().FailWith(sentinel).Score(context.Background(), entity.SpeculationTree{BatchID: "q/batch/1"}) - require.ErrorIs(t, err, sentinel) -} diff --git a/submitqueue/extension/speculation/pathscorer/mock/BUILD.bazel b/submitqueue/extension/speculation/pathscorer/mock/BUILD.bazel deleted file mode 100644 index 16082c77..00000000 --- a/submitqueue/extension/speculation/pathscorer/mock/BUILD.bazel +++ /dev/null @@ -1,13 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["pathscorer_mock.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer/mock", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/pathscorer:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/pathscorer/mock/pathscorer_mock.go b/submitqueue/extension/speculation/pathscorer/mock/pathscorer_mock.go deleted file mode 100644 index a6997c7b..00000000 --- a/submitqueue/extension/speculation/pathscorer/mock/pathscorer_mock.go +++ /dev/null @@ -1,97 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: pathscorer.go -// -// Generated by this command: -// -// mockgen -source=pathscorer.go -destination=mock/pathscorer_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" - pathscorer "github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer" - gomock "go.uber.org/mock/gomock" -) - -// MockScorer is a mock of Scorer interface. -type MockScorer struct { - ctrl *gomock.Controller - recorder *MockScorerMockRecorder - isgomock struct{} -} - -// MockScorerMockRecorder is the mock recorder for MockScorer. -type MockScorerMockRecorder struct { - mock *MockScorer -} - -// NewMockScorer creates a new mock instance. -func NewMockScorer(ctrl *gomock.Controller) *MockScorer { - mock := &MockScorer{ctrl: ctrl} - mock.recorder = &MockScorerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockScorer) EXPECT() *MockScorerMockRecorder { - return m.recorder -} - -// Score mocks base method. -func (m *MockScorer) Score(ctx context.Context, tree entity.SpeculationTree) ([]entity.PathScore, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Score", ctx, tree) - ret0, _ := ret[0].([]entity.PathScore) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Score indicates an expected call of Score. -func (mr *MockScorerMockRecorder) Score(ctx, tree any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Score", reflect.TypeOf((*MockScorer)(nil).Score), 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 pathscorer.Config) (pathscorer.Scorer, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "For", cfg) - ret0, _ := ret[0].(pathscorer.Scorer) - 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/pathscorer/pathscorer.go b/submitqueue/extension/speculation/pathscorer/pathscorer.go deleted file mode 100644 index 641a2e3a..00000000 --- a/submitqueue/extension/speculation/pathscorer/pathscorer.go +++ /dev/null @@ -1,76 +0,0 @@ -// 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 pathscorer - -//go:generate mockgen -source=pathscorer.go -destination=mock/pathscorer_mock.go -package=mock - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" -) - -// Scorer computes the predicted-success score of every path in a batch's -// speculation tree. -// -// A path's score is a prediction — "how likely is this bet to pay off?" — and -// predictions must move as evidence arrives. The scorer answers "how good is -// each path right now" from the current state: the per-batch success -// probabilities of a path's base batches (entity.Batch.Score, set by 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). -// -// The controller re-runs the scorer on every respeculate, right after it -// reconciles path 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 and persists the result; the scorer owns the *formula*. -// -// This is the per-*path* scorer, distinct from the per-*batch* score stage -// (extension/scorer), which sets entity.Batch.Score. The path scorer consumes -// those batch scores to score whole paths. -// -// The controller hands the scorer the batch's speculation tree directly — the -// subject it scores. Any richer signal an implementation needs (the dependency -// batches' scores, historical pass rates) is injected at its Factory, not passed -// here. It never writes: it returns per-path scores and the controller merges -// them into the tree and persists, keeping the controller the single writer of -// tree state. -type Scorer interface { - // Score returns the freshly computed predicted-success score of each path in - // the tree as entity.PathScore values, keyed by path ID - // (entity.SpeculationPathInfo.ID). Paths omitted from the result keep their - // last persisted score. The combination formula is the implementation's - // concern; the returned values must be probabilities in [0, 1] — the - // controller enforces the range when it consumes them. - Score(ctx context.Context, tree entity.SpeculationTree) ([]entity.PathScore, error) -} - -// Config carries the per-queue identity handed to a Factory. The system knows -// only the queue name; everything an implementation needs (scoring knobs, and -// read access to any extra signals such as the dependency batches' scores) is -// injected at construction by the integrator. -type Config struct { - // QueueName identifies the queue this Scorer serves. - QueueName string -} - -// Factory builds the Scorer for a queue. Implementations are provided by -// integrators (and tests) and inject whatever they need at construction. -type Factory interface { - // For returns the Scorer for the given queue. - For(cfg Config) (Scorer, error) -} diff --git a/submitqueue/extension/speculation/pathscorer/probability/BUILD.bazel b/submitqueue/extension/speculation/pathscorer/probability/BUILD.bazel deleted file mode 100644 index 5fd08a1b..00000000 --- a/submitqueue/extension/speculation/pathscorer/probability/BUILD.bazel +++ /dev/null @@ -1,26 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["probability.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer/probability", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/pathscorer:go_default_library", - "//submitqueue/extension/storage:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["probability_test.go"], - embed = [":go_default_library"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/storage/mock:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/pathscorer/probability/README.md b/submitqueue/extension/speculation/pathscorer/probability/README.md deleted file mode 100644 index 49f45422..00000000 --- a/submitqueue/extension/speculation/pathscorer/probability/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Probability Path Scorer - -The probability `pathscorer.Scorer` scores every path in a batch's speculation tree as the probability that exactly that path's assumption holds: its base dependencies all land, and every other active dependency of the head fails to land. Concretely, a path's score is the head batch's probability, multiplied by the probability of each base dependency, multiplied by one minus the probability of each of the head's dependencies not in the base. - -Each batch's probability is resolved from its state, so a resolved outcome overrides the prediction. A batch that has succeeded contributes certainty 1; a batch that has failed or been cancelled contributes 0; a batch still in flight contributes `entity.Batch.Score`, the per-batch success probability the score stage sets before a batch ever reaches speculation. A batch in the best-effort `cancelling` state keeps its prediction — it may still succeed. This state resolution is what redistributes score when the world changes: a dead dependency zeroes every path that built on it and boosts every path that excluded it by the full `(1 − p) = 1` factor, with no cross-path coupling needed. A worked example of this redistribution lives in the package documentation. - -## Scope - -The model is deliberately simple: it treats dependency outcomes as independent, and does not weigh how long a batch has waited, its historical pass rate, or any correlation between siblings. It scores every path in the tree regardless of status, returning one path-ID-keyed score per path — the controller merges them into the tree and overwrites `Score` wholesale on every respeculate; nothing else about a path passes through the scorer. Folding resolved outcomes into path *status* (dead-pathing, cancellation) is the controller's reconcile job, not the scorer's; the scorer only makes the scores reflect them. - -Batches are read one at a time through the injected `storage.BatchStore`, matching the store's key/value contract, and each batch referenced by the tree is loaded at most once per `Score` call. A store error, including a missing batch, is returned wrapped and unclassified — scorer implementations, like all extensions, leave user/infra classification to the controller's error classifier. diff --git a/submitqueue/extension/speculation/pathscorer/probability/probability.go b/submitqueue/extension/speculation/pathscorer/probability/probability.go deleted file mode 100644 index ddc15b3c..00000000 --- a/submitqueue/extension/speculation/pathscorer/probability/probability.go +++ /dev/null @@ -1,158 +0,0 @@ -// 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 probability provides a pathscorer.Scorer that scores each path as -// the probability that exactly that path's assumption holds: every base -// dependency lands and every non-base dependency of the head does not. Each -// dependency's probability is resolved from its batch's state — certainty -// (1 or 0) once the batch is terminal, its predicted build-success score -// while it is in flight — so a resolved dependency automatically kills the -// paths that bet against the outcome and boosts the paths consistent with -// it. Dependency outcomes are treated as independent; there is no adjustment -// for wait time, historical pass rate, or correlation between outcomes. -// -// # Worked example -// -// Batch C has active dependencies A and B, with predicted success -// probabilities p(C) = 0.8, p(A) = 0.9, p(B) = 0.6, and a three-path tree: -// -// path base formula score -// chain [A B] 0.8 * 0.9 * 0.6 0.432 -// drop-B [A] 0.8 * 0.9 * (1 - 0.6) 0.288 -// alone [] 0.8 * (1 - 0.9) * (1 - 0.6) 0.032 -// -// The chain path leads while everything looks healthy. When B's build fails, -// p(B) resolves to 0: -// -// path base formula score -// chain [A B] 0.8 * 0.9 * 0 0 -// drop-B [A] 0.8 * 0.9 * 1 0.72 -// alone [] 0.8 * 0.1 * 1 0.08 -// -// The chain path — a bet on B landing — dies, and the drop-B path inherits -// its score mass. If A then lands, p(A) resolves to 1: drop-B rises to 0.8 -// and alone falls to 0. The selector and prioritizer rank on these scores, -// so builds follow the paths still consistent with reality. -package probability - -import ( - "context" - "fmt" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/pathscorer" - "github.com/uber/submitqueue/submitqueue/extension/storage" -) - -// probabilityScorer computes each path's Score as the probability that -// exactly its assumption holds: every base dependency lands and every other -// active dependency of the head fails to land — so this exact path, and no -// sibling, is the one that materializes. Batch probabilities come from -// batchProbability, which prefers a terminal outcome over the prediction. -// Folding resolved outcomes into path Status (dead-pathing, cancellation) -// remains the controller's reconcile concern; this scorer only recomputes -// Score. -type probabilityScorer struct { - // batches resolves a batch ID to its current state and predicted-success - // probability (entity.Batch.State / entity.Batch.Score) and, for the - // tree's head batch, its full active-dependency set - // (entity.Batch.Dependencies). - batches storage.BatchStore -} - -// New returns a pathscorer.Scorer implementing the path-probability -// heuristic, reading batch states and success probabilities from batches. -func New(batches storage.BatchStore) pathscorer.Scorer { - return &probabilityScorer{batches: batches} -} - -// batchProbability resolves the probability that b lands. A terminal state -// is certainty — 1 for succeeded, 0 for failed or cancelled — and overrides -// any prediction. Otherwise it is b.Score, the predicted build-success -// probability: the score stage sets it before a batch ever reaches -// speculation, so every batch the scorer sees carries a real prediction. -// Cancelling is deliberately not treated as terminal: cancellation is -// best-effort and the batch may still succeed, so the prediction stands -// until the state settles. -func batchProbability(b entity.Batch) float64 { - switch b.State { - case entity.BatchStateSucceeded: - return 1 - case entity.BatchStateFailed, entity.BatchStateCancelled: - return 0 - } - return b.Score -} - -// Score returns one PathScore per path in tree, regardless of Status, each -// naming its path by ID. A path's score is: -// -// p(head) * Π p(d) for d in path.Base * Π (1 - p(d)) for d in head.Dependencies but not in path.Base -// -// where p(b) is 1 when b has succeeded, 0 when b has failed or been -// cancelled, and otherwise b.Score — see batchProbability. Each batch -// referenced by the tree is loaded from the store at most once. Any store -// error (including not-found) is returned wrapped, unclassified — extensions -// never classify errors as user or infra. -func (s *probabilityScorer) Score(ctx context.Context, tree entity.SpeculationTree) ([]entity.PathScore, error) { - if len(tree.Paths) == 0 { - return nil, nil - } - - head, err := s.batches.Get(ctx, tree.BatchID) - if err != nil { - return nil, fmt.Errorf("probability: get head batch %q: %w", tree.BatchID, err) - } - - probabilities := map[string]float64{} - probabilityOf := func(batchID string) (float64, error) { - if p, ok := probabilities[batchID]; ok { - return p, nil - } - b, err := s.batches.Get(ctx, batchID) - if err != nil { - return 0, fmt.Errorf("probability: get dependency batch %q: %w", batchID, err) - } - p := batchProbability(b) - probabilities[batchID] = p - return p, nil - } - - scores := make([]entity.PathScore, len(tree.Paths)) - for i, path := range tree.Paths { - inBase := make(map[string]bool, len(path.Path.Base)) - score := batchProbability(head) - for _, dep := range path.Path.Base { - inBase[dep] = true - p, err := probabilityOf(dep) - if err != nil { - return nil, err - } - score *= p - } - for _, dep := range head.Dependencies { - if inBase[dep] { - continue - } - p, err := probabilityOf(dep) - if err != nil { - return nil, err - } - score *= 1 - p - } - scores[i] = entity.PathScore{PathID: path.ID, Score: float32(score)} - } - - return scores, nil -} diff --git a/submitqueue/extension/speculation/pathscorer/probability/probability_test.go b/submitqueue/extension/speculation/pathscorer/probability/probability_test.go deleted file mode 100644 index c098047a..00000000 --- a/submitqueue/extension/speculation/pathscorer/probability/probability_test.go +++ /dev/null @@ -1,243 +0,0 @@ -// 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 probability - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber/submitqueue/submitqueue/entity" - storagemock "github.com/uber/submitqueue/submitqueue/extension/storage/mock" - "go.uber.org/mock/gomock" -) - -const headID = "q/batch/5" - -func TestScore_ProbabilityFormula(t *testing.T) { - tests := []struct { - name string - head entity.Batch - deps map[string]entity.Batch - path entity.SpeculationPath - want float32 - }{ - { - name: "healthy dep in base multiplies straight through", - head: entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}}, - deps: map[string]entity.Batch{"q/batch/1": {ID: "q/batch/1", Score: 0.9}}, - path: entity.SpeculationPath{Base: []string{"q/batch/1"}, Head: headID}, - want: 0.72, - }, - { - name: "shaky dep not in base contributes its failure probability", - head: entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}}, - deps: map[string]entity.Batch{"q/batch/1": {ID: "q/batch/1", Score: 0.9}}, - path: entity.SpeculationPath{Head: headID}, - want: 0.08, // 0.8 * (1 - 0.9) - }, - { - name: "succeeded dep in base is certainty, overriding its prediction", - head: entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}}, - deps: map[string]entity.Batch{"q/batch/1": {ID: "q/batch/1", Score: 0.3, State: entity.BatchStateSucceeded}}, - path: entity.SpeculationPath{Base: []string{"q/batch/1"}, Head: headID}, - want: 0.8, // 0.8 * 1 - }, - { - name: "succeeded dep not in base zeroes the path betting against it", - head: entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}}, - deps: map[string]entity.Batch{"q/batch/1": {ID: "q/batch/1", Score: 0.3, State: entity.BatchStateSucceeded}}, - path: entity.SpeculationPath{Head: headID}, - want: 0, // 0.8 * (1 - 1) - }, - { - name: "failed dep in base zeroes the path built on it", - head: entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}}, - deps: map[string]entity.Batch{"q/batch/1": {ID: "q/batch/1", Score: 0.9, State: entity.BatchStateFailed}}, - path: entity.SpeculationPath{Base: []string{"q/batch/1"}, Head: headID}, - want: 0, // 0.8 * 0 - }, - { - name: "failed dep not in base boosts the path that excluded it", - head: entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}}, - deps: map[string]entity.Batch{"q/batch/1": {ID: "q/batch/1", Score: 0.9, State: entity.BatchStateFailed}}, - path: entity.SpeculationPath{Head: headID}, - want: 0.8, // 0.8 * (1 - 0) - }, - { - name: "cancelled dep in base zeroes the path built on it", - head: entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}}, - deps: map[string]entity.Batch{"q/batch/1": {ID: "q/batch/1", Score: 0.9, State: entity.BatchStateCancelled}}, - path: entity.SpeculationPath{Base: []string{"q/batch/1"}, Head: headID}, - want: 0, // 0.8 * 0 - }, - { - name: "cancelling dep keeps its prediction — cancellation is best-effort", - head: entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}}, - deps: map[string]entity.Batch{"q/batch/1": {ID: "q/batch/1", Score: 0.9, State: entity.BatchStateCancelling}}, - path: entity.SpeculationPath{Base: []string{"q/batch/1"}, Head: headID}, - want: 0.72, // 0.8 * 0.9 - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctrl := gomock.NewController(t) - store := storagemock.NewMockBatchStore(ctrl) - store.EXPECT().Get(gomock.Any(), headID).Return(tt.head, nil) - for id, b := range tt.deps { - store.EXPECT().Get(gomock.Any(), id).Return(b, nil) - } - - tree := entity.SpeculationTree{ - BatchID: headID, - Paths: []entity.SpeculationPathInfo{{ID: "p/0", Path: tt.path, Status: entity.SpeculationPathStatusCandidate}}, - } - got, err := New(store).Score(context.Background(), tree) - require.NoError(t, err) - require.Len(t, got, 1) - assert.Equal(t, "p/0", got[0].PathID) - assert.InDelta(t, tt.want, got[0].Score, 0.0001) - }) - } -} - -// TestScore_DependencyFailureShiftsMassToSurvivingPaths pins the respeculate -// story end-to-end: with three paths over deps A and B, a failure of B zeroes -// the chain path that built on it and boosts the drop-B path by the full -// (1 - p(B)) = 1 factor — the score mass shifts to the paths consistent with -// the resolved outcome, with no cross-path coupling in the scorer. -func TestScore_DependencyFailureShiftsMassToSurvivingPaths(t *testing.T) { - depA := entity.Batch{ID: "q/batch/1", Score: 0.9} - depB := entity.Batch{ID: "q/batch/2", Score: 0.6} - head := entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{depA.ID, depB.ID}} - - tree := entity.SpeculationTree{ - BatchID: headID, - Paths: []entity.SpeculationPathInfo{ - {ID: "p/chain", Path: entity.SpeculationPath{Base: []string{depA.ID, depB.ID}, Head: headID}}, // full chain - {ID: "p/drop-b", Path: entity.SpeculationPath{Base: []string{depA.ID}, Head: headID}}, // drop B - {ID: "p/alone", Path: entity.SpeculationPath{Head: headID}}, // alone - }, - } - - score := func(t *testing.T, deps ...entity.Batch) []float32 { - ctrl := gomock.NewController(t) - store := storagemock.NewMockBatchStore(ctrl) - store.EXPECT().Get(gomock.Any(), headID).Return(head, nil) - for _, d := range deps { - store.EXPECT().Get(gomock.Any(), d.ID).Return(d, nil) - } - got, err := New(store).Score(context.Background(), tree) - require.NoError(t, err) - require.Len(t, got, 3) - scores := make([]float32, 3) - for i, ps := range got { - assert.Equal(t, tree.Paths[i].ID, ps.PathID) - scores[i] = ps.Score - } - return scores - } - - t.Run("before: both deps in flight, chain path leads", func(t *testing.T) { - scores := score(t, depA, depB) - assert.InDelta(t, 0.8*0.9*0.6, scores[0], 0.0001) // chain - assert.InDelta(t, 0.8*0.9*(1-0.6), scores[1], 0.0001) // drop B - assert.InDelta(t, 0.8*(1-0.9)*(1-0.6), scores[2], 0.0001) // alone - }) - - t.Run("after: B failed, drop-B path is boosted and chain dies", func(t *testing.T) { - failedB := depB - failedB.State = entity.BatchStateFailed - scores := score(t, depA, failedB) - assert.InDelta(t, 0, scores[0], 0.0001) // chain: bets on B landing - assert.InDelta(t, 0.8*0.9*1, scores[1], 0.0001) // drop B: (1 - 0) boost - assert.InDelta(t, 0.8*(1-0.9)*1, scores[2], 0.0001) // alone - }) -} - -func TestScore_MultiplePathsScoredIndependentlyAndDepsCachedOnce(t *testing.T) { - ctrl := gomock.NewController(t) - store := storagemock.NewMockBatchStore(ctrl) - - head := entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1", "q/batch/2"}} - dep1 := entity.Batch{ID: "q/batch/1", Score: 0.9} - dep2 := entity.Batch{ID: "q/batch/2", Score: 0.6} - - // Each batch is loaded at most once per Score call, even though both - // paths below reference dep1 and dep2. - store.EXPECT().Get(gomock.Any(), headID).Return(head, nil).Times(1) - store.EXPECT().Get(gomock.Any(), "q/batch/1").Return(dep1, nil).Times(1) - store.EXPECT().Get(gomock.Any(), "q/batch/2").Return(dep2, nil).Times(1) - - tree := entity.SpeculationTree{ - BatchID: headID, - Paths: []entity.SpeculationPathInfo{ - {ID: "p/0", Path: entity.SpeculationPath{Base: []string{"q/batch/1"}, Head: headID}}, - {ID: "p/1", Path: entity.SpeculationPath{Base: []string{"q/batch/2"}, Head: headID}}, - }, - } - - got, err := New(store).Score(context.Background(), tree) - require.NoError(t, err) - require.Len(t, got, 2) - assert.Equal(t, "p/0", got[0].PathID) - assert.InDelta(t, 0.8*0.9*(1-0.6), got[0].Score, 0.0001) - assert.Equal(t, "p/1", got[1].PathID) - assert.InDelta(t, 0.8*0.6*(1-0.9), got[1].Score, 0.0001) -} - -func TestScore_HeadStoreErrorPropagates(t *testing.T) { - ctrl := gomock.NewController(t) - store := storagemock.NewMockBatchStore(ctrl) - sentinel := errors.New("boom") - store.EXPECT().Get(gomock.Any(), headID).Return(entity.Batch{}, sentinel) - - tree := entity.SpeculationTree{ - BatchID: headID, - Paths: []entity.SpeculationPathInfo{{Path: entity.SpeculationPath{Head: headID}}}, - } - _, err := New(store).Score(context.Background(), tree) - require.ErrorIs(t, err, sentinel) -} - -func TestScore_DependencyStoreErrorPropagates(t *testing.T) { - ctrl := gomock.NewController(t) - store := storagemock.NewMockBatchStore(ctrl) - sentinel := errors.New("boom") - head := entity.Batch{ID: headID, Score: 0.8, Dependencies: []string{"q/batch/1"}} - store.EXPECT().Get(gomock.Any(), headID).Return(head, nil) - store.EXPECT().Get(gomock.Any(), "q/batch/1").Return(entity.Batch{}, sentinel) - - tree := entity.SpeculationTree{ - BatchID: headID, - Paths: []entity.SpeculationPathInfo{{Path: entity.SpeculationPath{Head: headID}}}, - } - _, err := New(store).Score(context.Background(), tree) - require.ErrorIs(t, err, sentinel) -} - -func TestScore_EmptyTreeSkipsStore(t *testing.T) { - ctrl := gomock.NewController(t) - store := storagemock.NewMockBatchStore(ctrl) - // No EXPECT() calls set up: the store must not be touched for an empty tree. - - tree := entity.SpeculationTree{BatchID: headID} - got, err := New(store).Score(context.Background(), tree) - require.NoError(t, err) - assert.Empty(t, got) -} diff --git a/submitqueue/extension/speculation/prioritizationlimit/BUILD.bazel b/submitqueue/extension/speculation/prioritizationlimit/BUILD.bazel deleted file mode 100644 index b777ccaf..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/BUILD.bazel +++ /dev/null @@ -1,8 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["prioritizationlimit.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit", - visibility = ["//visibility:public"], -) diff --git a/submitqueue/extension/speculation/prioritizationlimit/README.md b/submitqueue/extension/speculation/prioritizationlimit/README.md deleted file mode 100644 index 114a88fa..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Prioritization Limit - -Vendor-agnostic "how much" policy that bounds how many builds a queue may run at once — the queue's concurrent-build budget. - -See the [Speculation RFC](/doc/rfc/submitqueue/speculation.md) for the end-to-end design and how limits fit into the two-layer speculation model. - -## Prioritization Limit - -The prioritization limit is the [prioritizer](../prioritizer)'s companion. The prioritizer decides **which** of the queue's pending builds run — its ranking across all in-flight batches; the prioritization limit decides **how many** fit at once. It is the queue-wide resource knob, the ultimate cap on speculation's demand on CI. - -The value is **signal-driven**, not a fixed constant. Its primary input is the build system's available capacity, but a policy may also weigh cost budgets, time of day, or an experiment toggle. - -It is **injected into the prioritizer** at construction and called by it, never passed as a method parameter — following the repo's extension-contract pattern, keeping the prioritizer interface limit-free and stable, and letting the limit be swapped independently of prioritizer logic. - -## Factory - -A per-queue factory returns the limit policy for a queue, following the repo's extension contract. It is handed only the queue identity; the signals a policy weighs — a capacity feed, cost budgets, config — are injected at construction by the integrator in the wiring layer, which is also where the limit is handed to the prioritizer. Computing the limit itself takes no further inputs. diff --git a/submitqueue/extension/speculation/prioritizationlimit/fake/BUILD.bazel b/submitqueue/extension/speculation/prioritizationlimit/fake/BUILD.bazel deleted file mode 100644 index 1610a824..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/fake/BUILD.bazel +++ /dev/null @@ -1,19 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["fake.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit/fake", - visibility = ["//visibility:public"], - deps = ["//submitqueue/extension/speculation/prioritizationlimit:go_default_library"], -) - -go_test( - name = "go_default_test", - srcs = ["fake_test.go"], - embed = [":go_default_library"], - deps = [ - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/prioritizationlimit/fake/fake.go b/submitqueue/extension/speculation/prioritizationlimit/fake/fake.go deleted file mode 100644 index e8328f69..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/fake/fake.go +++ /dev/null @@ -1,53 +0,0 @@ -// 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 fake provides a programmable prioritizationlimit.PrioritizationLimit -// for tests and examples. New sets the value returned by Limit; FailWith injects -// an error on every call. It is intended for examples and tests only, never -// production. -package fake - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit" -) - -// PrioritizationLimit is a programmable prioritizationlimit.PrioritizationLimit. -type PrioritizationLimit struct { - limit int - err error -} - -// New returns a fake PrioritizationLimit whose Limit returns the given value. -func New(value int) *PrioritizationLimit { - return &PrioritizationLimit{limit: value} -} - -// FailWith makes every Limit call return err. -func (l *PrioritizationLimit) FailWith(err error) *PrioritizationLimit { - l.err = err - return l -} - -// Limit returns the configured value, or the injected error if FailWith was set. -func (l *PrioritizationLimit) Limit(_ context.Context) (int, error) { - if l.err != nil { - return 0, l.err - } - return l.limit, nil -} - -// ensure the fake satisfies the interface. -var _ prioritizationlimit.PrioritizationLimit = (*PrioritizationLimit)(nil) diff --git a/submitqueue/extension/speculation/prioritizationlimit/fake/fake_test.go b/submitqueue/extension/speculation/prioritizationlimit/fake/fake_test.go deleted file mode 100644 index f49fdcb0..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/fake/fake_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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 fake - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLimit_ReturnsConfiguredValue(t *testing.T) { - got, err := New(8).Limit(context.Background()) - require.NoError(t, err) - assert.Equal(t, 8, got) -} - -func TestLimit_FailWith(t *testing.T) { - sentinel := errors.New("boom") - _, err := New(8).FailWith(sentinel).Limit(context.Background()) - require.ErrorIs(t, err, sentinel) -} diff --git a/submitqueue/extension/speculation/prioritizationlimit/mock/BUILD.bazel b/submitqueue/extension/speculation/prioritizationlimit/mock/BUILD.bazel deleted file mode 100644 index cefd8ff5..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/mock/BUILD.bazel +++ /dev/null @@ -1,12 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["prioritizationlimit_mock.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit/mock", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/extension/speculation/prioritizationlimit:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/prioritizationlimit/mock/prioritizationlimit_mock.go b/submitqueue/extension/speculation/prioritizationlimit/mock/prioritizationlimit_mock.go deleted file mode 100644 index 277b8cf1..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/mock/prioritizationlimit_mock.go +++ /dev/null @@ -1,96 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: prioritizationlimit.go -// -// Generated by this command: -// -// mockgen -source=prioritizationlimit.go -destination=mock/prioritizationlimit_mock.go -package=mock -// - -// Package mock is a generated GoMock package. -package mock - -import ( - context "context" - reflect "reflect" - - prioritizationlimit "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit" - gomock "go.uber.org/mock/gomock" -) - -// MockPrioritizationLimit is a mock of PrioritizationLimit interface. -type MockPrioritizationLimit struct { - ctrl *gomock.Controller - recorder *MockPrioritizationLimitMockRecorder - isgomock struct{} -} - -// MockPrioritizationLimitMockRecorder is the mock recorder for MockPrioritizationLimit. -type MockPrioritizationLimitMockRecorder struct { - mock *MockPrioritizationLimit -} - -// NewMockPrioritizationLimit creates a new mock instance. -func NewMockPrioritizationLimit(ctrl *gomock.Controller) *MockPrioritizationLimit { - mock := &MockPrioritizationLimit{ctrl: ctrl} - mock.recorder = &MockPrioritizationLimitMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockPrioritizationLimit) EXPECT() *MockPrioritizationLimitMockRecorder { - return m.recorder -} - -// Limit mocks base method. -func (m *MockPrioritizationLimit) Limit(ctx context.Context) (int, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Limit", ctx) - ret0, _ := ret[0].(int) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Limit indicates an expected call of Limit. -func (mr *MockPrioritizationLimitMockRecorder) Limit(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Limit", reflect.TypeOf((*MockPrioritizationLimit)(nil).Limit), ctx) -} - -// 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 prioritizationlimit.Config) (prioritizationlimit.PrioritizationLimit, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "For", cfg) - ret0, _ := ret[0].(prioritizationlimit.PrioritizationLimit) - 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/prioritizationlimit/prioritizationlimit.go b/submitqueue/extension/speculation/prioritizationlimit/prioritizationlimit.go deleted file mode 100644 index 6022d51e..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/prioritizationlimit.go +++ /dev/null @@ -1,57 +0,0 @@ -// 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 prioritizationlimit - -//go:generate mockgen -source=prioritizationlimit.go -destination=mock/prioritizationlimit_mock.go -package=mock - -import "context" - -// PrioritizationLimit is the "how much" policy that bounds how many builds a -// queue may run at once — the queue's concurrent-build budget. -// -// It is the prioritizer's companion: the prioritizer decides *which* of the -// queue's pending builds run (its ranking across all in-flight batches); the -// prioritization limit decides *how many* fit at once. It is the queue-wide -// resource knob, the ultimate cap on speculation's demand on CI. -// -// The value is dynamic: it may change between calls, so the prioritizer reads it -// each round rather than caching it. -// -// It is injected into the prioritizer at construction and called by it, never -// passed as a method parameter, keeping the prioritizer interface limit-free and -// stable. -type PrioritizationLimit interface { - // Limit returns the current maximum number of concurrent builds for the - // queue. The prioritizer admits at most this many candidates. It takes no - // parameters; anything an implementation needs is injected at construction. - Limit(ctx context.Context) (int, error) -} - -// Config carries the per-queue identity handed to a Factory. The system knows -// only the queue name; everything a policy needs to compute the limit (a -// capacity feed, cost budgets, config) is injected at construction by the -// integrator. -type Config struct { - // QueueName identifies the queue this PrioritizationLimit serves. - QueueName string -} - -// Factory builds the PrioritizationLimit for a queue. Implementations are -// provided by integrators (and tests) and inject whatever signals they need at -// construction. -type Factory interface { - // For returns the PrioritizationLimit for the given queue. - For(cfg Config) (PrioritizationLimit, error) -} diff --git a/submitqueue/extension/speculation/prioritizationlimit/static/BUILD.bazel b/submitqueue/extension/speculation/prioritizationlimit/static/BUILD.bazel deleted file mode 100644 index ca20be37..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/static/BUILD.bazel +++ /dev/null @@ -1,19 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["static.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit/static", - visibility = ["//visibility:public"], - deps = ["//submitqueue/extension/speculation/prioritizationlimit:go_default_library"], -) - -go_test( - name = "go_default_test", - srcs = ["static_test.go"], - embed = [":go_default_library"], - deps = [ - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/prioritizationlimit/static/README.md b/submitqueue/extension/speculation/prioritizationlimit/static/README.md deleted file mode 100644 index 9316af92..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/static/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Static Prioritization Limit - -The static `prioritizationlimit.PrioritizationLimit` is constructed with a single integer and returns it, unchanged, from every call to `Limit`. It never errors and never consults any external signal — the simplest possible prioritization limit, useful for wiring tests, local development, and any queue whose concurrent-build budget is a fixed operational constant rather than one derived from live CI capacity or cost signals. diff --git a/submitqueue/extension/speculation/prioritizationlimit/static/static.go b/submitqueue/extension/speculation/prioritizationlimit/static/static.go deleted file mode 100644 index 8f634b7a..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/static/static.go +++ /dev/null @@ -1,42 +0,0 @@ -// 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 static provides a prioritizationlimit.PrioritizationLimit that -// always returns a fixed, construction-time value. -package static - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit" -) - -// staticLimit is a prioritizationlimit.PrioritizationLimit that always -// returns a fixed value. -type staticLimit struct { - // limit is the fixed concurrent-build budget returned by every call to - // Limit. - limit int -} - -// New returns a prioritizationlimit.PrioritizationLimit whose Limit always -// returns limit. -func New(limit int) prioritizationlimit.PrioritizationLimit { - return staticLimit{limit: limit} -} - -// Limit returns the fixed value given to New. It never errors. -func (l staticLimit) Limit(_ context.Context) (int, error) { - return l.limit, nil -} diff --git a/submitqueue/extension/speculation/prioritizationlimit/static/static_test.go b/submitqueue/extension/speculation/prioritizationlimit/static/static_test.go deleted file mode 100644 index 78ed2362..00000000 --- a/submitqueue/extension/speculation/prioritizationlimit/static/static_test.go +++ /dev/null @@ -1,35 +0,0 @@ -// 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 static - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLimit_ReturnsConfiguredValue(t *testing.T) { - got, err := New(5).Limit(context.Background()) - require.NoError(t, err) - assert.Equal(t, 5, got) -} - -func TestLimit_ZeroValue(t *testing.T) { - got, err := New(0).Limit(context.Background()) - require.NoError(t, err) - assert.Equal(t, 0, got) -} diff --git a/submitqueue/extension/speculation/prioritizer/BUILD.bazel b/submitqueue/extension/speculation/prioritizer/BUILD.bazel deleted file mode 100644 index f7ceb281..00000000 --- a/submitqueue/extension/speculation/prioritizer/BUILD.bazel +++ /dev/null @@ -1,9 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["prioritizer.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer", - visibility = ["//visibility:public"], - deps = ["//submitqueue/entity:go_default_library"], -) diff --git a/submitqueue/extension/speculation/prioritizer/README.md b/submitqueue/extension/speculation/prioritizer/README.md deleted file mode 100644 index 99e746ca..00000000 --- a/submitqueue/extension/speculation/prioritizer/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Speculation Prioritizer - -Vendor-agnostic interface for the queue-wide policy that rations a shared build budget across every in-flight batch in a queue. - -See the [Speculation RFC](/doc/rfc/submitqueue/speculation.md) for the end-to-end design and how prioritization fits into the orchestrator pipeline. - -## Prioritizer - -Selection is per batch and blind to other batches, so it cannot ration a shared budget: if every batch promoted generously, their combined demand could swamp CI. The prioritizer closes that gap. It sees every path across all of the queue's in-flight batches that is running or wants to run, ranks them by each path's score (plus any fairness or tie-break policy), and admits only the subset that fits the queue's concurrent-build budget. Selection expresses *desire* per batch; prioritization reconciles that desire against *supply* — it is the queue-wide enforcer. - -The controller hands the prioritizer the queue's candidate paths directly — every path that is `Selected` (wants a slot) or `Prioritized`/`Building` (holds a slot), each carrying its identity and score. It returns **sparse decisions**, each naming a path by its ID: `Promote` to admit a pending path, `Cancel` to preempt a running one. Paths it omits are left as-is, and it returns at most one decision per path. It never writes: the controller maps each decision back to its tree (it remembers which tree each candidate came from), maps it to a status transition (`Promote` → `Prioritized`, `Cancel` → `Cancelling`) applied under that tree's optimistic lock, and enacts it, staying the single writer. - -`Prioritized` means **admitted under the queue's build budget and cleared to build, but not yet building** — the path holds a slot and is waiting for the build stage to dispatch it and a build signal to confirm it (at which point it becomes `Building`). It is deliberately not called "admitted": batches are admitted at several points in the queue's lifecycle, while `Prioritized` names exactly the grant this seam makes. Contrast it with `Selected`, which is only the batch-level *desire* for a slot — a selected path has no claim on the budget until the prioritizer promotes it. - -**Whether to preempt is the prioritizer's own policy**, swappable per queue. A sticky-slots implementation never emits `Cancel` for a running path — it only fills free slots, and a higher-priority path waits until a slot frees. A preemptive implementation ranks running and pending paths together and may `Cancel` a running path to admit a higher-scored one. Both read the same input through the same interface; only the ranking/eviction logic differs. (Preemption discards in-flight CI work, so "fill free slots only" is a common default.) - -Prioritization is queue-wide — a different vantage point than the per-batch seams (enumerator, scorer, selector) — but it acts in the same currency: it ranks on the same path `Score` the scorer produces (a probability in [0, 1] under the [path scorer](../pathscorer)'s contract — the prioritizer needs no other assumption about the values, only that all paths in a queue share the scale) and emits the same `PathDecision` the selector does. Where the queue-wide reconcile runs in the pipeline is an integration detail; the contract here is unaffected by it. - -## Factory - -A per-queue factory returns the prioritizer for a queue, following the repo's extension contract. It is handed only the queue identity; the prioritization limit, fairness policy, and capacity signals are injected at construction by the integrator in the wiring layer, which resolves per-queue settings through `queueconfig`. Prioritization itself stays config-free. diff --git a/submitqueue/extension/speculation/prioritizer/fake/BUILD.bazel b/submitqueue/extension/speculation/prioritizer/fake/BUILD.bazel deleted file mode 100644 index 34481788..00000000 --- a/submitqueue/extension/speculation/prioritizer/fake/BUILD.bazel +++ /dev/null @@ -1,23 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["fake.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer/fake", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/prioritizer:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["fake_test.go"], - embed = [":go_default_library"], - deps = [ - "//submitqueue/entity:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/prioritizer/fake/fake.go b/submitqueue/extension/speculation/prioritizer/fake/fake.go deleted file mode 100644 index c4b1429d..00000000 --- a/submitqueue/extension/speculation/prioritizer/fake/fake.go +++ /dev/null @@ -1,61 +0,0 @@ -// 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 fake provides a programmable prioritizer.Prioritizer for tests and -// examples. It returns the decisions seeded via SetDecisions (none by default, -// i.e. leave every candidate as-is). FailWith injects an error on every call. It -// is intended for examples and tests only, never production. -package fake - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer" -) - -// Prioritizer is a programmable prioritizer.Prioritizer. -type Prioritizer struct { - decisions []entity.PathDecision - err error -} - -// New returns a fake Prioritizer that decides nothing (leaves every candidate -// as-is). Seed decisions with SetDecisions. -func New() *Prioritizer { - return &Prioritizer{} -} - -// SetDecisions seeds the decisions returned by Prioritize, in the order given. -func (p *Prioritizer) SetDecisions(decisions ...entity.PathDecision) *Prioritizer { - p.decisions = decisions - return p -} - -// FailWith makes every Prioritize call return err. -func (p *Prioritizer) FailWith(err error) *Prioritizer { - p.err = err - return p -} - -// Prioritize returns the seeded decisions. The candidates argument is ignored. -func (p *Prioritizer) Prioritize(_ context.Context, _ []entity.SpeculationPathInfo) ([]entity.PathDecision, error) { - if p.err != nil { - return nil, p.err - } - return p.decisions, nil -} - -// ensure the fake satisfies the interface. -var _ prioritizer.Prioritizer = (*Prioritizer)(nil) diff --git a/submitqueue/extension/speculation/prioritizer/fake/fake_test.go b/submitqueue/extension/speculation/prioritizer/fake/fake_test.go deleted file mode 100644 index 1e8bbc73..00000000 --- a/submitqueue/extension/speculation/prioritizer/fake/fake_test.go +++ /dev/null @@ -1,50 +0,0 @@ -// 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 fake - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber/submitqueue/submitqueue/entity" -) - -func TestPrioritize_DefaultDecidesNothing(t *testing.T) { - got, err := New().Prioritize(context.Background(), nil) - require.NoError(t, err) - assert.Empty(t, got) -} - -func TestPrioritize_ReturnsSeededDecisions(t *testing.T) { - want := []entity.PathDecision{ - {PathID: "q/batch/2/path/0", Action: entity.SpeculationPathActionPromote}, - {PathID: "q/batch/3/path/1", Action: entity.SpeculationPathActionCancel}, - } - candidates := []entity.SpeculationPathInfo{ - {ID: "q/batch/2/path/0", Path: entity.SpeculationPath{Head: "q/batch/2"}, Status: entity.SpeculationPathStatusSelected, Score: 0.9}, - } - got, err := New().SetDecisions(want...).Prioritize(context.Background(), candidates) - require.NoError(t, err) - assert.Equal(t, want, got) -} - -func TestPrioritize_FailWith(t *testing.T) { - sentinel := errors.New("boom") - _, err := New().FailWith(sentinel).Prioritize(context.Background(), nil) - require.ErrorIs(t, err, sentinel) -} diff --git a/submitqueue/extension/speculation/prioritizer/mock/BUILD.bazel b/submitqueue/extension/speculation/prioritizer/mock/BUILD.bazel deleted file mode 100644 index 10287a77..00000000 --- a/submitqueue/extension/speculation/prioritizer/mock/BUILD.bazel +++ /dev/null @@ -1,13 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["prioritizer_mock.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer/mock", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/prioritizer:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/prioritizer/mock/prioritizer_mock.go b/submitqueue/extension/speculation/prioritizer/mock/prioritizer_mock.go deleted file mode 100644 index 6fc716e2..00000000 --- a/submitqueue/extension/speculation/prioritizer/mock/prioritizer_mock.go +++ /dev/null @@ -1,97 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: prioritizer.go -// -// Generated by this command: -// -// mockgen -source=prioritizer.go -destination=mock/prioritizer_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" - prioritizer "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer" - gomock "go.uber.org/mock/gomock" -) - -// MockPrioritizer is a mock of Prioritizer interface. -type MockPrioritizer struct { - ctrl *gomock.Controller - recorder *MockPrioritizerMockRecorder - isgomock struct{} -} - -// MockPrioritizerMockRecorder is the mock recorder for MockPrioritizer. -type MockPrioritizerMockRecorder struct { - mock *MockPrioritizer -} - -// NewMockPrioritizer creates a new mock instance. -func NewMockPrioritizer(ctrl *gomock.Controller) *MockPrioritizer { - mock := &MockPrioritizer{ctrl: ctrl} - mock.recorder = &MockPrioritizerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockPrioritizer) EXPECT() *MockPrioritizerMockRecorder { - return m.recorder -} - -// Prioritize mocks base method. -func (m *MockPrioritizer) Prioritize(ctx context.Context, candidates []entity.SpeculationPathInfo) ([]entity.PathDecision, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Prioritize", ctx, candidates) - ret0, _ := ret[0].([]entity.PathDecision) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Prioritize indicates an expected call of Prioritize. -func (mr *MockPrioritizerMockRecorder) Prioritize(ctx, candidates any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Prioritize", reflect.TypeOf((*MockPrioritizer)(nil).Prioritize), ctx, candidates) -} - -// 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 prioritizer.Config) (prioritizer.Prioritizer, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "For", cfg) - ret0, _ := ret[0].(prioritizer.Prioritizer) - 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/prioritizer/prioritizer.go b/submitqueue/extension/speculation/prioritizer/prioritizer.go deleted file mode 100644 index 8d396ef4..00000000 --- a/submitqueue/extension/speculation/prioritizer/prioritizer.go +++ /dev/null @@ -1,74 +0,0 @@ -// 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 prioritizer - -//go:generate mockgen -source=prioritizer.go -destination=mock/prioritizer_mock.go -package=mock - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" -) - -// Prioritizer is the queue-wide policy that rations a shared build budget across -// every in-flight batch in a queue. -// -// Selection is per batch and blind to other batches, so it cannot ration a -// shared budget: if every batch promoted generously, their combined demand could -// swamp CI. Prioritization closes that gap. It sees every path across all of the -// queue's in-flight batches that is running or wants to run, ranks them (by each -// path's score, plus any fairness or tie-break policy), and admits only the -// subset that fits the queue's concurrent-build budget. Selection expresses -// desire per batch; prioritization reconciles that desire against supply. It is -// constructed with its prioritization limit and applies it itself. -// -// The controller hands it the queue's candidate paths directly — every path that -// is Selected (wants a slot) or Prioritized/Building (holds a slot), each -// carrying its identity (ID) and score. The prioritizer returns sparse -// decisions, each naming a path by that ID: Promote to admit a pending path, -// Cancel to preempt a running one; paths it omits are left as-is, and it -// returns at most one decision per path (the controller treats conflicting -// duplicates as a policy bug and skips them). Whether it preempts at all is -// its own policy — a sticky-slots implementation -// never emits Cancel for a running path and only fills free slots, while a -// preemptive one ranks running and pending together and may Cancel a running -// path to admit a higher-scored one. It never writes: the controller maps each -// decision to a status transition (Promote → Prioritized, Cancel → Cancelling), -// applied per tree under that tree's optimistic lock, and enacts it, staying -// the single writer. -type Prioritizer interface { - // Prioritize ranks the queue's candidate paths and returns the decisions to - // apply: Promote for paths admitted to run now, Cancel for running paths it - // chooses to preempt. Omitted paths are left as-is. Ranking is by score — - // a probability in [0, 1] per the path scorer's contract — plus any - // fairness policy, bounded by the prioritization limit. - Prioritize(ctx context.Context, candidates []entity.SpeculationPathInfo) ([]entity.PathDecision, error) -} - -// Config carries the per-queue identity handed to a Factory. The system knows -// only the queue name; everything an implementation needs (its prioritization -// limit, fairness policy, capacity signals) is injected at construction by the -// integrator. -type Config struct { - // QueueName identifies the queue this Prioritizer serves. - QueueName string -} - -// Factory builds the Prioritizer for a queue. Implementations are provided by -// integrators (and tests) and inject whatever they need at construction. -type Factory interface { - // For returns the Prioritizer for the given queue. - For(cfg Config) (Prioritizer, error) -} diff --git a/submitqueue/extension/speculation/prioritizer/sticky/BUILD.bazel b/submitqueue/extension/speculation/prioritizer/sticky/BUILD.bazel deleted file mode 100644 index 6d70c364..00000000 --- a/submitqueue/extension/speculation/prioritizer/sticky/BUILD.bazel +++ /dev/null @@ -1,25 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["sticky.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer/sticky", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/prioritizationlimit:go_default_library", - "//submitqueue/extension/speculation/prioritizer:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["sticky_test.go"], - embed = [":go_default_library"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/prioritizationlimit/fake:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/prioritizer/sticky/README.md b/submitqueue/extension/speculation/prioritizer/sticky/README.md deleted file mode 100644 index 059ea1ca..00000000 --- a/submitqueue/extension/speculation/prioritizer/sticky/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Sticky Prioritizer - -The sticky `prioritizer.Prioritizer` implements sticky build slots: every candidate path that is already `Prioritized` or `Building` holds a slot it keeps until it resolves. It counts those, subtracts the count from the queue's current prioritization limit to get the free budget, and floors the result at zero rather than going negative when the queue is already over budget. It then ranks every `Selected` candidate — the ones asking for a slot — by descending `Score`, and admits as many as the free budget allows with a `Promote` decision each, in ranked order. Ties on `Score` are broken deterministically, first by the path's `Head` batch ID and then by its joined `Base`, so repeated rounds over the same input always order the same way. - -Candidates in any other status, and any `Selected` candidate beyond the free budget, are simply omitted — sticky prioritization never emits `Cancel`. This makes it the least disruptive prioritization policy: a lower-scored path that is already running is never evicted to make room for a newer, higher-scored one; the newer path instead waits until a slot frees up naturally. It is constructed with its `prioritizationlimit.PrioritizationLimit`, consulting it fresh on every call since the budget can change between rounds. diff --git a/submitqueue/extension/speculation/prioritizer/sticky/sticky.go b/submitqueue/extension/speculation/prioritizer/sticky/sticky.go deleted file mode 100644 index 1fd285fa..00000000 --- a/submitqueue/extension/speculation/prioritizer/sticky/sticky.go +++ /dev/null @@ -1,101 +0,0 @@ -// 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 sticky provides a prioritizer.Prioritizer implementing sticky -// build slots: a running path keeps its slot until it resolves, and only the -// budget left over is handed out to pending candidates. It never preempts a -// running path to admit a higher-scored one. -package sticky - -import ( - "context" - "sort" - "strings" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit" - "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer" -) - -// stickyPrioritizer is a prioritizer.Prioritizer that never emits Cancel: it -// only fills whatever budget running paths have not already claimed. -type stickyPrioritizer struct { - // limit reports the queue's current concurrent-build budget. It is - // consulted on every Prioritize call rather than cached, since the value - // may change between calls. - limit prioritizationlimit.PrioritizationLimit -} - -// New returns a prioritizer.Prioritizer implementing sticky build slots: it -// never emits Cancel, and admits Selected candidates by descending Score into -// whatever budget limit leaves free once running paths are accounted for. -func New(limit prioritizationlimit.PrioritizationLimit) prioritizer.Prioritizer { - return &stickyPrioritizer{limit: limit} -} - -// Prioritize counts candidates already holding a slot (Prioritized or -// Building) against the queue's prioritization limit to compute the -// remaining free budget, then admits the free budget's worth of Selected -// candidates, highest Score first, with a Promote decision (naming the path by -// its ID) each. Ties break -// on Path.Head, then on the base joined with commas, so repeated rounds over -// the same input are stable. Candidates in any other status, and any Selected -// candidate beyond the free budget, are left as-is: sticky prioritization -// never cancels a running path. -func (p *stickyPrioritizer) Prioritize(ctx context.Context, candidates []entity.SpeculationPathInfo) ([]entity.PathDecision, error) { - used := 0 - var pending []entity.SpeculationPathInfo - for _, c := range candidates { - switch c.Status { - case entity.SpeculationPathStatusPrioritized, entity.SpeculationPathStatusBuilding: - used++ - case entity.SpeculationPathStatusSelected: - pending = append(pending, c) - } - } - - limit, err := p.limit.Limit(ctx) - if err != nil { - return nil, err - } - free := limit - used - if free < 0 { - free = 0 - } - if free == 0 || len(pending) == 0 { - return nil, nil - } - - sort.SliceStable(pending, func(i, j int) bool { - if pending[i].Score != pending[j].Score { - return pending[i].Score > pending[j].Score - } - if pending[i].Path.Head != pending[j].Path.Head { - return pending[i].Path.Head < pending[j].Path.Head - } - return strings.Join(pending[i].Path.Base, ",") < strings.Join(pending[j].Path.Base, ",") - }) - - if free > len(pending) { - free = len(pending) - } - decisions := make([]entity.PathDecision, free) - for i := 0; i < free; i++ { - decisions[i] = entity.PathDecision{ - PathID: pending[i].ID, - Action: entity.SpeculationPathActionPromote, - } - } - return decisions, nil -} diff --git a/submitqueue/extension/speculation/prioritizer/sticky/sticky_test.go b/submitqueue/extension/speculation/prioritizer/sticky/sticky_test.go deleted file mode 100644 index ef839a0b..00000000 --- a/submitqueue/extension/speculation/prioritizer/sticky/sticky_test.go +++ /dev/null @@ -1,107 +0,0 @@ -// 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 sticky - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber/submitqueue/submitqueue/entity" - limitfake "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizationlimit/fake" -) - -func path(head string, base ...string) entity.SpeculationPath { - return entity.SpeculationPath{Base: base, Head: head} -} - -func TestPrioritize_AdmitsTopScoredSelectedIntoFreeSlots(t *testing.T) { - low := entity.SpeculationPathInfo{ID: "p/low", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusSelected, Score: 0.4} - high := entity.SpeculationPathInfo{ID: "p/high", Path: path("q/batch/2"), Status: entity.SpeculationPathStatusSelected, Score: 0.9} - mid := entity.SpeculationPathInfo{ID: "p/mid", Path: path("q/batch/3"), Status: entity.SpeculationPathStatusSelected, Score: 0.6} - - p := New(limitfake.New(2)) - got, err := p.Prioritize(context.Background(), []entity.SpeculationPathInfo{low, high, mid}) - require.NoError(t, err) - assert.Equal(t, []entity.PathDecision{ - {PathID: high.ID, Action: entity.SpeculationPathActionPromote}, - {PathID: mid.ID, Action: entity.SpeculationPathActionPromote}, - }, got) -} - -func TestPrioritize_RunningPathsCountAgainstBudget(t *testing.T) { - building := entity.SpeculationPathInfo{ID: "p/building", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusBuilding, Score: 0.99} - prioritized := entity.SpeculationPathInfo{ID: "p/prioritized", Path: path("q/batch/2"), Status: entity.SpeculationPathStatusPrioritized, Score: 0.99} - pending := entity.SpeculationPathInfo{ID: "p/pending", Path: path("q/batch/3"), Status: entity.SpeculationPathStatusSelected, Score: 0.5} - - // limit 3, 2 already running -> exactly 1 free slot for the pending candidate. - p := New(limitfake.New(3)) - got, err := p.Prioritize(context.Background(), []entity.SpeculationPathInfo{building, prioritized, pending}) - require.NoError(t, err) - assert.Equal(t, []entity.PathDecision{ - {PathID: pending.ID, Action: entity.SpeculationPathActionPromote}, - }, got) -} - -func TestPrioritize_ZeroFreeYieldsNoDecisions(t *testing.T) { - building := entity.SpeculationPathInfo{ID: "p/building", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusBuilding, Score: 0.9} - pending := entity.SpeculationPathInfo{ID: "p/pending", Path: path("q/batch/2"), Status: entity.SpeculationPathStatusSelected, Score: 0.9} - - p := New(limitfake.New(1)) - got, err := p.Prioritize(context.Background(), []entity.SpeculationPathInfo{building, pending}) - require.NoError(t, err) - assert.Empty(t, got) - - // Also verify no Selected candidates at all yields no decisions. - got, err = p.Prioritize(context.Background(), []entity.SpeculationPathInfo{building}) - require.NoError(t, err) - assert.Empty(t, got) -} - -func TestPrioritize_TieBreakIsDeterministic(t *testing.T) { - a := entity.SpeculationPathInfo{ID: "p/a", Path: path("q/batch/2"), Status: entity.SpeculationPathStatusSelected, Score: 0.5} - b := entity.SpeculationPathInfo{ID: "p/b", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusSelected, Score: 0.5} - c := entity.SpeculationPathInfo{ID: "p/c", Path: path("q/batch/1", "q/batch/0"), Status: entity.SpeculationPathStatusSelected, Score: 0.5} - - p := New(limitfake.New(3)) - - // Same score across all three: ties break on Head, then on Base. Running - // this repeatedly with inputs in different orders must always produce the - // same output order. - for _, in := range [][]entity.SpeculationPathInfo{ - {a, b, c}, - {c, b, a}, - {b, c, a}, - } { - got, err := p.Prioritize(context.Background(), in) - require.NoError(t, err) - assert.Equal(t, []entity.PathDecision{ - {PathID: b.ID, Action: entity.SpeculationPathActionPromote}, // q/batch/1, base [] - {PathID: c.ID, Action: entity.SpeculationPathActionPromote}, // q/batch/1, base [q/batch/0] - {PathID: a.ID, Action: entity.SpeculationPathActionPromote}, // q/batch/2 - }, got) - } -} - -func TestPrioritize_LimitErrorPropagates(t *testing.T) { - sentinel := errors.New("boom") - p := New(limitfake.New(3).FailWith(sentinel)) - _, err := p.Prioritize(context.Background(), []entity.SpeculationPathInfo{ - {ID: "p/1", Path: path("q/batch/1"), Status: entity.SpeculationPathStatusSelected, Score: 0.5}, - }) - require.ErrorIs(t, err, sentinel) -} diff --git a/submitqueue/extension/speculation/selectionlimit/BUILD.bazel b/submitqueue/extension/speculation/selectionlimit/BUILD.bazel deleted file mode 100644 index 8dcc050e..00000000 --- a/submitqueue/extension/speculation/selectionlimit/BUILD.bazel +++ /dev/null @@ -1,8 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["selectionlimit.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit", - visibility = ["//visibility:public"], -) diff --git a/submitqueue/extension/speculation/selectionlimit/README.md b/submitqueue/extension/speculation/selectionlimit/README.md deleted file mode 100644 index 30ff9e9f..00000000 --- a/submitqueue/extension/speculation/selectionlimit/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# Speculation Selection Limit - -Vendor-agnostic "how much" policy that bounds how many paths a batch may build in parallel. - -See the [Speculation RFC](/doc/rfc/submitqueue/speculation.md) for the end-to-end design and how limits fit into the two-layer speculation model. - -## Selection Limit - -The selection limit is the [selector](../selector)'s companion. The selector decides **which** of a batch's paths are worth building — its ranking over the tree; the selection limit decides **how many** of them may run at once. Keeping "which" and "how much" separate keeps selector logic free of resource accounting and lets the bound scale with build resources without touching that logic. - -The value is **signal-driven**, not a fixed constant. Its primary input is the build system's available capacity, but a policy may also weigh historical pass rates, cost budgets, time of day, or an experiment toggle. - -Unlike the dependency limit — which the controller holds and applies as an eligibility gate — the selection limit is **injected into the seam that uses it**: the selector is constructed with it and calls it itself, never receiving it as a method parameter. This follows the repo's extension-contract pattern (dependencies injected at the `Factory`), keeps the selector interface limit-free and stable, and lets the limit be swapped independently of selector logic. - -## Factory - -A per-queue factory returns the limit policy for a queue, following the repo's extension contract. It is handed only the queue identity; the signals a policy weighs — a capacity feed, historical metrics, config — are injected at construction by the integrator in the wiring layer, which is also where the limit is handed to the selector. Computing the limit itself takes no further inputs. diff --git a/submitqueue/extension/speculation/selectionlimit/fake/BUILD.bazel b/submitqueue/extension/speculation/selectionlimit/fake/BUILD.bazel deleted file mode 100644 index cf5ff1ec..00000000 --- a/submitqueue/extension/speculation/selectionlimit/fake/BUILD.bazel +++ /dev/null @@ -1,19 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["fake.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit/fake", - visibility = ["//visibility:public"], - deps = ["//submitqueue/extension/speculation/selectionlimit:go_default_library"], -) - -go_test( - name = "go_default_test", - srcs = ["fake_test.go"], - embed = [":go_default_library"], - deps = [ - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/selectionlimit/fake/fake.go b/submitqueue/extension/speculation/selectionlimit/fake/fake.go deleted file mode 100644 index 4311f831..00000000 --- a/submitqueue/extension/speculation/selectionlimit/fake/fake.go +++ /dev/null @@ -1,52 +0,0 @@ -// 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 fake provides a programmable selectionlimit.SelectionLimit for tests -// and examples. New sets the value returned by Limit; FailWith injects an error -// on every call. It is intended for examples and tests only, never production. -package fake - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit" -) - -// SelectionLimit is a programmable selectionlimit.SelectionLimit. -type SelectionLimit struct { - limit int - err error -} - -// New returns a fake SelectionLimit whose Limit returns the given value. -func New(limit int) *SelectionLimit { - return &SelectionLimit{limit: limit} -} - -// FailWith makes every Limit call return err. -func (l *SelectionLimit) FailWith(err error) *SelectionLimit { - l.err = err - return l -} - -// Limit returns the configured value, or the injected error if FailWith was set. -func (l *SelectionLimit) Limit(_ context.Context) (int, error) { - if l.err != nil { - return 0, l.err - } - return l.limit, nil -} - -// ensure the fake satisfies the interface. -var _ selectionlimit.SelectionLimit = (*SelectionLimit)(nil) diff --git a/submitqueue/extension/speculation/selectionlimit/fake/fake_test.go b/submitqueue/extension/speculation/selectionlimit/fake/fake_test.go deleted file mode 100644 index a69497b1..00000000 --- a/submitqueue/extension/speculation/selectionlimit/fake/fake_test.go +++ /dev/null @@ -1,36 +0,0 @@ -// 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 fake - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestLimit_ReturnsConfiguredValue(t *testing.T) { - got, err := New(2).Limit(context.Background()) - require.NoError(t, err) - assert.Equal(t, 2, got) -} - -func TestLimit_FailWith(t *testing.T) { - sentinel := errors.New("boom") - _, err := New(2).FailWith(sentinel).Limit(context.Background()) - require.ErrorIs(t, err, sentinel) -} diff --git a/submitqueue/extension/speculation/selectionlimit/mock/BUILD.bazel b/submitqueue/extension/speculation/selectionlimit/mock/BUILD.bazel deleted file mode 100644 index 7d9fceec..00000000 --- a/submitqueue/extension/speculation/selectionlimit/mock/BUILD.bazel +++ /dev/null @@ -1,12 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["selectionlimit_mock.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit/mock", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/extension/speculation/selectionlimit:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/selectionlimit/mock/selectionlimit_mock.go b/submitqueue/extension/speculation/selectionlimit/mock/selectionlimit_mock.go deleted file mode 100644 index f4cbbf14..00000000 --- a/submitqueue/extension/speculation/selectionlimit/mock/selectionlimit_mock.go +++ /dev/null @@ -1,96 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: selectionlimit.go -// -// Generated by this command: -// -// mockgen -source=selectionlimit.go -destination=mock/selectionlimit_mock.go -package=mock -// - -// Package mock is a generated GoMock package. -package mock - -import ( - context "context" - reflect "reflect" - - selectionlimit "github.com/uber/submitqueue/submitqueue/extension/speculation/selectionlimit" - gomock "go.uber.org/mock/gomock" -) - -// MockSelectionLimit is a mock of SelectionLimit interface. -type MockSelectionLimit struct { - ctrl *gomock.Controller - recorder *MockSelectionLimitMockRecorder - isgomock struct{} -} - -// MockSelectionLimitMockRecorder is the mock recorder for MockSelectionLimit. -type MockSelectionLimitMockRecorder struct { - mock *MockSelectionLimit -} - -// NewMockSelectionLimit creates a new mock instance. -func NewMockSelectionLimit(ctrl *gomock.Controller) *MockSelectionLimit { - mock := &MockSelectionLimit{ctrl: ctrl} - mock.recorder = &MockSelectionLimitMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSelectionLimit) EXPECT() *MockSelectionLimitMockRecorder { - return m.recorder -} - -// Limit mocks base method. -func (m *MockSelectionLimit) Limit(ctx context.Context) (int, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Limit", ctx) - ret0, _ := ret[0].(int) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Limit indicates an expected call of Limit. -func (mr *MockSelectionLimitMockRecorder) Limit(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Limit", reflect.TypeOf((*MockSelectionLimit)(nil).Limit), ctx) -} - -// 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 selectionlimit.Config) (selectionlimit.SelectionLimit, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "For", cfg) - ret0, _ := ret[0].(selectionlimit.SelectionLimit) - 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/selectionlimit/selectionlimit.go b/submitqueue/extension/speculation/selectionlimit/selectionlimit.go deleted file mode 100644 index 68cfdcd0..00000000 --- a/submitqueue/extension/speculation/selectionlimit/selectionlimit.go +++ /dev/null @@ -1,57 +0,0 @@ -// 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 selectionlimit - -//go:generate mockgen -source=selectionlimit.go -destination=mock/selectionlimit_mock.go -package=mock - -import "context" - -// SelectionLimit is the "how much" policy that bounds how many paths a batch may -// build in parallel. -// -// It is the selector's companion: the selector decides *which* of a batch's -// paths are worth building (its ranking); the selection limit decides *how many* -// of them may run at once. Separating the two keeps selector logic free of -// resource accounting and lets the bound scale with build resources without -// touching that logic. -// -// The value is dynamic: it may change between calls, so the selector reads it -// each pass rather than caching it. -// -// Unlike the dependency limit, this limit is injected into the seam that uses it -// — the selector is constructed with it and calls it itself — never passed as a -// method parameter, keeping the selector interface limit-free and stable. -type SelectionLimit interface { - // Limit returns the current maximum number of paths a batch may build in - // parallel. The selector caps its Promote decisions at this. It takes no - // parameters; anything an implementation needs is injected at construction. - Limit(ctx context.Context) (int, error) -} - -// Config carries the per-queue identity handed to a Factory. The system knows -// only the queue name; everything a policy needs to compute the limit (a -// capacity feed, historical metrics, config) is injected at construction by the -// integrator. -type Config struct { - // QueueName identifies the queue this SelectionLimit serves. - QueueName string -} - -// Factory builds the SelectionLimit for a queue. Implementations are provided by -// integrators (and tests) and inject whatever signals they need at construction. -type Factory interface { - // For returns the SelectionLimit for the given queue. - For(cfg Config) (SelectionLimit, error) -} diff --git a/submitqueue/extension/speculation/selector/BUILD.bazel b/submitqueue/extension/speculation/selector/BUILD.bazel deleted file mode 100644 index aca92b3d..00000000 --- a/submitqueue/extension/speculation/selector/BUILD.bazel +++ /dev/null @@ -1,9 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["selector.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector", - visibility = ["//visibility:public"], - deps = ["//submitqueue/entity:go_default_library"], -) diff --git a/submitqueue/extension/speculation/selector/README.md b/submitqueue/extension/speculation/selector/README.md deleted file mode 100644 index ee558ec0..00000000 --- a/submitqueue/extension/speculation/selector/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# 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 the batch's tree and their current status, what should we do with each, right now?* It returns an **action** per path — `Promote` (advance it one stage toward running) 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 controller hands the selector the batch's **speculation tree** directly — the subject it decides over. The controller is the single writer: it reconciles each path's status (candidate, selected, prioritized, building, passed, failed, cancelling, cancelled) from the latest builds and dependency states, and it maps each of the selector's decisions to a status transition — `Promote` → `Selected`, `Cancel` → `Cancelling` (or `Cancelled` when nothing is building) — applied under the tree's optimistic lock and persisted. The selector's only output is decisions, each naming a path by its ID, at most one per path; it **never** writes status. This keeps it a deterministic policy over the tree it is given. - -Selection expresses **desire, not admission**: `Selected` means this batch wants to spend a build slot on the path, while `Prioritized` means the queue-wide prioritizer has actually admitted it under the shared build budget and it is cleared to build. A promoted path waits in `Selected` until the prioritizer clears it — with free budget it would move on promptly, under contention it may wait indefinitely or be dropped. That split exists because a selector sees only its own batch and cannot ration a budget shared across the whole queue. - -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 - -A per-queue factory returns the selector for a queue, following the repo's extension contract. It is handed only the queue identity and 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`. Selection itself stays config-free. diff --git a/submitqueue/extension/speculation/selector/all/BUILD.bazel b/submitqueue/extension/speculation/selector/all/BUILD.bazel deleted file mode 100644 index bb478718..00000000 --- a/submitqueue/extension/speculation/selector/all/BUILD.bazel +++ /dev/null @@ -1,23 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["all.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector/all", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/selector:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["all_test.go"], - embed = [":go_default_library"], - deps = [ - "//submitqueue/entity:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/selector/all/README.md b/submitqueue/extension/speculation/selector/all/README.md deleted file mode 100644 index 7f95beb3..00000000 --- a/submitqueue/extension/speculation/selector/all/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# All Selector - -The all `selector.Selector` scans a batch's speculation tree and returns a Promote decision for every path currently in the Candidate status, in tree order — it builds every candidate path, maximum parallelism at maximum build cost. It has no opinion on any path in another status — Selected, Prioritized, Building, or a terminal status — and simply omits those, leaving them as-is for the controller. If the tree has no candidate paths, it returns no decisions. - -This is the least discriminating selection policy: it never narrows the candidate set, so it always asks to build every path the enumerator produced for a batch. It is the baseline and the counterpart to the queue-wide prioritizer, which is where an actual budget is enforced — the all selector expresses maximal per-batch desire, and the prioritizer (see the [prioritizer](../../prioritizer) extension, such as its `sticky` implementation) reconciles that desire against the queue's shared build capacity. diff --git a/submitqueue/extension/speculation/selector/all/all.go b/submitqueue/extension/speculation/selector/all/all.go deleted file mode 100644 index b81073ab..00000000 --- a/submitqueue/extension/speculation/selector/all/all.go +++ /dev/null @@ -1,52 +0,0 @@ -// 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 all provides a selector.Selector that promotes every Candidate -// path in a batch's speculation tree — maximum parallelism, maximum build -// cost. It is a baseline policy: build every candidate the enumerator -// produced rather than picking a subset. -package all - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/selector" -) - -// allSelector is a selector.Selector that promotes every Candidate path. -type allSelector struct{} - -// New returns a selector.Selector that promotes every Candidate path and -// leaves every other path as-is. -func New() selector.Selector { - return allSelector{} -} - -// Select returns a Promote decision, in tree order, for every path whose -// Status is Candidate, each naming its path by ID. Paths in any other status -// are omitted. -func (allSelector) Select(_ context.Context, tree entity.SpeculationTree) ([]entity.PathDecision, error) { - var decisions []entity.PathDecision - for _, p := range tree.Paths { - if p.Status != entity.SpeculationPathStatusCandidate { - continue - } - decisions = append(decisions, entity.PathDecision{ - PathID: p.ID, - Action: entity.SpeculationPathActionPromote, - }) - } - return decisions, nil -} diff --git a/submitqueue/extension/speculation/selector/all/all_test.go b/submitqueue/extension/speculation/selector/all/all_test.go deleted file mode 100644 index 76ba93b9..00000000 --- a/submitqueue/extension/speculation/selector/all/all_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// 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 all - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber/submitqueue/submitqueue/entity" -) - -func TestSelect(t *testing.T) { - candidatePath := entity.SpeculationPath{Head: "q/batch/1"} - selectedPath := entity.SpeculationPath{Base: []string{"q/batch/0"}, Head: "q/batch/1"} - passedPath := entity.SpeculationPath{Head: "q/batch/2"} - - tests := []struct { - name string - paths []entity.SpeculationPathInfo - want []entity.PathDecision - }{ - { - name: "mixed status tree promotes only candidates", - paths: []entity.SpeculationPathInfo{ - {ID: "p/candidate", Path: candidatePath, Status: entity.SpeculationPathStatusCandidate}, - {ID: "p/selected", Path: selectedPath, Status: entity.SpeculationPathStatusSelected}, - {ID: "p/passed", Path: passedPath, Status: entity.SpeculationPathStatusPassed}, - }, - want: []entity.PathDecision{ - {PathID: "p/candidate", Action: entity.SpeculationPathActionPromote}, - }, - }, - { - name: "no candidates yields no decisions", - paths: []entity.SpeculationPathInfo{ - {ID: "p/selected", Path: selectedPath, Status: entity.SpeculationPathStatusSelected}, - {ID: "p/passed", Path: passedPath, Status: entity.SpeculationPathStatusPassed}, - }, - want: nil, - }, - } - - s := New() - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tree := entity.SpeculationTree{BatchID: "q/batch/1", Paths: tt.paths} - got, err := s.Select(context.Background(), tree) - require.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } -} diff --git a/submitqueue/extension/speculation/selector/fake/BUILD.bazel b/submitqueue/extension/speculation/selector/fake/BUILD.bazel deleted file mode 100644 index 7013e840..00000000 --- a/submitqueue/extension/speculation/selector/fake/BUILD.bazel +++ /dev/null @@ -1,23 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["fake.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector/fake", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/selector:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["fake_test.go"], - embed = [":go_default_library"], - deps = [ - "//submitqueue/entity:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/selector/fake/fake.go b/submitqueue/extension/speculation/selector/fake/fake.go deleted file mode 100644 index 30adc241..00000000 --- a/submitqueue/extension/speculation/selector/fake/fake.go +++ /dev/null @@ -1,61 +0,0 @@ -// 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 fake provides a programmable selector.Selector for tests and examples. -// It returns the decisions seeded via SetDecisions (none by default, i.e. leave -// every path as-is). FailWith injects an error on every call. It is intended for -// examples and tests only, never production. -package fake - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/selector" -) - -// Selector is a programmable selector.Selector. -type Selector struct { - decisions []entity.PathDecision - err error -} - -// New returns a fake Selector that decides nothing (leaves every path as-is). -// Seed decisions with SetDecisions. -func New() *Selector { - return &Selector{} -} - -// SetDecisions seeds the decisions returned by Select. -func (s *Selector) SetDecisions(decisions ...entity.PathDecision) *Selector { - s.decisions = decisions - return s -} - -// FailWith makes every Select call return err. -func (s *Selector) FailWith(err error) *Selector { - s.err = err - return s -} - -// Select returns the seeded decisions. The tree argument is ignored. -func (s *Selector) Select(_ context.Context, _ entity.SpeculationTree) ([]entity.PathDecision, error) { - if s.err != nil { - return nil, s.err - } - return s.decisions, nil -} - -// ensure the fake satisfies the interface. -var _ selector.Selector = (*Selector)(nil) diff --git a/submitqueue/extension/speculation/selector/fake/fake_test.go b/submitqueue/extension/speculation/selector/fake/fake_test.go deleted file mode 100644 index 8545dd85..00000000 --- a/submitqueue/extension/speculation/selector/fake/fake_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// 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 fake - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber/submitqueue/submitqueue/entity" -) - -func TestSelect_DefaultDecidesNothing(t *testing.T) { - got, err := New().Select(context.Background(), entity.SpeculationTree{BatchID: "q/batch/2"}) - require.NoError(t, err) - assert.Empty(t, got) -} - -func TestSelect_ReturnsSeededDecisions(t *testing.T) { - want := []entity.PathDecision{ - {PathID: "q/batch/2/path/0", Action: entity.SpeculationPathActionPromote}, - } - got, err := New().SetDecisions(want...).Select(context.Background(), entity.SpeculationTree{BatchID: "q/batch/2"}) - require.NoError(t, err) - assert.Equal(t, want, got) -} - -func TestSelect_FailWith(t *testing.T) { - sentinel := errors.New("boom") - _, err := New().FailWith(sentinel).Select(context.Background(), entity.SpeculationTree{BatchID: "q/batch/2"}) - require.ErrorIs(t, err, sentinel) -} diff --git a/submitqueue/extension/speculation/selector/mock/BUILD.bazel b/submitqueue/extension/speculation/selector/mock/BUILD.bazel deleted file mode 100644 index e31a192b..00000000 --- a/submitqueue/extension/speculation/selector/mock/BUILD.bazel +++ /dev/null @@ -1,13 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library") - -go_library( - name = "go_default_library", - srcs = ["selector_mock.go"], - importpath = "github.com/uber/submitqueue/submitqueue/extension/speculation/selector/mock", - visibility = ["//visibility:public"], - deps = [ - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/selector:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - ], -) diff --git a/submitqueue/extension/speculation/selector/mock/selector_mock.go b/submitqueue/extension/speculation/selector/mock/selector_mock.go deleted file mode 100644 index b3b88c30..00000000 --- a/submitqueue/extension/speculation/selector/mock/selector_mock.go +++ /dev/null @@ -1,97 +0,0 @@ -// 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.PathDecision, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Select", ctx, tree) - ret0, _ := ret[0].([]entity.PathDecision) - 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 deleted file mode 100644 index fec06ef0..00000000 --- a/submitqueue/extension/speculation/selector/selector.go +++ /dev/null @@ -1,67 +0,0 @@ -// 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 batch's tree — each path's -// controller-stamped Status (Candidate / Selected / Prioritized / Building / -// Passed / Failed / Cancelling / Cancelled) and Score — and returns a decision -// per path it wants to act on. -// -// The controller hands the selector the batch's speculation tree directly — the -// subject it decides over. Its only output is decisions (Promote / Cancel), -// each naming a path by its ID (entity.SpeculationPathInfo.ID); it never -// writes Status. The controller maps each decision to a status -// transition — Promote → Selected, Cancel → Cancelling (or Cancelled) — applied -// under the tree's optimistic lock, and persists it, staying the single writer -// of tree state. This keeps the selector a deterministic policy over the tree it -// is given. 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 decisions to take for the given tree, at most one per - // path (the controller treats conflicting duplicates as a policy bug and - // skips them). Returning multiple Promote decisions selects several paths - // for building in parallel — each waits for the prioritizer to clear it - // under the queue's build budget before a build is dispatched. 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.PathDecision, error) -} - -// Config carries the per-queue identity handed to a Factory. The system knows -// only the queue name; policy knobs such as a top-K cap or build budget are -// 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/BUILD.bazel b/submitqueue/extension/storage/BUILD.bazel index d2081a00..7a8d4993 100644 --- a/submitqueue/extension/storage/BUILD.bazel +++ b/submitqueue/extension/storage/BUILD.bazel @@ -12,8 +12,6 @@ go_library( "request_store.go", "request_summary_store.go", "request_uri_store.go", - "speculation_path_build_store.go", - "speculation_tree_store.go", "storage.go", ], importpath = "github.com/uber/submitqueue/submitqueue/extension/storage", diff --git a/submitqueue/extension/storage/mock/BUILD.bazel b/submitqueue/extension/storage/mock/BUILD.bazel index 4b2f4f51..546c0848 100644 --- a/submitqueue/extension/storage/mock/BUILD.bazel +++ b/submitqueue/extension/storage/mock/BUILD.bazel @@ -12,8 +12,6 @@ go_library( "request_store_mock.go", "request_summary_store_mock.go", "request_uri_store_mock.go", - "speculation_path_build_store_mock.go", - "speculation_tree_store_mock.go", "storage_mock.go", ], importpath = "github.com/uber/submitqueue/submitqueue/extension/storage/mock", diff --git a/submitqueue/extension/storage/mock/speculation_path_build_store_mock.go b/submitqueue/extension/storage/mock/speculation_path_build_store_mock.go deleted file mode 100644 index 91e200db..00000000 --- a/submitqueue/extension/storage/mock/speculation_path_build_store_mock.go +++ /dev/null @@ -1,71 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: speculation_path_build_store.go -// -// Generated by this command: -// -// mockgen -source=speculation_path_build_store.go -destination=mock/speculation_path_build_store_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" - gomock "go.uber.org/mock/gomock" -) - -// MockSpeculationPathBuildStore is a mock of SpeculationPathBuildStore interface. -type MockSpeculationPathBuildStore struct { - ctrl *gomock.Controller - recorder *MockSpeculationPathBuildStoreMockRecorder - isgomock struct{} -} - -// MockSpeculationPathBuildStoreMockRecorder is the mock recorder for MockSpeculationPathBuildStore. -type MockSpeculationPathBuildStoreMockRecorder struct { - mock *MockSpeculationPathBuildStore -} - -// NewMockSpeculationPathBuildStore creates a new mock instance. -func NewMockSpeculationPathBuildStore(ctrl *gomock.Controller) *MockSpeculationPathBuildStore { - mock := &MockSpeculationPathBuildStore{ctrl: ctrl} - mock.recorder = &MockSpeculationPathBuildStoreMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSpeculationPathBuildStore) EXPECT() *MockSpeculationPathBuildStoreMockRecorder { - return m.recorder -} - -// Create mocks base method. -func (m *MockSpeculationPathBuildStore) Create(ctx context.Context, pathBuild entity.SpeculationPathBuild) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, pathBuild) - ret0, _ := ret[0].(error) - return ret0 -} - -// Create indicates an expected call of Create. -func (mr *MockSpeculationPathBuildStoreMockRecorder) Create(ctx, pathBuild any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSpeculationPathBuildStore)(nil).Create), ctx, pathBuild) -} - -// Get mocks base method. -func (m *MockSpeculationPathBuildStore) Get(ctx context.Context, pathID string) (entity.SpeculationPathBuild, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", ctx, pathID) - ret0, _ := ret[0].(entity.SpeculationPathBuild) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Get indicates an expected call of Get. -func (mr *MockSpeculationPathBuildStoreMockRecorder) Get(ctx, pathID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSpeculationPathBuildStore)(nil).Get), ctx, pathID) -} diff --git a/submitqueue/extension/storage/mock/speculation_tree_store_mock.go b/submitqueue/extension/storage/mock/speculation_tree_store_mock.go deleted file mode 100644 index 99240061..00000000 --- a/submitqueue/extension/storage/mock/speculation_tree_store_mock.go +++ /dev/null @@ -1,85 +0,0 @@ -// Code generated by MockGen. DO NOT EDIT. -// Source: speculation_tree_store.go -// -// Generated by this command: -// -// mockgen -source=speculation_tree_store.go -destination=mock/speculation_tree_store_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" - gomock "go.uber.org/mock/gomock" -) - -// MockSpeculationTreeStore is a mock of SpeculationTreeStore interface. -type MockSpeculationTreeStore struct { - ctrl *gomock.Controller - recorder *MockSpeculationTreeStoreMockRecorder - isgomock struct{} -} - -// MockSpeculationTreeStoreMockRecorder is the mock recorder for MockSpeculationTreeStore. -type MockSpeculationTreeStoreMockRecorder struct { - mock *MockSpeculationTreeStore -} - -// NewMockSpeculationTreeStore creates a new mock instance. -func NewMockSpeculationTreeStore(ctrl *gomock.Controller) *MockSpeculationTreeStore { - mock := &MockSpeculationTreeStore{ctrl: ctrl} - mock.recorder = &MockSpeculationTreeStoreMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockSpeculationTreeStore) EXPECT() *MockSpeculationTreeStoreMockRecorder { - return m.recorder -} - -// Create mocks base method. -func (m *MockSpeculationTreeStore) Create(ctx context.Context, speculationTree entity.SpeculationTree) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, speculationTree) - ret0, _ := ret[0].(error) - return ret0 -} - -// Create indicates an expected call of Create. -func (mr *MockSpeculationTreeStoreMockRecorder) Create(ctx, speculationTree any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockSpeculationTreeStore)(nil).Create), ctx, speculationTree) -} - -// Get mocks base method. -func (m *MockSpeculationTreeStore) Get(ctx context.Context, batchID string) (entity.SpeculationTree, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Get", ctx, batchID) - ret0, _ := ret[0].(entity.SpeculationTree) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// Get indicates an expected call of Get. -func (mr *MockSpeculationTreeStoreMockRecorder) Get(ctx, batchID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSpeculationTreeStore)(nil).Get), ctx, batchID) -} - -// Update mocks base method. -func (m *MockSpeculationTreeStore) Update(ctx context.Context, batchID string, oldVersion, newVersion int32, paths []entity.SpeculationPathInfo) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Update", ctx, batchID, oldVersion, newVersion, paths) - ret0, _ := ret[0].(error) - return ret0 -} - -// Update indicates an expected call of Update. -func (mr *MockSpeculationTreeStoreMockRecorder) Update(ctx, batchID, oldVersion, newVersion, paths any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockSpeculationTreeStore)(nil).Update), ctx, batchID, oldVersion, newVersion, paths) -} diff --git a/submitqueue/extension/storage/mock/storage_mock.go b/submitqueue/extension/storage/mock/storage_mock.go index 6655716f..3bd7789c 100644 --- a/submitqueue/extension/storage/mock/storage_mock.go +++ b/submitqueue/extension/storage/mock/storage_mock.go @@ -152,20 +152,6 @@ func (mr *MockStorageMockRecorder) GetRequestStore() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRequestStore", reflect.TypeOf((*MockStorage)(nil).GetRequestStore)) } -// GetSpeculationPathBuildStore mocks base method. -func (m *MockStorage) GetSpeculationPathBuildStore() storage.SpeculationPathBuildStore { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSpeculationPathBuildStore") - ret0, _ := ret[0].(storage.SpeculationPathBuildStore) - return ret0 -} - -// GetSpeculationPathBuildStore indicates an expected call of GetSpeculationPathBuildStore. -func (mr *MockStorageMockRecorder) GetSpeculationPathBuildStore() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSpeculationPathBuildStore", reflect.TypeOf((*MockStorage)(nil).GetSpeculationPathBuildStore)) -} - // GetRequestSummaryStore mocks base method. func (m *MockStorage) GetRequestSummaryStore() storage.RequestSummaryStore { m.ctrl.T.Helper() @@ -193,17 +179,3 @@ func (mr *MockStorageMockRecorder) GetRequestURIStore() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRequestURIStore", reflect.TypeOf((*MockStorage)(nil).GetRequestURIStore)) } - -// GetSpeculationTreeStore mocks base method. -func (m *MockStorage) GetSpeculationTreeStore() storage.SpeculationTreeStore { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetSpeculationTreeStore") - ret0, _ := ret[0].(storage.SpeculationTreeStore) - return ret0 -} - -// GetSpeculationTreeStore indicates an expected call of GetSpeculationTreeStore. -func (mr *MockStorageMockRecorder) GetSpeculationTreeStore() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSpeculationTreeStore", reflect.TypeOf((*MockStorage)(nil).GetSpeculationTreeStore)) -} diff --git a/submitqueue/extension/storage/mysql/BUILD.bazel b/submitqueue/extension/storage/mysql/BUILD.bazel index 1cbbf031..aac0822b 100644 --- a/submitqueue/extension/storage/mysql/BUILD.bazel +++ b/submitqueue/extension/storage/mysql/BUILD.bazel @@ -12,8 +12,6 @@ go_library( "request_store.go", "request_summary_store.go", "request_uri_store.go", - "speculation_path_build_store.go", - "speculation_tree_store.go", "storage.go", ], importpath = "github.com/uber/submitqueue/submitqueue/extension/storage/mysql", @@ -39,8 +37,6 @@ go_test( "request_store_test.go", "request_summary_store_test.go", "request_uri_store_test.go", - "speculation_path_build_store_test.go", - "speculation_tree_store_test.go", "storage_test.go", ], embed = [":go_default_library"], diff --git a/submitqueue/extension/storage/mysql/build_store.go b/submitqueue/extension/storage/mysql/build_store.go index cfcf8d99..6d1b0184 100644 --- a/submitqueue/extension/storage/mysql/build_store.go +++ b/submitqueue/extension/storage/mysql/build_store.go @@ -46,9 +46,9 @@ func (s *buildStore) Get(ctx context.Context, id string) (ret entity.Build, retE var build entity.Build err := s.db.QueryRowContext(ctx, - "SELECT id, batch_id, status, speculation_path_id FROM build WHERE id = ?", + "SELECT id, batch_id, status FROM build WHERE id = ?", id, - ).Scan(&build.ID, &build.BatchID, &build.Status, &build.SpeculationPathID) + ).Scan(&build.ID, &build.BatchID, &build.Status) if errors.Is(err, sql.ErrNoRows) { return entity.Build{}, storage.WrapNotFound(err) @@ -66,8 +66,8 @@ func (s *buildStore) Create(ctx context.Context, build entity.Build) (retErr err defer func() { op.Complete(retErr) }() _, err := s.db.ExecContext(ctx, - "INSERT INTO build (id, batch_id, status, speculation_path_id) VALUES (?, ?, ?, ?)", - build.ID, build.BatchID, build.Status, build.SpeculationPathID, + "INSERT INTO build (id, batch_id, status) VALUES (?, ?, ?)", + build.ID, build.BatchID, build.Status, ) if err != nil { var mysqlErr *mysql.MySQLError diff --git a/submitqueue/extension/storage/mysql/build_store_test.go b/submitqueue/extension/storage/mysql/build_store_test.go index 540ae8c0..c89db986 100644 --- a/submitqueue/extension/storage/mysql/build_store_test.go +++ b/submitqueue/extension/storage/mysql/build_store_test.go @@ -42,10 +42,9 @@ func setupBuildStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.BuildS func TestBuildStore_Get(t *testing.T) { want := entity.Build{ - ID: "bk-1001", - BatchID: "monorepo/batch/1", - Status: entity.BuildStatusRunning, - SpeculationPathID: "path-1", + ID: "bk-1001", + BatchID: "monorepo/batch/1", + Status: entity.BuildStatusRunning, } tests := []struct { @@ -60,9 +59,9 @@ func TestBuildStore_Get(t *testing.T) { name: "found", id: want.ID, setup: func(mock sqlmock.Sqlmock) { - rows := sqlmock.NewRows([]string{"id", "batch_id", "status", "speculation_path_id"}). - AddRow(want.ID, want.BatchID, string(want.Status), want.SpeculationPathID) - mock.ExpectQuery("SELECT id, batch_id, status, speculation_path_id"). + rows := sqlmock.NewRows([]string{"id", "batch_id", "status"}). + AddRow(want.ID, want.BatchID, string(want.Status)) + mock.ExpectQuery("SELECT id, batch_id, status"). WithArgs(want.ID). WillReturnRows(rows) }, @@ -72,7 +71,7 @@ func TestBuildStore_Get(t *testing.T) { name: "not found", id: "missing", setup: func(mock sqlmock.Sqlmock) { - mock.ExpectQuery("SELECT id, batch_id, status, speculation_path_id"). + mock.ExpectQuery("SELECT id, batch_id, status"). WithArgs("missing"). WillReturnError(sql.ErrNoRows) }, @@ -83,7 +82,7 @@ func TestBuildStore_Get(t *testing.T) { name: "query error", id: "bad", setup: func(mock sqlmock.Sqlmock) { - mock.ExpectQuery("SELECT id, batch_id, status, speculation_path_id"). + mock.ExpectQuery("SELECT id, batch_id, status"). WithArgs("bad"). WillReturnError(fmt.Errorf("connection reset")) }, @@ -115,10 +114,9 @@ func TestBuildStore_Get(t *testing.T) { func TestBuildStore_Create(t *testing.T) { build := entity.Build{ - ID: "bk-1001", - BatchID: "monorepo/batch/1", - Status: entity.BuildStatusAccepted, - SpeculationPathID: "path-1", + ID: "bk-1001", + BatchID: "monorepo/batch/1", + Status: entity.BuildStatusAccepted, } tests := []struct { @@ -131,7 +129,7 @@ func TestBuildStore_Create(t *testing.T) { name: "success", setup: func(mock sqlmock.Sqlmock) { mock.ExpectExec("INSERT INTO build"). - WithArgs(build.ID, build.BatchID, build.Status, build.SpeculationPathID). + WithArgs(build.ID, build.BatchID, build.Status). WillReturnResult(sqlmock.NewResult(0, 1)) }, }, @@ -139,7 +137,7 @@ func TestBuildStore_Create(t *testing.T) { name: "duplicate id returns ErrAlreadyExists", setup: func(mock sqlmock.Sqlmock) { mock.ExpectExec("INSERT INTO build"). - WithArgs(build.ID, build.BatchID, build.Status, build.SpeculationPathID). + WithArgs(build.ID, build.BatchID, build.Status). WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) }, wantErr: true, @@ -149,7 +147,7 @@ func TestBuildStore_Create(t *testing.T) { name: "other exec error", setup: func(mock sqlmock.Sqlmock) { mock.ExpectExec("INSERT INTO build"). - WithArgs(build.ID, build.BatchID, build.Status, build.SpeculationPathID). + WithArgs(build.ID, build.BatchID, build.Status). WillReturnError(fmt.Errorf("connection reset")) }, wantErr: true, diff --git a/submitqueue/extension/storage/mysql/schema/build.sql b/submitqueue/extension/storage/mysql/schema/build.sql index 946e45e4..f720397f 100644 --- a/submitqueue/extension/storage/mysql/schema/build.sql +++ b/submitqueue/extension/storage/mysql/schema/build.sql @@ -2,6 +2,5 @@ CREATE TABLE IF NOT EXISTS build ( id VARCHAR(255) NOT NULL, batch_id VARCHAR(255) NOT NULL, status VARCHAR(64) NOT NULL, - speculation_path_id VARCHAR(255) NOT NULL, PRIMARY KEY (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/submitqueue/extension/storage/mysql/schema/speculation_path_build.sql b/submitqueue/extension/storage/mysql/schema/speculation_path_build.sql deleted file mode 100644 index b4a6e817..00000000 --- a/submitqueue/extension/storage/mysql/schema/speculation_path_build.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE IF NOT EXISTS speculation_path_build ( - path_id VARCHAR(255) NOT NULL, - build_id VARCHAR(255) NOT NULL, - batch_id VARCHAR(255) NOT NULL, - version INT NOT NULL, - created_at BIGINT NOT NULL, - PRIMARY KEY (path_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/submitqueue/extension/storage/mysql/schema/speculation_tree.sql b/submitqueue/extension/storage/mysql/schema/speculation_tree.sql deleted file mode 100644 index d96b6625..00000000 --- a/submitqueue/extension/storage/mysql/schema/speculation_tree.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS speculation_tree ( - batch_id VARCHAR(255) NOT NULL, - paths JSON NOT NULL, - version INT NOT NULL, - PRIMARY KEY (batch_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/submitqueue/extension/storage/mysql/speculation_path_build_store.go b/submitqueue/extension/storage/mysql/speculation_path_build_store.go deleted file mode 100644 index edb9d7e8..00000000 --- a/submitqueue/extension/storage/mysql/speculation_path_build_store.go +++ /dev/null @@ -1,84 +0,0 @@ -// 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 mysql - -import ( - "context" - "database/sql" - "errors" - "fmt" - - "github.com/go-sql-driver/mysql" - "github.com/uber-go/tally" - - "github.com/uber/submitqueue/platform/metrics" - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/storage" -) - -type speculationPathBuildStore struct { - db *sql.DB - scope tally.Scope -} - -// NewSpeculationPathBuildStore creates a new MySQL-backed SpeculationPathBuildStore. -func NewSpeculationPathBuildStore(db *sql.DB, scope tally.Scope) storage.SpeculationPathBuildStore { - return &speculationPathBuildStore{db: db, scope: scope} -} - -// Create creates a new path->build mapping. Returns ErrAlreadyExists if a -// mapping for the given PathID already exists. The caller-supplied Version is -// persisted as-is; version arithmetic is owned by the controller, not the store. -func (s *speculationPathBuildStore) Create(ctx context.Context, pathBuild entity.SpeculationPathBuild) (retErr error) { - op := metrics.Begin(s.scope, "create") - defer func() { op.Complete(retErr) }() - - _, err := s.db.ExecContext(ctx, - "INSERT INTO speculation_path_build (path_id, build_id, batch_id, version, created_at) VALUES (?, ?, ?, ?, ?)", - pathBuild.PathID, pathBuild.BuildID, pathBuild.BatchID, pathBuild.Version, pathBuild.CreatedAt, - ) - if err != nil { - var mysqlErr *mysql.MySQLError - if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 { - return fmt.Errorf("path build mapping pathID=%s: %w", pathBuild.PathID, storage.ErrAlreadyExists) - } - return fmt.Errorf("failed to insert path build mapping pathID=%s: %w", pathBuild.PathID, err) - } - - return nil -} - -// Get retrieves the path->build mapping for the given path ID. Returns -// ErrNotFound if no mapping exists for pathID. -func (s *speculationPathBuildStore) Get(ctx context.Context, pathID string) (ret entity.SpeculationPathBuild, retErr error) { - op := metrics.Begin(s.scope, "get") - defer func() { op.Complete(retErr) }() - - var pb entity.SpeculationPathBuild - - err := s.db.QueryRowContext(ctx, - "SELECT path_id, build_id, batch_id, version, created_at FROM speculation_path_build WHERE path_id = ?", - pathID, - ).Scan(&pb.PathID, &pb.BuildID, &pb.BatchID, &pb.Version, &pb.CreatedAt) - - if errors.Is(err, sql.ErrNoRows) { - return entity.SpeculationPathBuild{}, storage.WrapNotFound(err) - } - if err != nil { - return entity.SpeculationPathBuild{}, fmt.Errorf("failed to get path build mapping pathID=%s from the database: %w", pathID, err) - } - - return pb, nil -} diff --git a/submitqueue/extension/storage/mysql/speculation_path_build_store_test.go b/submitqueue/extension/storage/mysql/speculation_path_build_store_test.go deleted file mode 100644 index f9911250..00000000 --- a/submitqueue/extension/storage/mysql/speculation_path_build_store_test.go +++ /dev/null @@ -1,180 +0,0 @@ -// 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 mysql - -import ( - "context" - "database/sql" - "fmt" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/go-sql-driver/mysql" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/storage" -) - -func setupSpeculationPathBuildStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.SpeculationPathBuildStore) { - t.Helper() - - db, mock, err := sqlmock.New() - require.NoError(t, err) - - store := NewSpeculationPathBuildStore(db, testMetrics()) - - return db, mock, store -} - -func TestSpeculationPathBuildStore_Create(t *testing.T) { - pathBuild := entity.SpeculationPathBuild{ - PathID: "path-1", - BuildID: "bk-1001", - BatchID: "monorepo/batch/1", - CreatedAt: 1000, - Version: 1, - } - - tests := []struct { - name string - setup func(mock sqlmock.Sqlmock) - wantErr bool - wantErrIs error - }{ - { - name: "success", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("INSERT INTO speculation_path_build"). - WithArgs(pathBuild.PathID, pathBuild.BuildID, pathBuild.BatchID, pathBuild.Version, pathBuild.CreatedAt). - WillReturnResult(sqlmock.NewResult(0, 1)) - }, - }, - { - name: "duplicate path id returns ErrAlreadyExists", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("INSERT INTO speculation_path_build"). - WithArgs(pathBuild.PathID, pathBuild.BuildID, pathBuild.BatchID, pathBuild.Version, pathBuild.CreatedAt). - WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) - }, - wantErr: true, - wantErrIs: storage.ErrAlreadyExists, - }, - { - name: "other exec error", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("INSERT INTO speculation_path_build"). - WithArgs(pathBuild.PathID, pathBuild.BuildID, pathBuild.BatchID, pathBuild.Version, pathBuild.CreatedAt). - WillReturnError(fmt.Errorf("connection reset")) - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - db, mock, store := setupSpeculationPathBuildStoreTest(t) - defer db.Close() - - tt.setup(mock) - - err := store.Create(context.Background(), pathBuild) - if tt.wantErr { - require.Error(t, err) - if tt.wantErrIs != nil { - assert.ErrorIs(t, err, tt.wantErrIs) - } - } else { - require.NoError(t, err) - } - require.NoError(t, mock.ExpectationsWereMet()) - }) - } -} - -func TestSpeculationPathBuildStore_Get(t *testing.T) { - want := entity.SpeculationPathBuild{ - PathID: "path-1", - BuildID: "bk-1001", - BatchID: "monorepo/batch/1", - CreatedAt: 1000, - Version: 1, - } - - tests := []struct { - name string - pathID string - setup func(mock sqlmock.Sqlmock) - want entity.SpeculationPathBuild - wantErr bool - wantErrIs error - }{ - { - name: "found", - pathID: want.PathID, - setup: func(mock sqlmock.Sqlmock) { - rows := sqlmock.NewRows([]string{"path_id", "build_id", "batch_id", "version", "created_at"}). - AddRow(want.PathID, want.BuildID, want.BatchID, want.Version, want.CreatedAt) - mock.ExpectQuery("SELECT path_id, build_id, batch_id, version, created_at"). - WithArgs(want.PathID). - WillReturnRows(rows) - }, - want: want, - }, - { - name: "not found", - pathID: "missing", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectQuery("SELECT path_id, build_id, batch_id, version, created_at"). - WithArgs("missing"). - WillReturnError(sql.ErrNoRows) - }, - wantErr: true, - wantErrIs: storage.ErrNotFound, - }, - { - name: "query error", - pathID: "bad", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectQuery("SELECT path_id, build_id, batch_id, version, created_at"). - WithArgs("bad"). - WillReturnError(fmt.Errorf("connection reset")) - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - db, mock, store := setupSpeculationPathBuildStoreTest(t) - defer db.Close() - - tt.setup(mock) - - got, err := store.Get(context.Background(), tt.pathID) - if tt.wantErr { - require.Error(t, err) - if tt.wantErrIs != nil { - assert.ErrorIs(t, err, tt.wantErrIs) - } - } else { - require.NoError(t, err) - assert.Equal(t, tt.want, got) - } - require.NoError(t, mock.ExpectationsWereMet()) - }) - } -} diff --git a/submitqueue/extension/storage/mysql/speculation_tree_store.go b/submitqueue/extension/storage/mysql/speculation_tree_store.go deleted file mode 100644 index 3245eef5..00000000 --- a/submitqueue/extension/storage/mysql/speculation_tree_store.go +++ /dev/null @@ -1,135 +0,0 @@ -// 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 mysql - -import ( - "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - - "github.com/go-sql-driver/mysql" - "github.com/uber-go/tally" - - "github.com/uber/submitqueue/platform/metrics" - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/storage" -) - -type speculationTreeStore struct { - db *sql.DB - scope tally.Scope -} - -// NewSpeculationTreeStore creates a new MySQL-backed SpeculationTreeStore. -func NewSpeculationTreeStore(db *sql.DB, scope tally.Scope) storage.SpeculationTreeStore { - return &speculationTreeStore{db: db, scope: scope} -} - -// Get retrieves the speculation tree by batch ID. Returns ErrNotFound if the speculation tree is not found. -func (s *speculationTreeStore) Get(ctx context.Context, batchID string) (ret entity.SpeculationTree, retErr error) { - op := metrics.Begin(s.scope, "get") - defer func() { op.Complete(retErr) }() - - var st entity.SpeculationTree - var pathsJSON []byte - - err := s.db.QueryRowContext(ctx, - "SELECT batch_id, paths, version FROM speculation_tree WHERE batch_id = ?", - batchID, - ).Scan(&st.BatchID, &pathsJSON, &st.Version) - - if errors.Is(err, sql.ErrNoRows) { - return entity.SpeculationTree{}, storage.WrapNotFound(err) - } - if err != nil { - return entity.SpeculationTree{}, fmt.Errorf("failed to get speculation tree entity batchID=%s from the database: %w", batchID, err) - } - - if err := json.Unmarshal(pathsJSON, &st.Paths); err != nil { - return entity.SpeculationTree{}, fmt.Errorf("failed to unmarshal paths for speculation tree entity batchID=%s from the database: %w", batchID, err) - } - - return st, nil -} - -// Create creates a new speculation tree. Returns ErrAlreadyExists if the entry already exists. -func (s *speculationTreeStore) Create(ctx context.Context, speculationTree entity.SpeculationTree) (retErr error) { - op := metrics.Begin(s.scope, "create") - defer func() { op.Complete(retErr) }() - - pathsJSON, err := json.Marshal(speculationTree.Paths) - if err != nil { - return fmt.Errorf("failed to marshal paths batchID=%s for Create speculation tree entity: %w", speculationTree.BatchID, err) - } - - _, err = s.db.ExecContext(ctx, - "INSERT INTO speculation_tree (batch_id, paths, version) VALUES (?, ?, ?)", - speculationTree.BatchID, pathsJSON, speculationTree.Version, - ) - if err != nil { - var mysqlErr *mysql.MySQLError - if errors.As(err, &mysqlErr) && mysqlErr.Number == mysqlErrDuplicateEntry { - return fmt.Errorf("speculation tree entity batchID=%s: %w", speculationTree.BatchID, storage.ErrAlreadyExists) - } - return fmt.Errorf("failed to insert speculation tree entity batchID=%s: %w", speculationTree.BatchID, err) - } - - return nil -} - -// Update overwrites the paths of an existing speculation tree and sets its -// version to newVersion if the current persisted version matches oldVersion. -// If versions do not match (or no tree exists for batchID), returns -// ErrVersionMismatch. Version arithmetic is owned by the caller; this is a -// pure conditional write. -func (s *speculationTreeStore) Update(ctx context.Context, batchID string, oldVersion, newVersion int32, paths []entity.SpeculationPathInfo) (retErr error) { - op := metrics.Begin(s.scope, "update") - defer func() { op.Complete(retErr) }() - - pathsJSON, err := json.Marshal(paths) - if err != nil { - return fmt.Errorf("failed to marshal paths batchID=%s for Update: %w", batchID, err) - } - - result, err := s.db.ExecContext(ctx, - "UPDATE speculation_tree SET paths = ?, version = ? WHERE batch_id = ? AND version = ?", - pathsJSON, newVersion, batchID, oldVersion, - ) - if err != nil { - return fmt.Errorf("failed to update speculation tree for batchID=%q oldVersion=%d newVersion=%d: %w", batchID, oldVersion, newVersion, err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get rows affected from update for batchID=%q: %w", batchID, err) - } - - switch rowsAffected { - case 1: - return nil - case 0: - return fmt.Errorf( - "version mismatch for speculation tree update: batchID=%q expected_version=%d: %w", - batchID, oldVersion, storage.ErrVersionMismatch, - ) - default: - return fmt.Errorf( - "unexpected rows affected %d for speculation tree update batchID=%q", - rowsAffected, batchID, - ) - } -} diff --git a/submitqueue/extension/storage/mysql/speculation_tree_store_test.go b/submitqueue/extension/storage/mysql/speculation_tree_store_test.go deleted file mode 100644 index 23c88610..00000000 --- a/submitqueue/extension/storage/mysql/speculation_tree_store_test.go +++ /dev/null @@ -1,276 +0,0 @@ -// 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 mysql - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "testing" - - "github.com/DATA-DOG/go-sqlmock" - "github.com/go-sql-driver/mysql" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/storage" -) - -func setupSpeculationTreeStoreTest(t *testing.T) (*sql.DB, sqlmock.Sqlmock, storage.SpeculationTreeStore) { - t.Helper() - - db, mock, err := sqlmock.New() - require.NoError(t, err) - - store := NewSpeculationTreeStore(db, testMetrics()) - - return db, mock, store -} - -func TestSpeculationTreeStore_Get(t *testing.T) { - want := entity.SpeculationTree{ - BatchID: "monorepo/batch/1", - Paths: []entity.SpeculationPathInfo{ - {ID: "path-1", Path: entity.SpeculationPath{Head: "monorepo/batch/1"}, Status: entity.SpeculationPathStatusCandidate}, - }, - Version: 1, - } - pathsJSON, err := json.Marshal(want.Paths) - require.NoError(t, err) - - tests := []struct { - name string - batchID string - setup func(mock sqlmock.Sqlmock) - want entity.SpeculationTree - wantErr bool - wantErrIs error - }{ - { - name: "found", - batchID: want.BatchID, - setup: func(mock sqlmock.Sqlmock) { - rows := sqlmock.NewRows([]string{"batch_id", "paths", "version"}). - AddRow(want.BatchID, pathsJSON, want.Version) - mock.ExpectQuery("SELECT batch_id, paths, version FROM speculation_tree"). - WithArgs(want.BatchID). - WillReturnRows(rows) - }, - want: want, - }, - { - name: "not found", - batchID: "missing", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectQuery("SELECT batch_id, paths, version FROM speculation_tree"). - WithArgs("missing"). - WillReturnError(sql.ErrNoRows) - }, - wantErr: true, - wantErrIs: storage.ErrNotFound, - }, - { - name: "query error", - batchID: "bad", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectQuery("SELECT batch_id, paths, version FROM speculation_tree"). - WithArgs("bad"). - WillReturnError(fmt.Errorf("connection reset")) - }, - wantErr: true, - }, - { - name: "malformed paths JSON", - batchID: "malformed", - setup: func(mock sqlmock.Sqlmock) { - rows := sqlmock.NewRows([]string{"batch_id", "paths", "version"}). - AddRow(want.BatchID, []byte("not json"), want.Version) - mock.ExpectQuery("SELECT batch_id, paths, version FROM speculation_tree"). - WithArgs("malformed"). - WillReturnRows(rows) - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - db, mock, store := setupSpeculationTreeStoreTest(t) - defer db.Close() - - tt.setup(mock) - - got, err := store.Get(context.Background(), tt.batchID) - if tt.wantErr { - require.Error(t, err) - if tt.wantErrIs != nil { - assert.ErrorIs(t, err, tt.wantErrIs) - } - } else { - require.NoError(t, err) - assert.Equal(t, tt.want, got) - } - require.NoError(t, mock.ExpectationsWereMet()) - }) - } -} - -func TestSpeculationTreeStore_Create(t *testing.T) { - tree := entity.SpeculationTree{ - BatchID: "monorepo/batch/1", - Paths: []entity.SpeculationPathInfo{ - {ID: "path-1", Path: entity.SpeculationPath{Head: "monorepo/batch/1"}, Status: entity.SpeculationPathStatusCandidate}, - }, - Version: 1, - } - - tests := []struct { - name string - setup func(mock sqlmock.Sqlmock) - wantErr bool - wantErrIs error - }{ - { - name: "success", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("INSERT INTO speculation_tree"). - WithArgs(tree.BatchID, sqlmock.AnyArg(), tree.Version). - WillReturnResult(sqlmock.NewResult(0, 1)) - }, - }, - { - name: "duplicate batch id returns ErrAlreadyExists", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("INSERT INTO speculation_tree"). - WithArgs(tree.BatchID, sqlmock.AnyArg(), tree.Version). - WillReturnError(&mysql.MySQLError{Number: mysqlErrDuplicateEntry}) - }, - wantErr: true, - wantErrIs: storage.ErrAlreadyExists, - }, - { - name: "other exec error", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("INSERT INTO speculation_tree"). - WithArgs(tree.BatchID, sqlmock.AnyArg(), tree.Version). - WillReturnError(fmt.Errorf("connection reset")) - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - db, mock, store := setupSpeculationTreeStoreTest(t) - defer db.Close() - - tt.setup(mock) - - err := store.Create(context.Background(), tree) - if tt.wantErr { - require.Error(t, err) - if tt.wantErrIs != nil { - assert.ErrorIs(t, err, tt.wantErrIs) - } - } else { - require.NoError(t, err) - } - require.NoError(t, mock.ExpectationsWereMet()) - }) - } -} - -func TestSpeculationTreeStore_Update(t *testing.T) { - const batchID = "monorepo/batch/1" - const oldVersion, newVersion = int32(1), int32(2) - paths := []entity.SpeculationPathInfo{ - {ID: "path-1", Path: entity.SpeculationPath{Head: batchID}, Status: entity.SpeculationPathStatusCandidate}, - } - - tests := []struct { - name string - setup func(mock sqlmock.Sqlmock) - wantErr bool - wantErrIs error - }{ - { - name: "success", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("UPDATE speculation_tree"). - WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). - WillReturnResult(sqlmock.NewResult(0, 1)) - }, - }, - { - name: "version mismatch", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("UPDATE speculation_tree"). - WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). - WillReturnResult(sqlmock.NewResult(0, 0)) - }, - wantErr: true, - wantErrIs: storage.ErrVersionMismatch, - }, - { - name: "exec error", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("UPDATE speculation_tree"). - WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). - WillReturnError(fmt.Errorf("connection reset")) - }, - wantErr: true, - }, - { - name: "rows affected error", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("UPDATE speculation_tree"). - WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). - WillReturnResult(sqlmock.NewErrorResult(fmt.Errorf("driver error"))) - }, - wantErr: true, - }, - { - name: "unexpected rows affected", - setup: func(mock sqlmock.Sqlmock) { - mock.ExpectExec("UPDATE speculation_tree"). - WithArgs(sqlmock.AnyArg(), newVersion, batchID, oldVersion). - WillReturnResult(sqlmock.NewResult(0, 2)) - }, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - db, mock, store := setupSpeculationTreeStoreTest(t) - defer db.Close() - - tt.setup(mock) - - err := store.Update(context.Background(), batchID, oldVersion, newVersion, paths) - if tt.wantErr { - require.Error(t, err) - if tt.wantErrIs != nil { - assert.ErrorIs(t, err, tt.wantErrIs) - } - } else { - require.NoError(t, err) - } - require.NoError(t, mock.ExpectationsWereMet()) - }) - } -} diff --git a/submitqueue/extension/storage/mysql/storage.go b/submitqueue/extension/storage/mysql/storage.go index ffcf4d02..f810f827 100644 --- a/submitqueue/extension/storage/mysql/storage.go +++ b/submitqueue/extension/storage/mysql/storage.go @@ -28,35 +28,31 @@ import ( const mysqlErrDuplicateEntry = 1062 type mysqlStorage struct { - db *sql.DB - requestStore storage.RequestStore - changeStore storage.ChangeStore - batchStore storage.BatchStore - batchDependentStore storage.BatchDependentStore - buildStore storage.BuildStore - speculationPathBuildStore storage.SpeculationPathBuildStore - speculationTreeStore storage.SpeculationTreeStore - requestLogStore storage.RequestLogStore - requestSummaryStore storage.RequestSummaryStore - requestQueueStore storage.RequestQueueSummaryStore - requestURIStore storage.RequestURIStore + db *sql.DB + requestStore storage.RequestStore + changeStore storage.ChangeStore + batchStore storage.BatchStore + batchDependentStore storage.BatchDependentStore + buildStore storage.BuildStore + requestLogStore storage.RequestLogStore + requestSummaryStore storage.RequestSummaryStore + requestQueueStore storage.RequestQueueSummaryStore + requestURIStore storage.RequestURIStore } // NewStorage creates a new MySQL storage. func NewStorage(db *sql.DB, scope tally.Scope) (storage.Storage, error) { return &mysqlStorage{ - db: db, - requestStore: NewRequestStore(db, scope.SubScope("request_store")), - changeStore: NewChangeStore(db, scope.SubScope("change_store")), - batchStore: NewBatchStore(db, scope.SubScope("batch_store")), - batchDependentStore: NewBatchDependentStore(db, scope.SubScope("batch_dependent_store")), - buildStore: NewBuildStore(db, scope.SubScope("build_store")), - speculationPathBuildStore: NewSpeculationPathBuildStore(db, scope.SubScope("speculation_path_build_store")), - speculationTreeStore: NewSpeculationTreeStore(db, scope.SubScope("speculation_tree_store")), - requestLogStore: NewRequestLogStore(db, scope.SubScope("request_log_store")), - requestSummaryStore: NewRequestSummaryStore(db, scope.SubScope("request_summary_store")), - requestQueueStore: NewRequestQueueSummaryStore(db, scope.SubScope("request_queue_summary_store")), - requestURIStore: NewRequestURIStore(db, scope.SubScope("request_uri_store")), + db: db, + requestStore: NewRequestStore(db, scope.SubScope("request_store")), + changeStore: NewChangeStore(db, scope.SubScope("change_store")), + batchStore: NewBatchStore(db, scope.SubScope("batch_store")), + batchDependentStore: NewBatchDependentStore(db, scope.SubScope("batch_dependent_store")), + buildStore: NewBuildStore(db, scope.SubScope("build_store")), + requestLogStore: NewRequestLogStore(db, scope.SubScope("request_log_store")), + requestSummaryStore: NewRequestSummaryStore(db, scope.SubScope("request_summary_store")), + requestQueueStore: NewRequestQueueSummaryStore(db, scope.SubScope("request_queue_summary_store")), + requestURIStore: NewRequestURIStore(db, scope.SubScope("request_uri_store")), }, nil } @@ -85,16 +81,6 @@ func (f *mysqlStorage) GetBuildStore() storage.BuildStore { return f.buildStore } -// GetSpeculationPathBuildStore returns the MySQL-backed SpeculationPathBuildStore. -func (f *mysqlStorage) GetSpeculationPathBuildStore() storage.SpeculationPathBuildStore { - return f.speculationPathBuildStore -} - -// GetSpeculationTreeStore returns the MySQL-backed SpeculationTreeStore. -func (f *mysqlStorage) GetSpeculationTreeStore() storage.SpeculationTreeStore { - return f.speculationTreeStore -} - // GetRequestLogStore returns the MySQL-backed RequestLogStore. func (f *mysqlStorage) GetRequestLogStore() storage.RequestLogStore { return f.requestLogStore diff --git a/submitqueue/extension/storage/mysql/storage_test.go b/submitqueue/extension/storage/mysql/storage_test.go index bf0cb68b..6055ad5d 100644 --- a/submitqueue/extension/storage/mysql/storage_test.go +++ b/submitqueue/extension/storage/mysql/storage_test.go @@ -41,8 +41,6 @@ func TestNewStorage(t *testing.T) { assert.NotNil(t, s.GetBatchStore()) assert.NotNil(t, s.GetBatchDependentStore()) assert.NotNil(t, s.GetBuildStore()) - assert.NotNil(t, s.GetSpeculationPathBuildStore()) - assert.NotNil(t, s.GetSpeculationTreeStore()) assert.NotNil(t, s.GetRequestLogStore()) assert.NotNil(t, s.GetRequestSummaryStore()) assert.NotNil(t, s.GetRequestQueueSummaryStore()) diff --git a/submitqueue/extension/storage/speculation_path_build_store.go b/submitqueue/extension/storage/speculation_path_build_store.go deleted file mode 100644 index 64e82ab1..00000000 --- a/submitqueue/extension/storage/speculation_path_build_store.go +++ /dev/null @@ -1,41 +0,0 @@ -// 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 storage - -//go:generate mockgen -source=speculation_path_build_store.go -destination=mock/speculation_path_build_store_mock.go -package=mock - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" -) - -// SpeculationPathBuildStore is an interface that defines methods for managing -// the path->build mapping in the database. A row is written only by the build -// controller, when it triggers a build for a speculation path, and is -// immutable once written: a path maps to at most one build. If the mapping -// for a path is lost (e.g. redelivery races), the build controller -// re-triggers a build and the new mapping wins — so Create on an existing -// PathID returns ErrAlreadyExists, and the caller treats the already-persisted -// row as truth rather than overwriting it. -type SpeculationPathBuildStore interface { - // Create creates a new path->build mapping. Returns ErrAlreadyExists if a - // mapping for the given PathID already exists. - Create(ctx context.Context, pathBuild entity.SpeculationPathBuild) error - - // Get retrieves the path->build mapping for the given path ID. Returns - // ErrNotFound if no mapping exists for pathID. - Get(ctx context.Context, pathID string) (entity.SpeculationPathBuild, error) -} diff --git a/submitqueue/extension/storage/speculation_tree_store.go b/submitqueue/extension/storage/speculation_tree_store.go deleted file mode 100644 index 374213de..00000000 --- a/submitqueue/extension/storage/speculation_tree_store.go +++ /dev/null @@ -1,41 +0,0 @@ -// 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 storage - -//go:generate mockgen -source=speculation_tree_store.go -destination=mock/speculation_tree_store_mock.go -package=mock - -import ( - "context" - - "github.com/uber/submitqueue/submitqueue/entity" -) - -// SpeculationTreeStore is an interface that defines methods for managing speculation trees in the database. -type SpeculationTreeStore interface { - // Get retrieves the speculation tree by batch ID. - // Returns ErrNotFound if the speculation tree is not found. - Get(ctx context.Context, batchID string) (entity.SpeculationTree, error) - - // Create creates a new speculation tree. - // Returns ErrAlreadyExists if the entry already exists. - Create(ctx context.Context, speculationTree entity.SpeculationTree) error - - // Update overwrites the paths of an existing speculation tree and sets its - // version to newVersion if the current persisted version matches oldVersion. - // If versions do not match (or no tree exists for batchID), returns - // ErrVersionMismatch. Version arithmetic is owned by the caller; the store - // performs a pure conditional write. - Update(ctx context.Context, batchID string, oldVersion, newVersion int32, paths []entity.SpeculationPathInfo) error -} diff --git a/submitqueue/extension/storage/storage.go b/submitqueue/extension/storage/storage.go index b54736c3..c9506e5a 100644 --- a/submitqueue/extension/storage/storage.go +++ b/submitqueue/extension/storage/storage.go @@ -59,12 +59,6 @@ type Storage interface { // GetBuildStore returns the BuildStore instance. GetBuildStore() BuildStore - // GetSpeculationPathBuildStore returns the SpeculationPathBuildStore instance. - GetSpeculationPathBuildStore() SpeculationPathBuildStore - - // GetSpeculationTreeStore returns the SpeculationTreeStore instance. - GetSpeculationTreeStore() SpeculationTreeStore - // GetRequestLogStore returns the RequestLogStore instance. GetRequestLogStore() RequestLogStore diff --git a/submitqueue/orchestrator/controller/dlq/BUILD.bazel b/submitqueue/orchestrator/controller/dlq/BUILD.bazel index f36148b9..08a52c22 100644 --- a/submitqueue/orchestrator/controller/dlq/BUILD.bazel +++ b/submitqueue/orchestrator/controller/dlq/BUILD.bazel @@ -9,18 +9,15 @@ go_library( "log.go", "mergeconflictsignal.go", "mergesignal.go", - "queue.go", "request.go", ], importpath = "github.com/uber/submitqueue/submitqueue/orchestrator/controller/dlq", visibility = ["//visibility:public"], deps = [ "//api/runway/messagequeue:go_default_library", - "//platform/base/messagequeue:go_default_library", "//platform/consumer:go_default_library", "//platform/metrics:go_default_library", "//submitqueue/core/request:go_default_library", - "//submitqueue/core/topickey:go_default_library", "//submitqueue/entity:go_default_library", "//submitqueue/extension/storage:go_default_library", "@com_github_uber_go_tally//:go_default_library", @@ -38,7 +35,6 @@ go_test( "mergeconflictsignal_test.go", "mergesignal_test.go", "publisher_test.go", - "queue_test.go", "request_test.go", ], embed = [":go_default_library"], diff --git a/submitqueue/orchestrator/controller/dlq/queue.go b/submitqueue/orchestrator/controller/dlq/queue.go deleted file mode 100644 index 2b17cbdb..00000000 --- a/submitqueue/orchestrator/controller/dlq/queue.go +++ /dev/null @@ -1,166 +0,0 @@ -// 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 dlq - -import ( - "context" - "fmt" - - "github.com/uber-go/tally" - entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" - "github.com/uber/submitqueue/platform/consumer" - "github.com/uber/submitqueue/platform/metrics" - "github.com/uber/submitqueue/submitqueue/core/topickey" - "github.com/uber/submitqueue/submitqueue/entity" - "go.uber.org/zap" -) - -// queueController is the DLQ reconciler for the prioritize topic. Unlike -// every other primary topic, prioritize's payload carries a QueueID, not a -// request or batch identifier — the stage reconciles a whole queue's build -// budget in one pass, not a single entity. There is therefore no request or -// batch to drive to a terminal state here: the batches the failed round -// would have considered stay exactly where they were (Selected paths stay -// Selected, in-flight builds keep running). -// -// Reconciliation is therefore re-arming, not terminalizing: after logging -// the failure, the controller publishes a fresh prioritize round for the -// queue. Speculate republishes a round on every batch-level event, but a -// queue whose only pending work is waiting on prioritization (nothing -// building, no new requests arriving) may see no further events — without -// the requeue its batches would stay stranded until unrelated activity -// happened to trigger a round. -// -// The requeue cannot poison-loop on payload contents: a round carries only -// the queue name and recomputes entirely from live state, so there is no bad -// datum to replay. A round that fails persistently (e.g. storage down) -// cycles primary retry ladder -> DLQ -> requeue, throttled by the full -// ladder on every cycle and visible in DLQ metrics, and converges the first -// cycle after the fault heals. The requeued message ID is derived from the -// dead-lettered message's ID: distinct from the original round's ID so the -// queue's publish dedup does not swallow it, and deterministic per DLQ -// message so a redelivered DLQ message republishes the same ID and is -// coalesced instead of fanning out. -type queueController struct { - logger *zap.SugaredLogger - metricsScope tally.Scope - registry consumer.TopicRegistry - topicKey consumer.TopicKey - consumerGroup string -} - -// Verify queueController implements consumer.Controller at compile time. -var _ consumer.Controller = (*queueController)(nil) - -// NewDLQQueueController builds a DLQ controller for the prioritize topic. -// registry must resolve topickey.TopicKeyPrioritize — the topic the -// controller re-arms the queue on. -func NewDLQQueueController( - logger *zap.SugaredLogger, - scope tally.Scope, - registry consumer.TopicRegistry, - topicKey consumer.TopicKey, - consumerGroup string, -) consumer.Controller { - name := string(topicKey) + "_controller" - return &queueController{ - logger: logger.Named(name), - metricsScope: scope.SubScope(name), - registry: registry, - topicKey: topicKey, - consumerGroup: consumerGroup, - } -} - -// Process logs a DLQ'd prioritize message for operator visibility, re-arms -// the queue by publishing a fresh prioritize round, and acks. See the -// queueController doc comment for why re-arming is the reconciliation here -// and why it cannot loop unboundedly. -func (c *queueController) Process(ctx context.Context, delivery consumer.Delivery) (retErr error) { - const opName = "process" - - op := metrics.Begin(c.metricsScope, opName) - defer func() { op.Complete(retErr) }() - - msg := delivery.Message() - - qid, err := entity.QueueIDFromBytes(msg.Payload) - if err != nil { - metrics.NamedCounter(c.metricsScope, opName, "deserialize_errors", 1) - return fmt.Errorf("failed to decode queue id from dlq payload: %w", err) - } - if qid.Name == "" { - metrics.NamedCounter(c.metricsScope, opName, "empty_id_errors", 1) - return fmt.Errorf("dlq payload decoded to empty queue name") - } - - dmeta := delivery.Metadata() - c.logger.Warnw("dlq message received; requeueing a fresh prioritization round for queue", - "queue", qid.Name, - "attempt", delivery.Attempt(), - "dlq_original_topic", dmeta["dlq.original_topic"], - "dlq_failure_count", dmeta["dlq.failure_count"], - "dlq_last_error", dmeta["dlq.last_error"], - ) - - if err := c.requeueRound(ctx, qid, msg.ID); err != nil { - metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1) - return fmt.Errorf("failed to requeue prioritize round for queue %s: %w", qid.Name, err) - } - - metrics.NamedCounter(c.metricsScope, opName, "reconciled", 1) - return nil -} - -// requeueRound publishes a fresh prioritize round for the queue. The message -// ID appends a suffix to the dead-lettered message's ID (see the -// queueController doc comment for the dedup reasoning). -func (c *queueController) requeueRound(ctx context.Context, qid entity.QueueID, dlqMsgID string) error { - payload, err := qid.ToBytes() - if err != nil { - return fmt.Errorf("failed to serialize queue ID: %w", err) - } - - msg := entityqueue.NewMessage(dlqMsgID+"/dlq-requeue", payload, qid.Name, nil) - - q, ok := c.registry.Queue(topickey.TopicKeyPrioritize) - if !ok { - return fmt.Errorf("no queue registered for topic key %s", topickey.TopicKeyPrioritize) - } - topicName, ok := c.registry.TopicName(topickey.TopicKeyPrioritize) - if !ok { - return fmt.Errorf("no topic name registered for topic key %s", topickey.TopicKeyPrioritize) - } - - if err := q.Publisher().Publish(ctx, topicName, msg); err != nil { - return fmt.Errorf("failed to publish message: %w", err) - } - return nil -} - -// Name returns the controller name for logging and metrics. -func (c *queueController) Name() string { - return string(c.topicKey) -} - -// TopicKey returns the topic key this controller subscribes to. -func (c *queueController) TopicKey() consumer.TopicKey { - return c.topicKey -} - -// ConsumerGroup returns the consumer group for offset tracking. -func (c *queueController) ConsumerGroup() string { - return c.consumerGroup -} diff --git a/submitqueue/orchestrator/controller/dlq/queue_test.go b/submitqueue/orchestrator/controller/dlq/queue_test.go deleted file mode 100644 index a0f1dccc..00000000 --- a/submitqueue/orchestrator/controller/dlq/queue_test.go +++ /dev/null @@ -1,132 +0,0 @@ -// 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 dlq - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" - "github.com/uber/submitqueue/platform/consumer" - queuemock "github.com/uber/submitqueue/platform/extension/messagequeue/mock" - "github.com/uber/submitqueue/submitqueue/core/topickey" - "github.com/uber/submitqueue/submitqueue/entity" - "go.uber.org/mock/gomock" - "go.uber.org/zap/zaptest" -) - -// newPrioritizeRegistry wires a TopicRegistry exposing the prioritize topic -// backed by a mock publisher, returning both so tests can set expectations. -func newPrioritizeRegistry(t *testing.T, ctrl *gomock.Controller) (consumer.TopicRegistry, *queuemock.MockPublisher) { - pub := queuemock.NewMockPublisher(ctrl) - q := queuemock.NewMockQueue(ctrl) - q.EXPECT().Publisher().Return(pub).AnyTimes() - - registry, err := consumer.NewTopicRegistry([]consumer.TopicConfig{ - {Key: topickey.TopicKeyPrioritize, Name: "prioritize", Queue: q}, - }) - require.NoError(t, err) - return registry, pub -} - -func TestDLQQueueController_InterfaceAndAccessors(t *testing.T) { - ctrl := gomock.NewController(t) - registry, _ := newPrioritizeRegistry(t, ctrl) - - c := NewDLQQueueController(zaptest.NewLogger(t).Sugar(), testScope(), registry, TopicKey(topickey.TopicKeyPrioritize), "orchestrator-prioritize-dlq") - - assert.Equal(t, "prioritize_dlq", c.Name()) - assert.Equal(t, consumer.TopicKey("prioritize_dlq"), c.TopicKey()) - assert.Equal(t, "orchestrator-prioritize-dlq", c.ConsumerGroup()) -} - -// TestDLQQueueController_Process_RequeuesRound verifies the reconciler -// re-arms the queue: a fresh prioritize round is published for the queue, -// with a message ID derived from the dead-lettered message's ID (distinct -// from the original round so publish dedup does not swallow it, stable -// across DLQ redeliveries so they coalesce). -func TestDLQQueueController_Process_RequeuesRound(t *testing.T) { - ctrl := gomock.NewController(t) - registry, pub := newPrioritizeRegistry(t, ctrl) - - c := NewDLQQueueController(zaptest.NewLogger(t).Sugar(), testScope(), registry, TopicKey(topickey.TopicKeyPrioritize), "orchestrator-prioritize-dlq") - - payload, err := entity.QueueID{Name: "q"}.ToBytes() - require.NoError(t, err) - - var published entityqueue.Message - pub.EXPECT().Publish(gomock.Any(), "prioritize", gomock.Any()).DoAndReturn( - func(_ context.Context, _ string, msg entityqueue.Message) error { - published = msg - return nil - }, - ) - - delivery := newMockDelivery(ctrl, payload) - require.NoError(t, c.Process(context.Background(), delivery)) - - assert.Equal(t, "dlq-msg-1/dlq-requeue", published.ID, "requeued ID should derive from the DLQ message's ID") - assert.Equal(t, "q", published.PartitionKey, "rounds are partitioned by queue name") - qid, err := entity.QueueIDFromBytes(published.Payload) - require.NoError(t, err) - assert.Equal(t, "q", qid.Name) -} - -// TestDLQQueueController_Process_PublishFailureNacks verifies a failed -// requeue publish surfaces as an error so the DLQ consumer redelivers and -// the re-arm is eventually made. -func TestDLQQueueController_Process_PublishFailureNacks(t *testing.T) { - ctrl := gomock.NewController(t) - registry, pub := newPrioritizeRegistry(t, ctrl) - - c := NewDLQQueueController(zaptest.NewLogger(t).Sugar(), testScope(), registry, TopicKey(topickey.TopicKeyPrioritize), "orchestrator-prioritize-dlq") - - payload, err := entity.QueueID{Name: "q"}.ToBytes() - require.NoError(t, err) - - sentinel := errors.New("publish down") - pub.EXPECT().Publish(gomock.Any(), "prioritize", gomock.Any()).Return(sentinel) - - delivery := newMockDelivery(ctrl, payload) - require.ErrorIs(t, c.Process(context.Background(), delivery), sentinel) -} - -func TestDLQQueueController_Process_MalformedPayloadFails(t *testing.T) { - ctrl := gomock.NewController(t) - registry, _ := newPrioritizeRegistry(t, ctrl) - - c := NewDLQQueueController(zaptest.NewLogger(t).Sugar(), testScope(), registry, TopicKey(topickey.TopicKeyPrioritize), "orchestrator-prioritize-dlq") - - delivery := newMockDelivery(ctrl, []byte("garbage")) - err := c.Process(context.Background(), delivery) - require.Error(t, err) -} - -func TestDLQQueueController_Process_EmptyNameFails(t *testing.T) { - ctrl := gomock.NewController(t) - registry, _ := newPrioritizeRegistry(t, ctrl) - - c := NewDLQQueueController(zaptest.NewLogger(t).Sugar(), testScope(), registry, TopicKey(topickey.TopicKeyPrioritize), "orchestrator-prioritize-dlq") - - payload, err := entity.QueueID{Name: ""}.ToBytes() - require.NoError(t, err) - - delivery := newMockDelivery(ctrl, payload) - err = c.Process(context.Background(), delivery) - require.Error(t, err) -} diff --git a/submitqueue/orchestrator/controller/prioritize/BUILD.bazel b/submitqueue/orchestrator/controller/prioritize/BUILD.bazel deleted file mode 100644 index 536e0ab5..00000000 --- a/submitqueue/orchestrator/controller/prioritize/BUILD.bazel +++ /dev/null @@ -1,40 +0,0 @@ -load("@rules_go//go:def.bzl", "go_library", "go_test") - -go_library( - name = "go_default_library", - srcs = ["prioritize.go"], - importpath = "github.com/uber/submitqueue/submitqueue/orchestrator/controller/prioritize", - visibility = ["//visibility:public"], - deps = [ - "//platform/base/messagequeue:go_default_library", - "//platform/consumer:go_default_library", - "//platform/metrics:go_default_library", - "//submitqueue/core/topickey:go_default_library", - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/prioritizer:go_default_library", - "//submitqueue/extension/storage:go_default_library", - "@com_github_uber_go_tally//:go_default_library", - "@org_uber_go_zap//:go_default_library", - ], -) - -go_test( - name = "go_default_test", - srcs = ["prioritize_test.go"], - embed = [":go_default_library"], - deps = [ - "//platform/base/messagequeue:go_default_library", - "//platform/consumer:go_default_library", - "//platform/extension/messagequeue/mock:go_default_library", - "//submitqueue/core/topickey:go_default_library", - "//submitqueue/entity:go_default_library", - "//submitqueue/extension/speculation/prioritizer/mock:go_default_library", - "//submitqueue/extension/storage:go_default_library", - "//submitqueue/extension/storage/mock:go_default_library", - "@com_github_stretchr_testify//assert:go_default_library", - "@com_github_stretchr_testify//require:go_default_library", - "@com_github_uber_go_tally//:go_default_library", - "@org_uber_go_mock//gomock:go_default_library", - "@org_uber_go_zap//zaptest:go_default_library", - ], -) diff --git a/submitqueue/orchestrator/controller/prioritize/prioritize.go b/submitqueue/orchestrator/controller/prioritize/prioritize.go deleted file mode 100644 index 6bb53293..00000000 --- a/submitqueue/orchestrator/controller/prioritize/prioritize.go +++ /dev/null @@ -1,408 +0,0 @@ -// 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 prioritize implements the queue-wide reconcile stage that rations -// a shared build budget across every in-flight batch of a queue. -// -// Unlike every other pipeline stage, prioritize is not batch-scoped: its -// queue message carries only a queue name (entity.QueueID), and each -// invocation re-evaluates every Speculating batch's speculation tree for -// that queue together. It gathers the queue-wide set of candidate paths -// (Selected, Prioritized, or Building), hands them to the queue's -// prioritizer.Prioritizer, and applies the returned decisions: -// -// - Promote on a Selected path clears it to run (-> Prioritized). -// - Cancel on a Building path asks the build runner to stop it and marks -// it Cancelling; the build stage's own signal loop confirms the stop. -// - Cancel on a Prioritized path (no build yet) drops it straight to -// Cancelled. -// -// Each affected tree is persisted under its own optimistic lock, so a -// version conflict only nacks and re-derives that tree's part of the round -// on redelivery — the whole computation is a pure function of freshly read -// state, so recomputing it is always safe. After applying decisions, the -// controller republishes to the build topic for every batch whose tree has -// at least one Prioritized path with no build yet, not just newly promoted -// ones — this heals a build message dropped by a prior crash and is itself -// idempotent, since the build stage dedups on batch ID. -package prioritize - -import ( - "context" - "errors" - "fmt" - - "github.com/uber-go/tally" - entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" - "github.com/uber/submitqueue/platform/consumer" - "github.com/uber/submitqueue/platform/metrics" - "github.com/uber/submitqueue/submitqueue/core/topickey" - "github.com/uber/submitqueue/submitqueue/entity" - "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer" - "github.com/uber/submitqueue/submitqueue/extension/storage" - "go.uber.org/zap" -) - -// opName is the metric operation name shared by every emit in this file. -const opName = "process" - -// Controller consumes queue-wide prioritize messages, ranks every candidate -// speculation path in the queue against its build budget, applies the -// resulting decisions to each affected speculation tree, and republishes to -// build for paths cleared to run. -type Controller struct { - logger *zap.SugaredLogger - metricsScope tally.Scope - store storage.Storage - prioritizers prioritizer.Factory - registry consumer.TopicRegistry - topicKey consumer.TopicKey - consumerGroup string -} - -// Verify Controller implements consumer.Controller interface at compile time. -var _ consumer.Controller = (*Controller)(nil) - -// NewController creates a new prioritize controller for the orchestrator. -func NewController( - logger *zap.SugaredLogger, - scope tally.Scope, - store storage.Storage, - prioritizers prioritizer.Factory, - registry consumer.TopicRegistry, - topicKey consumer.TopicKey, - consumerGroup string, -) *Controller { - return &Controller{ - logger: logger.Named("prioritize_controller"), - metricsScope: scope.SubScope("prioritize_controller"), - store: store, - prioritizers: prioritizers, - registry: registry, - topicKey: topicKey, - consumerGroup: consumerGroup, - } -} - -// Process re-evaluates the build budget for one queue: it loads every -// Speculating batch's speculation tree, ranks the queue-wide candidate paths -// through the queue's prioritizer, applies the returned decisions, persists -// the affected trees, and republishes to build for any path now cleared to -// run. Returns nil to ack (success), or error to nack (retry) — the whole -// round is a pure function of freshly read state, so redelivery simply -// recomputes it. -func (c *Controller) Process(ctx context.Context, delivery consumer.Delivery) (retErr error) { - op := metrics.Begin(c.metricsScope, opName) - defer func() { op.Complete(retErr) }() - - msg := delivery.Message() - - qid, err := entity.QueueIDFromBytes(msg.Payload) - if err != nil { - metrics.NamedCounter(c.metricsScope, opName, "deserialize_errors", 1) - return fmt.Errorf("failed to deserialize queue ID: %w", err) - } - - batches, err := c.store.GetBatchStore().GetByQueueAndStates(ctx, qid.Name, []entity.BatchState{entity.BatchStateSpeculating}) - if err != nil { - metrics.NamedCounter(c.metricsScope, opName, "storage_errors", 1) - return fmt.Errorf("failed to get speculating batches for queue %s: %w", qid.Name, err) - } - if len(batches) == 0 { - metrics.NamedCounter(c.metricsScope, opName, "no_speculating_batches", 1) - return nil - } - - trees, err := c.loadTrees(ctx, batches) - if err != nil { - return err - } - if len(trees) == 0 { - metrics.NamedCounter(c.metricsScope, opName, "no_trees", 1) - return nil - } - - candidates := candidatesOf(trees) - - pf, err := c.prioritizers.For(prioritizer.Config{QueueName: qid.Name}) - if err != nil { - metrics.NamedCounter(c.metricsScope, opName, "prioritizer_errors", 1) - return fmt.Errorf("failed to get prioritizer for queue %s: %w", qid.Name, err) - } - decisions, err := pf.Prioritize(ctx, candidates) - if err != nil { - metrics.NamedCounter(c.metricsScope, opName, "prioritizer_errors", 1) - return fmt.Errorf("failed to prioritize candidates for queue %s: %w", qid.Name, err) - } - - changed, err := c.applyDecisions(ctx, qid.Name, trees, decisions) - if err != nil { - return err - } - - // Persist before publish. If republishBuilds fails below (or the process - // crashes between the two), the message nacks and the redelivered round - // recomputes everything from the persisted state — it carries no memory - // of what this attempt picked, and needs none: paths promoted here are - // now Prioritized, so the prioritizer counts them as slot holders instead - // of re-promoting them, and republishBuilds derives "who needs a build - // message" from the trees themselves (any Prioritized path without a - // build) rather than from this round's decisions, so a dropped publish is - // healed on the next pass. - if err := c.persistTrees(ctx, trees, changed); err != nil { - return err - } - - if err := c.republishBuilds(ctx, qid.Name, trees); err != nil { - return err - } - - return nil -} - -// loadTrees loads the speculation tree for each batch, keyed by batch ID. -// A batch with no tree yet (storage.ErrNotFound) has not been speculated on -// yet and is skipped; any other error aborts the round. -func (c *Controller) loadTrees(ctx context.Context, batches []entity.Batch) (map[string]entity.SpeculationTree, error) { - trees := make(map[string]entity.SpeculationTree, len(batches)) - for _, b := range batches { - tree, err := c.store.GetSpeculationTreeStore().Get(ctx, b.ID) - if err != nil { - if errors.Is(err, storage.ErrNotFound) { - metrics.NamedCounter(c.metricsScope, opName, "tree_not_found_skipped", 1) - continue - } - metrics.NamedCounter(c.metricsScope, opName, "storage_errors", 1) - return nil, fmt.Errorf("failed to get speculation tree for batch %s: %w", b.ID, err) - } - trees[b.ID] = tree - } - return trees, nil -} - -// candidatesOf flattens every path across all loaded trees whose status -// represents a queue-wide interest: Selected (wants a slot), or -// Prioritized/Building (holds one). -func candidatesOf(trees map[string]entity.SpeculationTree) []entity.SpeculationPathInfo { - var candidates []entity.SpeculationPathInfo - for _, tree := range trees { - for _, p := range tree.Paths { - switch p.Status { - case entity.SpeculationPathStatusSelected, - entity.SpeculationPathStatusPrioritized, - entity.SpeculationPathStatusBuilding: - candidates = append(candidates, p) - } - } - } - return candidates -} - -// applyDecisions maps each prioritizer decision to a status transition on its -// tree's matching path, mutating trees in place. It returns the set of batch -// IDs whose tree actually changed, so the caller persists only those. -// -// Decisions are captured as intent, never enacted here: this controller does -// not talk to the build system. A Cancel on a Building path only flips it to -// Cancelling in the tree; the build stage — the sole owner of runner -// interaction — enacts the runner cancel when it processes the batch, retried -// for free on every build message until the build reaches a terminal state -// and the speculate reconcile settles the path to Cancelled. Persisting the -// intent before any side effect is what makes the flow crash-safe: a lost -// build message is healed by republishBuilds, not by remembering this round. -// -// A decision naming a batch or path the round did not load, or applying an -// action the path's current status does not support, is a policy bug in the -// prioritizer: it is logged as a warning and skipped rather than corrupting -// the tree. -func (c *Controller) applyDecisions( - ctx context.Context, - queue string, - trees map[string]entity.SpeculationTree, - decisions []entity.PathDecision, -) (map[string]bool, error) { - changed := make(map[string]bool) - - // Decisions name paths by ID only; recover each path's tree from the - // trees loaded this round rather than parsing anything out of the ID. - pathBatch := make(map[string]string) - for batchID, tree := range trees { - for _, p := range tree.Paths { - pathBatch[p.ID] = batchID - } - } - - seen := make(map[string]bool, len(decisions)) - for _, d := range decisions { - if seen[d.PathID] { - metrics.NamedCounter(c.metricsScope, opName, "illegal_decision", 1) - c.logger.Warnw("illegal decision: duplicate decision for path", - "queue", queue, - "path_id", d.PathID, - "action", d.Action, - ) - continue - } - seen[d.PathID] = true - - batchID, ok := pathBatch[d.PathID] - if !ok { - metrics.NamedCounter(c.metricsScope, opName, "illegal_decision", 1) - c.logger.Warnw("illegal decision: path not loaded this round", - "queue", queue, - "path_id", d.PathID, - "action", d.Action, - ) - continue - } - tree := trees[batchID] - - // pathBatch was built from these same trees, so the ID must resolve; - // guard anyway so an index bug degrades to a skipped decision. - idx := tree.PathIndex(d.PathID) - if idx == -1 { - metrics.NamedCounter(c.metricsScope, opName, "illegal_decision", 1) - c.logger.Warnw("illegal decision: path not found in tree", - "queue", queue, - "batch_id", batchID, - "path_id", d.PathID, - "action", d.Action, - ) - continue - } - - info := tree.Paths[idx] - switch { - case d.Action == entity.SpeculationPathActionPromote && info.Status == entity.SpeculationPathStatusSelected: - info.Status = entity.SpeculationPathStatusPrioritized - metrics.NamedCounter(c.metricsScope, opName, "promoted", 1) - - case d.Action == entity.SpeculationPathActionCancel && info.Status == entity.SpeculationPathStatusBuilding: - info.Status = entity.SpeculationPathStatusCancelling - metrics.NamedCounter(c.metricsScope, opName, "cancelling", 1) - - case d.Action == entity.SpeculationPathActionCancel && info.Status == entity.SpeculationPathStatusPrioritized: - info.Status = entity.SpeculationPathStatusCancelled - metrics.NamedCounter(c.metricsScope, opName, "cancelled", 1) - - default: - metrics.NamedCounter(c.metricsScope, opName, "illegal_decision", 1) - c.logger.Warnw("illegal decision: action not valid for path status", - "queue", queue, - "batch_id", batchID, - "path_id", d.PathID, - "action", d.Action, - "status", string(info.Status), - ) - continue - } - - tree.Paths[idx] = info - trees[batchID] = tree - changed[batchID] = true - } - - return changed, nil -} - -// persistTrees writes each changed tree's paths under its own optimistic -// lock, bumping Version in trees on success. Version arithmetic is owned by -// the controller; the store performs a pure conditional write. -func (c *Controller) persistTrees(ctx context.Context, trees map[string]entity.SpeculationTree, changed map[string]bool) error { - for batchID := range changed { - tree := trees[batchID] - newVersion := tree.Version + 1 - if err := c.store.GetSpeculationTreeStore().Update(ctx, batchID, tree.Version, newVersion, tree.Paths); err != nil { - metrics.NamedCounter(c.metricsScope, opName, "storage_errors", 1) - return fmt.Errorf("failed to update speculation tree for batch %s: %w", batchID, err) - } - tree.Version = newVersion - trees[batchID] = tree - } - return nil -} - -// republishBuilds re-publishes a build message for every batch whose tree -// carries work the build stage still has to enact — a Prioritized path with -// no build yet (needs a trigger), or a Cancelling path (a persisted cancel -// intent whose runner cancel the build stage owns) — not just paths touched -// this round. This heals a build message a prior crash dropped between the -// tree write and the publish; it is safe to repeat because the build stage -// dedups triggers on the path->build mapping and runner cancels are -// idempotent. -func (c *Controller) republishBuilds(ctx context.Context, queue string, trees map[string]entity.SpeculationTree) error { - for batchID, tree := range trees { - needsBuild := false - for _, p := range tree.Paths { - if (p.Status == entity.SpeculationPathStatusPrioritized && p.BuildID == "") || - p.Status == entity.SpeculationPathStatusCancelling { - needsBuild = true - break - } - } - if !needsBuild { - continue - } - if err := c.publishBuild(ctx, batchID, queue); err != nil { - metrics.NamedCounter(c.metricsScope, opName, "publish_errors", 1) - return fmt.Errorf("failed to publish batch %s to build: %w", batchID, err) - } - metrics.NamedCounter(c.metricsScope, opName, "build_republished", 1) - } - return nil -} - -// publishBuild publishes a batch ID to the build topic. Only the identifier -// travels on the queue; the build controller reloads the full Batch (and, -// eventually, its speculation tree) from storage. -func (c *Controller) publishBuild(ctx context.Context, batchID, partitionKey string) error { - bid := entity.BatchID{ID: batchID} - payload, err := bid.ToBytes() - if err != nil { - return fmt.Errorf("failed to serialize batch ID: %w", err) - } - - msg := entityqueue.NewMessage(batchID, payload, partitionKey, nil) - - q, ok := c.registry.Queue(topickey.TopicKeyBuild) - if !ok { - return fmt.Errorf("no queue registered for topic key %s", topickey.TopicKeyBuild) - } - - topicName, ok := c.registry.TopicName(topickey.TopicKeyBuild) - if !ok { - return fmt.Errorf("no topic name registered for topic key %s", topickey.TopicKeyBuild) - } - - if err := q.Publisher().Publish(ctx, topicName, msg); err != nil { - return fmt.Errorf("failed to publish message: %w", err) - } - - return nil -} - -// Name returns the controller name for logging and metrics. -func (c *Controller) Name() string { - return "prioritize" -} - -// TopicKey returns the topic key this controller subscribes to. -func (c *Controller) TopicKey() consumer.TopicKey { - return c.topicKey -} - -// ConsumerGroup returns the consumer group for offset tracking. -func (c *Controller) ConsumerGroup() string { - return c.consumerGroup -} diff --git a/submitqueue/orchestrator/controller/prioritize/prioritize_test.go b/submitqueue/orchestrator/controller/prioritize/prioritize_test.go deleted file mode 100644 index 44364f57..00000000 --- a/submitqueue/orchestrator/controller/prioritize/prioritize_test.go +++ /dev/null @@ -1,395 +0,0 @@ -// 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 prioritize - -import ( - "context" - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/uber-go/tally" - entityqueue "github.com/uber/submitqueue/platform/base/messagequeue" - "github.com/uber/submitqueue/platform/consumer" - queuemock "github.com/uber/submitqueue/platform/extension/messagequeue/mock" - "github.com/uber/submitqueue/submitqueue/core/topickey" - "github.com/uber/submitqueue/submitqueue/entity" - prioritizermock "github.com/uber/submitqueue/submitqueue/extension/speculation/prioritizer/mock" - "github.com/uber/submitqueue/submitqueue/extension/storage" - storagemock "github.com/uber/submitqueue/submitqueue/extension/storage/mock" - "go.uber.org/mock/gomock" - "go.uber.org/zap/zaptest" -) - -// testHarness wires a Controller against mocked storage, prioritizer, build -// runner, and a build topic queue so tests can assert what got persisted and -// published. -type testHarness struct { - controller *Controller - batchStore *storagemock.MockBatchStore - treeStore *storagemock.MockSpeculationTreeStore - prio *prioritizermock.MockPrioritizer - buildPub *queuemock.MockPublisher -} - -func newTestHarness(t *testing.T, ctrl *gomock.Controller) *testHarness { - prio := prioritizermock.NewMockPrioritizer(ctrl) - prioFactory := prioritizermock.NewMockFactory(ctrl) - prioFactory.EXPECT().For(gomock.Any()).Return(prio, nil).AnyTimes() - - buildPub := queuemock.NewMockPublisher(ctrl) - buildQ := queuemock.NewMockQueue(ctrl) - buildQ.EXPECT().Publisher().Return(buildPub).AnyTimes() - - registry, err := consumer.NewTopicRegistry([]consumer.TopicConfig{ - {Key: topickey.TopicKeyPrioritize, Name: "prioritize", Queue: buildQ}, - {Key: topickey.TopicKeyBuild, Name: "build", Queue: buildQ}, - }) - require.NoError(t, err) - - batchStore := storagemock.NewMockBatchStore(ctrl) - treeStore := storagemock.NewMockSpeculationTreeStore(ctrl) - store := storagemock.NewMockStorage(ctrl) - store.EXPECT().GetBatchStore().Return(batchStore).AnyTimes() - store.EXPECT().GetSpeculationTreeStore().Return(treeStore).AnyTimes() - - c := NewController( - zaptest.NewLogger(t).Sugar(), - tally.NoopScope, - store, - prioFactory, - registry, - topickey.TopicKeyPrioritize, - "orchestrator-prioritize", - ) - return &testHarness{ - controller: c, - batchStore: batchStore, - treeStore: treeStore, - prio: prio, - buildPub: buildPub, - } -} - -// queueDelivery builds a delivery whose payload is a QueueID, matching the -// on-queue contract for this stage. -func queueDelivery(t *testing.T, ctrl *gomock.Controller, queue string) consumer.Delivery { - t.Helper() - payload, err := entity.QueueID{Name: queue}.ToBytes() - require.NoError(t, err) - msg := entityqueue.NewMessage(queue, payload, queue, nil) - d := queuemock.NewMockDelivery(ctrl) - d.EXPECT().Message().Return(msg).AnyTimes() - d.EXPECT().Attempt().Return(1).AnyTimes() - return d -} - -func TestController_Identity(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - assert.Equal(t, "prioritize", h.controller.Name()) - assert.Equal(t, topickey.TopicKeyPrioritize, h.controller.TopicKey()) - assert.Equal(t, "orchestrator-prioritize", h.controller.ConsumerGroup()) - - var _ consumer.Controller = h.controller -} - -// TestController_Process_NoSpeculatingBatches verifies an empty queue just -// acks without touching the prioritizer or publishing anything. -func TestController_Process_NoSpeculatingBatches(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return(nil, nil) - - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.NoError(t, err) -} - -// TestController_Process_TreeMissingSkipsBatch verifies a batch with no -// speculation tree yet (not yet speculated on) is skipped rather than -// erroring the whole round. -func TestController_Process_TreeMissingSkipsBatch(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - batch := entity.Batch{ID: "q/batch/1", Queue: "q", State: entity.BatchStateSpeculating} - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return([]entity.Batch{batch}, nil) - h.treeStore.EXPECT().Get(gomock.Any(), "q/batch/1").Return(entity.SpeculationTree{}, storage.ErrNotFound) - - // No prioritizer, no publish expected — the harness mocks fail the test if called unexpectedly. - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.NoError(t, err) -} - -// TestController_Process_PromoteApplied verifies a Promote decision on a -// Selected path transitions it to Prioritized, persists the tree with the -// correct old/new version pair, and republishes the batch to build. -func TestController_Process_PromoteApplied(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - path := entity.SpeculationPath{Base: nil, Head: "q/batch/1"} - batch := entity.Batch{ID: "q/batch/1", Queue: "q", State: entity.BatchStateSpeculating} - tree := entity.SpeculationTree{ - BatchID: "q/batch/1", - Version: 3, - Paths: []entity.SpeculationPathInfo{{ID: "q/batch/1/path/0", Path: path, Status: entity.SpeculationPathStatusSelected, Score: 0.9}}, - } - - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return([]entity.Batch{batch}, nil) - h.treeStore.EXPECT().Get(gomock.Any(), "q/batch/1").Return(tree, nil) - h.prio.EXPECT().Prioritize(gomock.Any(), gomock.Any()).Return([]entity.PathDecision{ - {PathID: "q/batch/1/path/0", Action: entity.SpeculationPathActionPromote}, - }, nil) - h.treeStore.EXPECT(). - Update(gomock.Any(), "q/batch/1", int32(3), int32(4), gomock.AssignableToTypeOf([]entity.SpeculationPathInfo{})). - DoAndReturn(func(_ context.Context, _ string, _, _ int32, paths []entity.SpeculationPathInfo) error { - require.Len(t, paths, 1) - assert.Equal(t, entity.SpeculationPathStatusPrioritized, paths[0].Status) - return nil - }) - h.buildPub.EXPECT(). - Publish(gomock.Any(), "build", gomock.AssignableToTypeOf(entityqueue.Message{})). - DoAndReturn(func(_ context.Context, _ string, msg entityqueue.Message) error { - bid, err := entity.BatchIDFromBytes(msg.Payload) - require.NoError(t, err) - assert.Equal(t, "q/batch/1", bid.ID) - assert.Equal(t, "q", msg.PartitionKey) - return nil - }) - - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.NoError(t, err) -} - -// TestController_Process_IllegalDecisionSkipped verifies a decision that does -// not match any loaded path is dropped without an Update call, and nothing -// is published (no path reached Prioritized). -func TestController_Process_IllegalDecisionSkipped(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - path := entity.SpeculationPath{Head: "q/batch/1"} - batch := entity.Batch{ID: "q/batch/1", Queue: "q", State: entity.BatchStateSpeculating} - tree := entity.SpeculationTree{ - BatchID: "q/batch/1", - Version: 1, - Paths: []entity.SpeculationPathInfo{{ID: "q/batch/1/path/0", Path: path, Status: entity.SpeculationPathStatusSelected}}, - } - - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return([]entity.Batch{batch}, nil) - h.treeStore.EXPECT().Get(gomock.Any(), "q/batch/1").Return(tree, nil) - // Cancel on a Selected path is not a legal transition: skip it. - h.prio.EXPECT().Prioritize(gomock.Any(), gomock.Any()).Return([]entity.PathDecision{ - {PathID: "q/batch/1/path/0", Action: entity.SpeculationPathActionCancel}, - }, nil) - // No treeStore.Update, no publish expected. - - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.NoError(t, err) -} - -// TestController_Process_CancelBuildingCapturesIntent verifies Cancel on a -// Building path only flips it to Cancelling in the tree — intent capture, -// never a runner call from this controller — and that a build message is -// republished for the batch so the build stage enacts the cancel. -func TestController_Process_CancelBuildingCapturesIntent(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - path := entity.SpeculationPath{Head: "q/batch/1"} - batch := entity.Batch{ID: "q/batch/1", Queue: "q", State: entity.BatchStateSpeculating} - tree := entity.SpeculationTree{ - BatchID: "q/batch/1", - Version: 2, - Paths: []entity.SpeculationPathInfo{ - {ID: "q/batch/1/path/0", Path: path, Status: entity.SpeculationPathStatusBuilding, BuildID: "build-1"}, - }, - } - - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return([]entity.Batch{batch}, nil) - h.treeStore.EXPECT().Get(gomock.Any(), "q/batch/1").Return(tree, nil) - h.prio.EXPECT().Prioritize(gomock.Any(), gomock.Any()).Return([]entity.PathDecision{ - {PathID: "q/batch/1/path/0", Action: entity.SpeculationPathActionCancel}, - }, nil) - h.treeStore.EXPECT(). - Update(gomock.Any(), "q/batch/1", int32(2), int32(3), gomock.AssignableToTypeOf([]entity.SpeculationPathInfo{})). - DoAndReturn(func(_ context.Context, _ string, _, _ int32, paths []entity.SpeculationPathInfo) error { - require.Len(t, paths, 1) - assert.Equal(t, entity.SpeculationPathStatusCancelling, paths[0].Status) - return nil - }) - // The Cancelling path is a persisted intent the build stage must enact, - // so a build message is republished for the batch. - h.buildPub.EXPECT().Publish(gomock.Any(), "build", gomock.Any()).Return(nil) - - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.NoError(t, err) -} - -// TestController_Process_CancelPrioritizedNoRunnerCall verifies Cancel on a -// Prioritized path (no build started yet) drops straight to Cancelled — -// there is no in-flight work, so no intent to hand to the build stage and no -// build republish. -func TestController_Process_CancelPrioritizedNoRunnerCall(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - path := entity.SpeculationPath{Head: "q/batch/1"} - batch := entity.Batch{ID: "q/batch/1", Queue: "q", State: entity.BatchStateSpeculating} - tree := entity.SpeculationTree{ - BatchID: "q/batch/1", - Version: 5, - Paths: []entity.SpeculationPathInfo{ - {ID: "q/batch/1/path/0", Path: path, Status: entity.SpeculationPathStatusPrioritized}, - }, - } - - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return([]entity.Batch{batch}, nil) - h.treeStore.EXPECT().Get(gomock.Any(), "q/batch/1").Return(tree, nil) - h.prio.EXPECT().Prioritize(gomock.Any(), gomock.Any()).Return([]entity.PathDecision{ - {PathID: "q/batch/1/path/0", Action: entity.SpeculationPathActionCancel}, - }, nil) - h.treeStore.EXPECT(). - Update(gomock.Any(), "q/batch/1", int32(5), int32(6), gomock.AssignableToTypeOf([]entity.SpeculationPathInfo{})). - DoAndReturn(func(_ context.Context, _ string, _, _ int32, paths []entity.SpeculationPathInfo) error { - require.Len(t, paths, 1) - assert.Equal(t, entity.SpeculationPathStatusCancelled, paths[0].Status) - return nil - }) - - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.NoError(t, err) -} - -// TestController_Process_VersionMismatchErrors verifies ErrVersionMismatch -// from the tree Update surfaces as an error (nack; the round is recomputed -// on redelivery). -func TestController_Process_VersionMismatchErrors(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - path := entity.SpeculationPath{Head: "q/batch/1"} - batch := entity.Batch{ID: "q/batch/1", Queue: "q", State: entity.BatchStateSpeculating} - tree := entity.SpeculationTree{ - BatchID: "q/batch/1", - Version: 1, - Paths: []entity.SpeculationPathInfo{{ID: "q/batch/1/path/0", Path: path, Status: entity.SpeculationPathStatusSelected}}, - } - - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return([]entity.Batch{batch}, nil) - h.treeStore.EXPECT().Get(gomock.Any(), "q/batch/1").Return(tree, nil) - h.prio.EXPECT().Prioritize(gomock.Any(), gomock.Any()).Return([]entity.PathDecision{ - {PathID: "q/batch/1/path/0", Action: entity.SpeculationPathActionPromote}, - }, nil) - h.treeStore.EXPECT(). - Update(gomock.Any(), "q/batch/1", int32(1), int32(2), gomock.Any()). - Return(storage.ErrVersionMismatch) - - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.Error(t, err) - assert.True(t, errors.Is(err, storage.ErrVersionMismatch)) -} - -// TestController_Process_PrioritizerErrors verifies a Prioritize failure -// surfaces as an error without touching storage further. -func TestController_Process_PrioritizerErrors(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - path := entity.SpeculationPath{Head: "q/batch/1"} - batch := entity.Batch{ID: "q/batch/1", Queue: "q", State: entity.BatchStateSpeculating} - tree := entity.SpeculationTree{ - BatchID: "q/batch/1", - Version: 1, - Paths: []entity.SpeculationPathInfo{{ID: "q/batch/1/path/0", Path: path, Status: entity.SpeculationPathStatusSelected}}, - } - - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return([]entity.Batch{batch}, nil) - h.treeStore.EXPECT().Get(gomock.Any(), "q/batch/1").Return(tree, nil) - h.prio.EXPECT().Prioritize(gomock.Any(), gomock.Any()).Return(nil, errors.New("policy boom")) - - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.Error(t, err) -} - -// TestController_Process_RepublishesPreExistingPrioritized verifies a batch -// whose tree already has a Prioritized path with no BuildID gets republished -// to build even when the prioritizer returns zero decisions this round — -// self-healing a dropped build message. -func TestController_Process_RepublishesPreExistingPrioritized(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - path := entity.SpeculationPath{Head: "q/batch/1"} - batch := entity.Batch{ID: "q/batch/1", Queue: "q", State: entity.BatchStateSpeculating} - tree := entity.SpeculationTree{ - BatchID: "q/batch/1", - Version: 4, - Paths: []entity.SpeculationPathInfo{ - {ID: "q/batch/1/path/0", Path: path, Status: entity.SpeculationPathStatusPrioritized, BuildID: ""}, - }, - } - - h.batchStore.EXPECT(). - GetByQueueAndStates(gomock.Any(), "q", []entity.BatchState{entity.BatchStateSpeculating}). - Return([]entity.Batch{batch}, nil) - h.treeStore.EXPECT().Get(gomock.Any(), "q/batch/1").Return(tree, nil) - h.prio.EXPECT().Prioritize(gomock.Any(), gomock.Any()).Return(nil, nil) - // No treeStore.Update expected: nothing changed this round. - h.buildPub.EXPECT(). - Publish(gomock.Any(), "build", gomock.AssignableToTypeOf(entityqueue.Message{})). - DoAndReturn(func(_ context.Context, _ string, msg entityqueue.Message) error { - bid, err := entity.BatchIDFromBytes(msg.Payload) - require.NoError(t, err) - assert.Equal(t, "q/batch/1", bid.ID) - return nil - }) - - err := h.controller.Process(context.Background(), queueDelivery(t, ctrl, "q")) - require.NoError(t, err) -} - -func TestController_Process_MalformedPayload(t *testing.T) { - ctrl := gomock.NewController(t) - h := newTestHarness(t, ctrl) - - msg := entityqueue.NewMessage("bad", []byte(`{"invalid"`), "q", nil) - d := queuemock.NewMockDelivery(ctrl) - d.EXPECT().Message().Return(msg).AnyTimes() - d.EXPECT().Attempt().Return(1).AnyTimes() - - err := h.controller.Process(context.Background(), d) - require.Error(t, err) -} diff --git a/test/integration/submitqueue/extension/storage/suite.go b/test/integration/submitqueue/extension/storage/suite.go index a07d7cbe..dd4d0b03 100644 --- a/test/integration/submitqueue/extension/storage/suite.go +++ b/test/integration/submitqueue/extension/storage/suite.go @@ -382,146 +382,19 @@ func (s *StorageContractSuite) TestStorage_ChangeCreate_EmptyDetails() { assert.Equal(t, entity.ChangeDetails{}, got[0].Details) } -// sampleSpeculationTree returns a representative tree for the given batch: a -// fallback path (build alone), a speculative path in flight on an assumed-good -// base, a budget-cleared path, and a path being cancelled — exercising every -// SpeculationPathInfo field and the intent statuses (Prioritized, Cancelling). -func sampleSpeculationTree(batchID string) entity.SpeculationTree { - return entity.SpeculationTree{ - BatchID: batchID, - Paths: []entity.SpeculationPathInfo{ - { - Path: entity.SpeculationPath{Base: nil, Head: batchID}, - Score: 0.5, - Status: entity.SpeculationPathStatusCandidate, - }, - { - Path: entity.SpeculationPath{Base: []string{"q/batch/1", "q/batch/2"}, Head: batchID}, - Score: 0.25, - Status: entity.SpeculationPathStatusBuilding, - BuildID: "build-42", - }, - { - Path: entity.SpeculationPath{Base: []string{"q/batch/1"}, Head: batchID}, - Score: 0.4, - Status: entity.SpeculationPathStatusPrioritized, - }, - { - Path: entity.SpeculationPath{Base: []string{"q/batch/2"}, Head: batchID}, - Score: 0.1, - Status: entity.SpeculationPathStatusCancelling, - BuildID: "build-43", - }, - }, - Version: 1, - } -} - -// TestStorage_SpeculationCreateAndGet verifies a tree round-trips through the -// store preserving every path field (Base/Head, Score, Status, BuildID). -func (s *StorageContractSuite) TestStorage_SpeculationCreateAndGet() { - t := s.T() - ctx := s.ctx - - tree := sampleSpeculationTree("spec/create-get") - - require.NoError(t, s.storage.GetSpeculationTreeStore().Create(ctx, tree)) - - retrieved, err := s.storage.GetSpeculationTreeStore().Get(ctx, tree.BatchID) - require.NoError(t, err) - assert.Equal(t, tree, retrieved, "speculation tree should round-trip unchanged") -} - -// TestStorage_SpeculationCreateDuplicate verifies a repeated Create for the same -// batch returns ErrAlreadyExists. -func (s *StorageContractSuite) TestStorage_SpeculationCreateDuplicate() { - t := s.T() - ctx := s.ctx - - tree := sampleSpeculationTree("spec/duplicate") - - require.NoError(t, s.storage.GetSpeculationTreeStore().Create(ctx, tree)) - - err := s.storage.GetSpeculationTreeStore().Create(ctx, tree) - assert.ErrorIs(t, err, storage.ErrAlreadyExists, "duplicate create should return ErrAlreadyExists") -} - -// TestStorage_SpeculationUpdate verifies Update overwrites the entire set of -// paths for a batch (the controller persists the whole tree each respeculate) -// under the version guard, and that a stale version is rejected with -// ErrVersionMismatch without modifying the stored tree. -func (s *StorageContractSuite) TestStorage_SpeculationUpdate() { - t := s.T() - ctx := s.ctx - - tree := sampleSpeculationTree("spec/update") - require.NoError(t, s.storage.GetSpeculationTreeStore().Create(ctx, tree)) - - // Respeculate: the speculative base broke, so its path is cancelled and the - // fallback advanced to passed — a wholesale replacement of the paths. - updatedPaths := []entity.SpeculationPathInfo{ - { - Path: entity.SpeculationPath{Base: nil, Head: tree.BatchID}, - Score: 0.75, - Status: entity.SpeculationPathStatusPassed, - BuildID: "build-99", - }, - } - require.NoError(t, s.storage.GetSpeculationTreeStore().Update(ctx, tree.BatchID, tree.Version, tree.Version+1, updatedPaths)) - - retrieved, err := s.storage.GetSpeculationTreeStore().Get(ctx, tree.BatchID) - require.NoError(t, err) - want := entity.SpeculationTree{BatchID: tree.BatchID, Paths: updatedPaths, Version: tree.Version + 1} - assert.Equal(t, want, retrieved, "Update should overwrite the whole tree and bump the version") - - // A write against the already-consumed version must fail and leave the - // stored tree untouched. - err = s.storage.GetSpeculationTreeStore().Update(ctx, tree.BatchID, tree.Version, tree.Version+1, sampleSpeculationTree(tree.BatchID).Paths) - assert.ErrorIs(t, err, storage.ErrVersionMismatch, "stale update should return ErrVersionMismatch") - - retrieved, err = s.storage.GetSpeculationTreeStore().Get(ctx, tree.BatchID) - require.NoError(t, err) - assert.Equal(t, want, retrieved, "stale update should not modify the tree") -} - -// TestStorage_SpeculationGetNotFound verifies Get for an unknown batch returns ErrNotFound. -func (s *StorageContractSuite) TestStorage_SpeculationGetNotFound() { - t := s.T() - ctx := s.ctx - - _, err := s.storage.GetSpeculationTreeStore().Get(ctx, "spec/nonexistent") - assert.ErrorIs(t, err, storage.ErrNotFound, "Get for unknown batch should return ErrNotFound") -} - -// TestStorage_SpeculationUpdateNotFound verifies Update for an unknown batch -// returns ErrVersionMismatch — the conditional write matches no row, and the -// store cannot distinguish a missing tree from a stale version. -func (s *StorageContractSuite) TestStorage_SpeculationUpdateNotFound() { - t := s.T() - ctx := s.ctx - - tree := sampleSpeculationTree("spec/update-nonexistent") - err := s.storage.GetSpeculationTreeStore().Update(ctx, tree.BatchID, tree.Version, tree.Version+1, tree.Paths) - assert.ErrorIs(t, err, storage.ErrVersionMismatch, "Update for unknown batch should return ErrVersionMismatch") -} - // TestStorage_BuildCreateAndGet verifies a build round-trips through the -// store: ID is the runner-minted build identifier (assigned by the build -// runner, not derived from the path), SpeculationPathID links the build back -// to the speculation-tree path it verifies, and Get by ID finds the build -// with both round-tripped intact. +// store: ID is the runner-minted build identifier, and Get by ID finds the +// build with every field intact. func (s *StorageContractSuite) TestStorage_BuildCreateAndGet() { t := s.T() ctx := s.ctx const batchID = "q/batch/create-and-get" const buildID = "runner/create-and-get/42" - const pathID = "spec/create-and-get/path-1" build := entity.Build{ - ID: buildID, - BatchID: batchID, - SpeculationPathID: pathID, - Status: entity.BuildStatusRunning, + ID: buildID, + BatchID: batchID, + Status: entity.BuildStatusRunning, } require.NoError(t, s.storage.GetBuildStore().Create(ctx, build)) @@ -531,62 +404,6 @@ func (s *StorageContractSuite) TestStorage_BuildCreateAndGet() { assert.Equal(t, build.ID, got.ID) assert.Equal(t, build.BatchID, got.BatchID) assert.Equal(t, build.Status, got.Status) - assert.Equal(t, build.SpeculationPathID, got.SpeculationPathID, "SpeculationPathID should round-trip") - assert.NotEqual(t, build.ID, build.SpeculationPathID, "build ID and speculation path ID are distinct identifiers") -} - -// TestStorage_SpeculationPathBuildCreateAndGet verifies a path->build mapping -// round-trips through the store unchanged. -func (s *StorageContractSuite) TestStorage_SpeculationPathBuildCreateAndGet() { - t := s.T() - ctx := s.ctx - - pb := entity.SpeculationPathBuild{ - PathID: "spec/path-build/create-get", - BuildID: "runner/path-build/create-get/1", - BatchID: "q/batch/path-build-create-get", - Version: 1, - CreatedAt: 1700000000000, - } - - require.NoError(t, s.storage.GetSpeculationPathBuildStore().Create(ctx, pb)) - - got, err := s.storage.GetSpeculationPathBuildStore().Get(ctx, pb.PathID) - require.NoError(t, err) - assert.Equal(t, pb, got, "path build mapping should round-trip unchanged") -} - -// TestStorage_SpeculationPathBuildCreateDuplicate verifies a repeated Create -// for the same PathID returns ErrAlreadyExists — a path maps to at most one -// build, and the first-written mapping wins. -func (s *StorageContractSuite) TestStorage_SpeculationPathBuildCreateDuplicate() { - t := s.T() - ctx := s.ctx - - pb := entity.SpeculationPathBuild{ - PathID: "spec/path-build/duplicate", - BuildID: "runner/path-build/duplicate/1", - BatchID: "q/batch/path-build-duplicate", - Version: 1, - CreatedAt: 1700000000000, - } - - require.NoError(t, s.storage.GetSpeculationPathBuildStore().Create(ctx, pb)) - - other := pb - other.BuildID = "runner/path-build/duplicate/2" - err := s.storage.GetSpeculationPathBuildStore().Create(ctx, other) - assert.ErrorIs(t, err, storage.ErrAlreadyExists, "duplicate create should return ErrAlreadyExists") -} - -// TestStorage_SpeculationPathBuildGetNotFound verifies Get for an unknown -// path ID returns ErrNotFound. -func (s *StorageContractSuite) TestStorage_SpeculationPathBuildGetNotFound() { - t := s.T() - ctx := s.ctx - - _, err := s.storage.GetSpeculationPathBuildStore().Get(ctx, "spec/path-build/nonexistent") - assert.ErrorIs(t, err, storage.ErrNotFound, "Get for unknown path ID should return ErrNotFound") } func (s *StorageContractSuite) TestStorage_RequestSummaryCreateGetAndCAS() {