#!/usr/bin/env -S deno run --allow-read --allow-env --allow-net --allow-write /** * The publisher: reads the record store, folds what is new into a rolling * aggregate, scores everything, and creates one manifest object. * * deno run -A tasks/test-selection-publish.ts [--days N] [--bootstrap] * [--out ] [--dry-run] [--concurrency N] * deno run -A tasks/test-selection-publish.ts --create * * `--create ` creates the manifest and the aggregate a * `--dry-run --out ` run wrote, and reads nothing else. The fold * needs no credential, so it can finish, however long it takes, before * the credential for creating is obtained. * * A run folds what the newest aggregate it can read does not already * hold, from the last day that aggregate read. A cold start has no * aggregate to read, so `--bootstrap` starts from nothing over a wider * window, run by hand. It refuses over a store holding an aggregate any * publisher can read. Its new real test failures accumulate over * unbounded history, and no window of records rebuilds them. * * A store with no aggregate under the area at all is what asks for it. * Nothing that happens to a stored aggregate does: the area they are * written under is named rather than numbered and does not move, a body * written in a shape under this publisher's is read forward, and one this * publisher cannot read is passed over for the newest one behind it that * it can. * * A stored aggregate whose new real test failures were credited under * rules other than these (see `NEW_REAL_TEST_FAILURE_RULE`) is the one an * ordinary run does not fold onto. It folds every day that aggregate's * history holds again instead, into an empty one, since a new real test * failure is held as a count that cannot be judged again by itself, and * every record behind it is still in the store. * * That is the whole of what the flag does. What to read is `inputChoice` * asked of each source and date the window covers, and both modes ask it * alike; their answers differ because their aggregates differ, and not * because one of them chooses inputs a second way. * * When the publisher fails nothing breaks: the previous manifest is still * the newest one and lanes keep using it. A manifest going stale degrades * selection slowly rather than failing anything, which is the right * direction for a system nothing should gate on. */ import { duration } from "./test-selection/duration.ts"; import { join } from "@std/path"; import { ulid } from "@std/ulid"; import { createObject, gzipText, listObjects, loadAliasResolver, objectUrl, readObject, type StoredReport, testIdentityKey, testIdentityOfKey, } from "@commonfabric/test-support/records"; import { maxOf, minOf } from "@commonfabric/utils/math"; import { ciSubmissionsPrefix, storeBucket, storePrefix, } from "./test-records-config.ts"; import { rollupShards } from "./test-records-compact.ts"; import { calibrate, laneObservations } from "./test-selection/calibrate.ts"; import { calibrationHealth, healthLines } from "./test-selection/health.ts"; import { type AggregateState, buildManifest, CI_SOURCE, coldStart, dayOf, departed, emptyAggregate, firstDay, Fold, lastReadDay, locateSurfaces, parseAggregate, partitionOf, surfaceName, type Unplaced, } from "./test-selection/build.ts"; import { capabilitiesBySuite, loadTopology, wholeUnits, } from "./test-topology.ts"; import { baselinesOf, mergeBaselines } from "./test-selection/baselines.ts"; import { daysBetween, NEW_REAL_TEST_FAILURE_RULE, } from "./test-selection/score.ts"; import { measuredCostLines } from "./test-selection/coverage.ts"; import type { Suite } from "./test-topology/suite.ts"; import { fetchManifest, manifestBody, manifestObjectName, manifestPrefix, newestAtOrBefore, stateDayOf, stateObjectName, statePrefix, } from "./test-selection/store.ts"; import { type CoverageBaseline, declaredSchema, type Manifest, MANIFEST_SCHEMA_VERSION, parseManifest, serializeManifest, writtenAhead, } from "./test-selection/manifest.ts"; import { costliestUnschedulable, plan } from "./test-selection/plan.ts"; import { gatesOf } from "./test-selection/census.ts"; import { COST_WINDOW_DAYS, LANE_BUDGET_SECONDS, } from "./test-selection/policy.ts"; /** * Everything this reaches the world through. The default is the real * store; a test supplies its own and exercises the whole publish without * a network, which is the only way the path that actually runs in * production gets tested at all. */ export interface StoreAccess { list(prefix: string): Promise; read(objectName: string): Promise; readText(objectName: string): Promise; create(name: string, body: Uint8Array): Promise; /** * The objects a day's rollup was written as, when the day has a * complete one. A day is compacted into shards sized so that a reader * can hold what one decompresses to, and the day's manifest — written * after every shard it names — is what says the rollup is finished and * which shards it holds. Reading those replaces reading the day's * thousands of raw objects. * * Neither mode asks for one because of the flag it was given: the rule * asks wherever a source and date is one nothing has been folded from * yet. In the steady state the incremental path never reaches such a * day, because compaction waits a week for late arrivals and that path * reads two days, so in practice this answers a bootstrap and a run * catching up after an outage. * * A rollup is a read optimization rather than the record of its day — * an object arriving after its shard is written stays in the raw area * alone — so a pair taken this way keeps only what the rollup held. * That is also what makes a rollup safe to give up on: a day whose * shards will not read is read from its raw objects instead, which is * how a day with no rollup at all is read, and no record of that day * is lost by it. What it costs is in * `docs/development/test-selection.md`. */ rollupShards(day: string): Promise; /** The credential for creating an object, when one is reachable. */ token(): string | undefined; } /** The store as it really is. */ export function liveStore(bucket: string): StoreAccess { return { list: (prefix) => listObjects({ bucket, prefix }), read: (objectName) => readObject({ bucket, objectName }), readText: async (objectName) => { const response = await fetch(objectUrl(bucket, objectName)); if (!response.ok) throw new Error(`HTTP ${response.status}`); // Transcoded on the way out, so this is already the JSON. return await response.text(); }, create: async (name, body) => { const token = writeToken(); if (token === undefined) throw new Error("no write credential"); await createObject({ bucket, name, body, contentType: "application/json", contentEncoding: "gzip", token, }); }, rollupShards: async (day) => { try { return await rollupShards({ bucket, day }); } catch { return undefined; } }, token: writeToken, }; } /** * What a run says where it has read part of its window and will not * publish from that. A manifest going stale degrades selection slowly, * where one built from part of a window scores every identity in the * part it missed as though it had not run. */ const PARTIAL_WINDOW = "test selection: refusing to publish from part of the window. The " + "previous manifest is still the newest one."; /** How many objects are fetched at once. */ const DEFAULT_CONCURRENCY = 24; /** * The shortest window the incremental path reads, and how far back it * passes over a stored state it cannot read. It reads from the last day * its aggregate read where that reaches further. */ const DEFAULT_DAYS = 2; /** Days a bootstrap reads, when no window was asked for. */ const BOOTSTRAP_DAYS = 60; /** Raw objects read before the next batch is fetched. */ export const CHUNK = 200; /** * Rollup shards read and spooled before the next batch is fetched. * Each shard targets eight mebibytes compressed. */ export const SHARD_CHUNK = 4; /** What the command line asked for. */ export interface Options { days: number; bootstrap: boolean; dryRun: boolean; out?: string; concurrency: number; /** A directory a `--dry-run --out` run wrote, to create from. */ create?: string; } export function parseArgs(args: readonly string[]): Options | undefined { const options: Options = { days: DEFAULT_DAYS, bootstrap: false, dryRun: false, concurrency: DEFAULT_CONCURRENCY, }; // A window somebody asked for wins over the one a bootstrap implies, // whichever order the two were typed in. let daysGiven = false; for (let i = 0; i < args.length; i++) { const flag = args[i]!; switch (flag) { case "--bootstrap": options.bootstrap = true; if (!daysGiven) options.days = BOOTSTRAP_DAYS; break; case "--dry-run": options.dryRun = true; break; case "--days": { const days = Number(args[++i]); if (!Number.isInteger(days) || days < 1) return undefined; options.days = days; daysGiven = true; break; } case "--concurrency": { const concurrency = Number(args[++i]); if (!Number.isInteger(concurrency) || concurrency < 1) return undefined; options.concurrency = concurrency; break; } case "--out": { const out = args[++i]; if (out === undefined) return undefined; options.out = out; break; } case "--create": { const create = args[++i]; if (create === undefined) return undefined; options.create = create; break; } default: return undefined; } } // Creating reads nothing but the directory, so every other flag // alongside it is one it would ignore. if (options.create !== undefined && args.length !== 2) return undefined; return options; } /** The day partitions to list, newest last. */ export function dayPartitions(today: Date, days: number): string[] { const partitions: string[] = []; for (let back = days - 1; back >= 0; back--) { const day = new Date(today.getTime() - back * 86_400_000); partitions.push(day.toISOString().slice(0, 10).replaceAll("-", "/")); } return partitions; } /** Runs a mapper over items, at most `limit` of them at a time. */ async function mapConcurrent( items: readonly T[], limit: number, map: (item: T) => Promise, ): Promise { const results: R[] = new Array(items.length); let next = 0; const workers = Array.from( { length: Math.min(limit, items.length) }, async () => { while (next < items.length) { const index = next++; results[index] = await map(items[index]!); } }, ); await Promise.all(workers); return results; } /** * Every local submission object of the days given. A local object's path * puts the reporting person ahead of the day, so that area is listed once * and filtered. */ async function listLocal( store: StoreAccess, days: readonly string[], ): Promise { const wanted = new Set(days); const names: string[] = []; for (const name of await store.list(`${storePrefix()}/submissions/local/`)) { const day = partitionOf(name); if (day.length > 0 && wanted.has(day)) names.push(name); } return names; } /** What one source and date still owes the aggregate. */ export type InputChoice = "settled" | "rollup" | "raw"; /** * What one source and date still has to give, which is the whole of how * this program chooses what to read. * * A bootstrap and an ordinary run apply it alike. A bootstrap is * permission to start from an empty aggregate and, by default, a wider * window; it is not a second way to choose inputs. Both then ask this of * each source and date the window covers, and the answers differ only * because the aggregates differ. * * `settled` — a receipt says this pair's rollup is folded. Rollups of * this format carry no record of which arrivals they cover, so nothing * can say how much a raw object of that pair would repeat. The pair is * closed rather than combined with objects whose overlap is unknown. * * `rollup` — nothing of this pair is folded and a rollup covers it, so * the rollup is the baseline and a receipt is written for it. One object * stands in for the day's thousands, which is what makes a cold start * over a wide window affordable at all. * * `raw` — everything else, and two different things reach it. A pair * with raw contributions already stays raw, because a rollup written * afterwards would overlap them. A pair no rollup covers is raw because * there is nothing else to read, which is every local source: rollups * cover the continuous-integration area alone. */ export function inputChoice( known: { settled: boolean; foldedRaw: boolean; rollup: boolean }, ): InputChoice { if (known.settled) return "settled"; if (known.foldedRaw || !known.rollup) return "raw"; return "rollup"; } /** One state object a run could not read, and what stopped it. */ interface PassedOver { /** The object's name, so a reader can go and look at it. */ name: string; /** * The shape it declares, for the one fault that names its own remedy: * a body written further ahead than this publisher. Undefined for * every other fault, which is a body that is not an aggregate at all. */ ahead?: number; } /** Why a state was passed over, as a sentence fragment. */ function faultOf({ ahead }: PassedOver): string { return ahead === undefined ? "it is not an aggregate this publisher understands" : `it is written in shape ${ahead}, and this publisher reads shape ` + `${MANIFEST_SCHEMA_VERSION}`; } /** What reading the rolling aggregate found. */ type AggregateRead = /** The state to fold onto, and what was passed over to reach it. */ | { state: AggregateState; name: string; passedOver: readonly PassedOver[] } /** The area holds no state at all, so this would be a first run. */ | { absent: true } /** The store did not answer, with the line saying what it was asked. */ | { unreachable: string } /** * Every state looked at was one this cannot read. `cutOffAt` is the * first day the run reads, given where the day bound is what stopped * the walk and absent where the walk ran out of states. */ | { unreadable: readonly PassedOver[]; cutOffAt?: string }; /** * Reads the aggregate to fold onto, which is the newest state object * this publisher can read. * * The outcomes are kept apart because folding into an empty aggregate while a * real one exists is the worst thing this program can do. `catches` accumulates * over unbounded history and is the whole of what makes a test worth running, * and the state object is where that history lives. A run that lost it and * carried on would publish a manifest scoring every test at the floor, and * because it succeeded that manifest would become the one every lane obeys. * Failing is the designed outcome — the previous manifest stays newest and * selection decays slowly — so an unreadable state is told apart from an absent * one rather than both becoming an empty one. * * A run starts from an empty aggregate while a state exists in two cases. * An incremental run folds every day of a state's history into one when * the state's new real test failures were credited under other rules (see * `NEW_REAL_TEST_FAILURE_RULE`), which reads every record those came from, * so none of them is lost. A bootstrap starts from one where this publisher * can read no state and none is written in a shape from further ahead, * which a publisher at that shape would read. * * Passing over a state this publisher cannot read is what keeps one from * stopping it for good. Nothing but the publisher creates a state, and it * creates one only where it folded, so a newest state it cannot read is * one every later run comes to in the same condition: a body written in * a shape from further ahead, which is what a lowered shape leaves * behind, and a body that arrives and is not an aggregate, which the * store's create-only credentials mean nothing can replace. * * A read that does not arrive is neither of those and is refused, as a * listing that fails is. It says nothing about the object, so a run that * passed over on it would be reading a fault of its own as a fact about * the store, and the state it then wrote would supersede the one it * skipped for good. * * `from` is the first day of the window an incremental run was asked for, and * it bounds how far back a state may be passed over; the run then reads further * back where the state it folds onto last read a day before that. A bootstrap * gives none, since a state of any day it can read is one it refuses over. What * a passed-over state folded and the one behind it did not comes back from the * records, so the walk reaches back over the days this run reads and no * further. That bound is the one this run can state rather than an exact * account of the gap: the runs that wrote the passed-over states read windows * of their own, reaching a day earlier than their own day for as many days as * their window held, and a record that arrived for one of those earlier days * after the state behind it was written is outside what this run reads. A run * that passes over says which state it passed over and which it folded onto, * rather than reporting the gap as closed. The newest state is read whatever * day it carries, since taking it is not a choice between two aggregates. */ async function readAggregate( store: StoreAccess, from: string | undefined, ): Promise { const prefix = statePrefix(); let names: string[]; try { // Trailing slash for the reason the manifest listing carries one: a // bare prefix matches a longer sibling too. names = await store.list(`${prefix}/`); } catch (error) { return { unreachable: `listing ${prefix} failed: ${error}` }; } // Newest first, which for these names is by the day they carry and // then by the identifier the run that created them drew. An object // under the prefix named some other way is not one of the publisher's // states, so it is neither read as an aggregate nor counted as one. const states = names.filter((name) => stateDayOf(name) !== undefined) .sort().reverse(); if (states.length === 0) return { absent: true }; const passedOver: PassedOver[] = []; let cutOff = false; for (const [index, name] of states.entries()) { if (index > 0 && from !== undefined && stateDayOf(name)! < from) { cutOff = true; break; } let text: string; try { text = await store.readText(name); } catch (error) { return { unreachable: `reading ${name} failed: ${error}` }; } // Parsed here rather than inside each reader, so that one body is // parsed once however many questions are asked of it. let body: unknown; try { body = JSON.parse(text); } catch { body = undefined; } const state = parseAggregate(body); if (state !== undefined) return { state, name, passedOver }; const ahead = writtenAhead(body) ? declaredSchema(body) : undefined; passedOver.push({ name, ...(ahead === undefined ? {} : { ahead }) }); } return { unreadable: passedOver, ...(cutOff && from !== undefined ? { cutOffAt: from } : {}), }; } /** * What a run says when every state it looked at was one it could not * read: each object and its fault, then where there are more states to * reach and what reaches them, then the thing not to do. * * `cutOffAt` is the first day the run reads, given where the day bound * is what stopped the walk and absent where the walk ran out of states. * Only in the first case are there states behind these at all, and what * is said of them is that they are there, since whether one of them * reads is what the run that reaches them finds out. */ function refusal( passedOver: readonly PassedOver[], cutOffAt: string | undefined, ): readonly string[] { const lines = passedOver.map((entry) => `${entry.name}: ${faultOf(entry)}`); const shapes = passedOver.flatMap(({ ahead }) => ahead === undefined ? [] : [ahead] ); if (shapes.length > 0) { const highest = maxOf(shapes); lines.push( `deploy a publisher that reads shape ${highest} or ` + `above, which is the highest shape any of these is written in`, ); } if (cutOffAt !== undefined) { lines.push( `the states behind these are named for days before ${cutOffAt}, ` + `which is the first day this run reads, and --days is what ` + `reaches them`, ); } lines.push( `a bootstrap reads every stored state, of any day, and refuses where ` + `one reads or one is written in a shape from further ahead; it ` + `starts from nothing only where none is either`, ); return lines; } /** * The newest manifest, which the one this run creates follows. Its * coverage baselines are brought forward, and its charges are what this * run's are compared with. The objects a run folds are the ones no * earlier run folded, so the baselines they hold are added to its * baselines rather than standing in for them. * * Throws where the store could not be asked. The objects those baselines * came from are ones no later run folds again, so a manifest published * without them would hold none of them, and neither would any manifest * after it. Where the store answers that it holds no manifest this * publisher can read, there is nothing to follow: the baselines start * empty, and the ones in objects earlier runs folded do not come back. */ export async function livePrevious( now: Date, fetch?: typeof globalThis.fetch, ): Promise { const previous = await fetchManifest({ at: now.toISOString(), ...(fetch === undefined ? {} : { fetch }), }); if (previous.unreachable) { throw new Error(`reading the previous manifest failed: ${previous.absent}`); } return previous.manifest; } /** * The aggregate a run folds into, with the name of the stored state it * came from, or undefined where the run must stop, having said why. * * An incremental run folds onto the newest state it can read and stops * where there is none. A bootstrap is the run for a store holding no * aggregate any publisher can read: it starts from nothing, and refuses * over a store holding one, since a run folding onto that aggregate * keeps every new real test failure it holds and starting from nothing * keeps none. A state written in a shape from further ahead is history a * publisher can read once it is deployed, so a bootstrap stops at one of * those too. */ async function aggregateToFold( store: StoreAccess, options: Options, partitions: readonly string[], today: string, ): Promise<{ aggregate: AggregateState; name?: string } | undefined> { // The first day this run reads, written the way a state object's name // writes a day, which is how far back an incremental run may pass a // state over. const from = partitions[0]!.replaceAll("/", "-"); const read = await readAggregate( store, options.bootstrap ? undefined : from, ); if ("unreachable" in read) { console.warn(`test selection: ${read.unreachable}`); console.warn( "test selection: refusing to publish without reading the stored " + "aggregates. The previous manifest is still the newest one.", ); return undefined; } if ("state" in read && options.bootstrap) { // An incremental run reads the newest state whatever its day, and // passes over a later one only inside its window. const reach = daysBetween(stateDayOf(read.name)!, today) + 1; const widen = read.passedOver.length > 0 && reach > DEFAULT_DAYS; console.warn( `test selection: ${read.name} is an aggregate this publisher can ` + `read, and a bootstrap would throw away every new real test ` + `failure it holds. Run without --bootstrap to fold onto it` + (widen ? `, with --days ${reach} to reach it.` : "."), ); return undefined; } if ("unreadable" in read) { const ahead = read.unreadable.some(({ ahead }) => ahead !== undefined); if (!options.bootstrap || ahead) { for (const line of refusal(read.unreadable, read.cutOffAt)) { console.warn(`test selection: ${line}`); } console.warn( "test selection: refusing to publish from an empty aggregate, " + "which would score every test at the floor. The previous " + "manifest is still the newest one.", ); return undefined; } } if ("absent" in read && !options.bootstrap) { console.warn( "test selection: no aggregate exists yet. A first run reads the " + "whole window and is asked for deliberately, with --bootstrap.", ); return undefined; } if (!("state" in read)) { for (const entry of "unreadable" in read ? read.unreadable : []) { console.log( `test selection: passing over ${entry.name}: ${faultOf(entry)}`, ); } console.log( "absent" in read ? "test selection: the store holds no aggregate, so this bootstrap " + "starts from nothing" : "test selection: no stored aggregate can be read by any " + "publisher, so this bootstrap starts from nothing", ); return { aggregate: coldStart(partitions[0]!, today) }; } for (const entry of read.passedOver) { console.log( `test selection: passing over ${entry.name}: ${faultOf(entry)}`, ); } if (read.passedOver.length > 0) { console.log( `test selection: folding onto ${read.name}, the newest aggregate ` + `this publisher can read. What the states above it folded from ` + `days this run does not read is not counted.`, ); } return { aggregate: read.state, name: read.name }; } /** * An access token for creating a manifest, when one is reachable. Only * the workflow has one: the publisher's identity is federated and pinned * to that workflow file, and it is the sole principal holding create on * the selection area. A person's own reporting key is scoped to their * `submissions/local//` folder and cannot write a manifest, so * there is no fallback to offer — `--dry-run --out` is how a person sees * what a run would produce. */ export function writeToken(): string | undefined { const federated = Deno.env.get("TEST_RECORDS_GCS_TOKEN"); return federated !== undefined && federated.length > 0 ? federated : undefined; } export async function publish( args: readonly string[], store: StoreAccess = liveStore(storeBucket()), now: Date = new Date(), topology: () => Promise = () => loadTopology(), previousManifest: (now: Date) => Promise = livePrevious, ): Promise { const options = parseArgs(args); if (options === undefined) { console.error( "usage: test-selection-publish.ts [--days N] [--bootstrap] " + "[--out ] [--dry-run] [--concurrency N] | --create ", ); return 2; } if (options.create !== undefined) { return await createFrom(options.create, store); } const startedAt = now; const today = startedAt.toISOString().slice(0, 10); const asked = dayPartitions(startedAt, options.days); let previous: Manifest | undefined; try { previous = await previousManifest(startedAt); } catch (error) { console.warn(`test selection: ${error}`); console.warn( "test selection: refusing to publish without the coverage baselines " + "the previous manifest carries. The previous manifest is still the " + "newest one.", ); return 1; } const chosen = await aggregateToFold(store, options, asked, today); if (chosen === undefined) return 1; let aggregate = chosen.aggregate; // An incremental run reads from the last day its aggregate read, however // far back that is, so that a stopped publisher catches up on its own // and no day is left unread behind the days it folds. That day is read // again because the run that read it read it before it was over; what // that run folded is passed over. let from = lastReadDay(aggregate); if ( !options.bootstrap && aggregate.catchRule !== NEW_REAL_TEST_FAILURE_RULE ) { // Its new real test failures were credited under other rules and are // held as counts, which cannot be judged again by themselves. Every // record behind them is still in the store, so all of them are folded // again into an empty aggregate. const since = firstDay(aggregate) ?? today; console.log( `test selection: ${chosen.name} credited its new real test failures ` + `under other rules, so this run folds every day from ${since} again`, ); from = since.replaceAll("-", "/"); aggregate = emptyAggregate(today); } const partitions = options.bootstrap || from >= asked[0]! ? asked : dayPartitions( startedAt, daysBetween(from.replaceAll("/", "-"), today) + 1, ); if (partitions !== asked && aggregate === chosen.aggregate) { console.log( `test selection: reading from ${from}, the last day the aggregate ` + `read`, ); } const resolver = await loadAliasResolver(); const fold = new Fold(aggregate, resolver, today); if (fold.resolved > 0) { console.log( `test selection: moved the history of ${fold.resolved} identities ` + `to the names the alias file gives them`, ); } const runs = new Set(); const found: { attempt: number; baseline: CoverageBaseline }[] = []; let commit = "unknown"; const noteReport = (report: StoredReport): void => { for (const group of report.reports) { const id = group.context?.ci?.workflowRunId; if (id !== undefined) runs.add(id); if (group.context?.branch === "main") commit = group.context.commit; const attempt = group.context?.ci?.runAttempt ?? 0; for (const baseline of baselinesOf(group)) { found.push({ attempt, baseline }); } } }; // What each source and date still owes, from the one rule both modes // apply. Only the continuous-integration area can answer anything but // `raw`, because it is the only one rollups cover, so it is the only // one the store is asked about — and it is asked only where the answer // could be a rollup, so a run over days it has already read raw asks // nothing. // Every day of the window for the local area: no local pair is ever // settled, so the rule answers `raw` for each of them. let local: string[]; try { local = await listLocal(store, partitions); } catch (error) { console.warn(`test selection: listing the submissions failed: ${error}`); console.warn(PARTIAL_WINDOW); return 1; } const concurrency = options.concurrency; /** * Reads a day's rollup, consuming each batch before fetching the next * shards, and then the day's local submissions. */ async function* readRollup( shards: readonly string[], locals: readonly string[], ) { for (let at = 0; at < shards.length; at += SHARD_CHUNK) { const reports = await mapConcurrent( shards.slice(at, at + SHARD_CHUNK), concurrency, (objectName) => store.read(objectName), ); for (const report of reports) { noteReport(report); yield report; } } const reports = await mapConcurrent( locals, concurrency, (objectName) => store.read(objectName), ); for (const report of reports) { noteReport(report); yield report; } } /** * Reads a day's raw objects a chunk at a time, consuming each before * fetching the next. A day holds thousands of objects, each holding * hundreds of executions, and holding them all would be bounded by the * number of runs rather than by the number of tests. */ async function* readRaw(names: readonly string[]) { for (let at = 0; at < names.length; at += CHUNK) { const reports = await mapConcurrent( names.slice(at, at + CHUNK), concurrency, // A read that fails is not an object with no records, so it is // not swallowed: the same partial-history argument as the // listing. (objectName) => store.read(objectName), ); for (const report of reports) { noteReport(report); yield report; } } } // The window is folded one day at a time, oldest first, whichever way each // day is read. The rules that decide whether a failure is a new real test // failure look a day or two either side of it, and back at what the default // branch last said, so a day folded after a later one would be judged against // what happened after it. // // How each day is read is settled for the whole window before any of it // is folded, so a rollup the compactor writes while this run is folding // is left for a later run rather than taken part way through. const choices: Array<{ date: string; choice: InputChoice; shards?: readonly string[]; }> = []; for (const date of partitions) { const settled = fold.settled(CI_SOURCE, date); const foldedRaw = fold.hasRaw(CI_SOURCE, date); const shards = settled || foldedRaw ? undefined : await store.rollupShards(date); const choice = inputChoice({ settled, foldedRaw, rollup: shards !== undefined, }); choices.push({ date, choice, ...(shards === undefined ? {} : { shards }) }); } let rollupDays = 0; let fromRollups = 0; for (const { date, choice, shards } of choices) { const locals = local.filter((name) => partitionOf(name) === date); if (choice === "rollup" && shards !== undefined) { rollupDays++; try { // The day's local submissions go into the same batch, which the // fold replays in time order. await fold.addUnordered(readRollup(shards, locals)); fold.markSettled(CI_SOURCE, date); fromRollups++; continue; } catch (error) { console.warn( `test selection: reading the rollup of ${date} failed: ${error}`, ); // A failure that left part of the day in the fold is the one this // cannot read its way out of: reading the day again, by any // route, would count that part twice. if (!fold.intact) { console.warn(PARTIAL_WINDOW); return 1; } // Ending the run here would end every later one the same way: // the store holds create and nothing else, so a shard that will // not read stays where it is, and the day is never recorded as // folded. The day's raw objects are all still there, so it is // read the long way instead. The pair is left open rather than // settled, so later runs read the day the same way. console.warn( `test selection: reading ${date} from its raw objects instead, ` + `as a day with no rollup is read`, ); } } let names: string[]; try { // A listing that fails is not an empty day. Folding what did list // and publishing from it would score every identity in the missing // day as though it had not run, and the manifest saying so would // become the one every lane obeys. The run ends instead, and the // previous manifest stays newest. names = choice === "settled" ? locals : (await store.list(`${ciSubmissionsPrefix()}/v1/${date}/`)) .concat(locals); } catch (error) { console.warn(`test selection: listing the submissions failed: ${error}`); console.warn(PARTIAL_WINDOW); return 1; } const fresh = [...new Set(names)] .filter((name) => !fold.knows(name)) .sort(); // Folded as one batch, which the fold replays in time order as it does // a rollup's day, since the names sort by area before time. try { await fold.addUnordered(readRaw(fresh)); } catch (error) { console.warn(`test selection: reading a submission failed: ${error}`); console.warn(PARTIAL_WINDOW); return 1; } if (fresh.length > 0) { console.log( `test selection: folded ${fresh.length} object(s) of ${date}`, ); } } if (rollupDays > 0) { console.log( `test selection: folded ${fromRollups} of ${rollupDays} day(s) from ` + `their rollups`, ); } const folded = fold.finish({ from: partitions[0]!, through: partitions.at(-1)!, }); // The topology is what says where an identity runs. Its suite // identifiers and units are what a lane looks a suite up by and what // the packer charges overheads and capabilities against, so a manifest // built without it names surfaces nothing in the tree answers to. const suites = await topology(); const { placed, unplaced } = locateSurfaces(suites, folded.surfaces); // A test the tree no longer holds leaves the aggregate here rather // than being carried and rescored for the rest of the store's life. // The state written below is the one the next run reads, so dropping // the entries from it is the whole of what forgetting an identity is. const left = departed(suites, unplaced, folded); for (const key of left) { delete folded.aggregate.states[key]; delete folded.aggregate.files[key]; folded.states.delete(key); } const states = new Map( [...folded.states].filter(([key]) => placed.has(key)), ); const observed = laneObservations(folded.aggregate.lanes ?? []); const manifest = buildManifest({ states, surfaces: placed, today, generatedAt: startedAt.toISOString(), seed: ulid(), commit, runs: runs.size, // What a lane costs beyond the tests it runs, from what lanes have // spent. Without it the packer charges nothing for opening a // capability, starting a runner, or loading a module, and a lane // packed to its budget runs past the bound it is packed to finish // inside. calibration: calibrate(observed, folded.aggregate.hooks), }); // What the coverage gate compares a pull request against. The full run on // `main` writes the counts as measurements, which the fold passes over, // so they are collected beside it. // Ordered by attempt, stably, so that of two attempts stamped with one // start the later is the one kept. manifest.coverageBaselines = mergeBaselines( previous?.coverageBaselines ?? [], found.sort((a, b) => a.attempt - b.attempt).map(({ baseline }) => baseline), startedAt, ); manifest.unavailable = suites.flatMap((suite) => suite.unavailable.map((entry) => ({ suite: suite.id, ...(suite.variant === undefined ? {} : { variant: suite.variant }), unit: entry.unit, ...(entry.leafName === undefined ? {} : { leafName: entry.leafName }), ...(entry.phase === undefined ? {} : { phase: entry.phase }), reason: entry.reason, })) ); const capabilities = capabilitiesBySuite(suites); const reference = plan({ manifest, mandatory: new Map(), capabilities, wholeUnits: wholeUnits(suites), // The gates of no change, so that none is packed as a lane's work and // none is chosen. gates: gatesOf(suites, manifest.entries, manifest.entries, new Set()), }); // What the packer refused, from the packer, carrying the cost the bound // was compared against rather than a raw one that leaves out every // overhead the lane would have paid. manifest.unschedulable = reference.unschedulable; manifest.lanes = reference.lanes.map((lane) => ({ lane: lane.lane, projectedSeconds: Math.round(lane.projectedSeconds * 10) / 10, batches: [...new Set(lane.selections.map((s) => s.entry.suite))].sort() .map((suite) => ({ suite, identities: lane.selections .filter((s) => s.entry.suite === suite) .map((s) => JSON.stringify(s.entry.test)), })), })); // Whether the model this manifest carries still describes the lanes. // Nothing obeys it, so the manifest is published whatever it says, and // the dashboard's test selection tile is what shows it. manifest.health = calibrationHealth({ manifest, previous, capabilities, observations: observed, }); for (const line of healthLines(manifest.health)) { console.log(`test selection: ${line}`); } summarize( manifest, suites, reference, folded.observations, unplaced, left, fold.declined, ); if (options.out !== undefined) { await Deno.mkdir(options.out, { recursive: true }); await Deno.writeTextFile( join(options.out, "manifest.json"), JSON.stringify(manifest, null, 2), ); await Deno.writeTextFile( join(options.out, "state.json"), JSON.stringify(folded.aggregate), ); console.log(`test selection: wrote ${options.out}/manifest.json`); } if (options.dryRun) return 0; return await create(store, manifest, JSON.stringify(folded.aggregate)); } /** * Creates the manifest and the aggregate a `--dry-run --out` run wrote * to a directory. Both are read back and checked before anything is * created, so a directory holding anything else creates nothing. */ async function createFrom(dir: string, store: StoreAccess): Promise { const manifest = parseManifest( await Deno.readTextFile(join(dir, "manifest.json")), ); const state = await Deno.readTextFile(join(dir, "state.json")); if (manifest === undefined || parseAggregate(state) === undefined) { console.error( `test selection: ${dir} does not hold a manifest and an aggregate ` + "this publisher wrote, so nothing was created. The previous " + "manifest is still the newest one.", ); return 1; } return await create(store, manifest, state); } /** Creates one manifest and the aggregate it was built from. */ async function create( store: StoreAccess, manifest: Manifest, state: string, ): Promise { if (store.token() === undefined) { console.warn( "test selection: no write credential, so nothing was created. The " + "previous manifest is still the newest one.", ); return 0; } // The manifest first and the state after it. A run that died between // the two leaves a manifest whose aggregate is a cycle behind, which // the next run folds forward; the other order would leave a state // claiming objects no manifest was built from. const id = ulid(); const name = manifestObjectName(manifest.generatedAt, id); await store.create(name, await manifestBody(manifest)); console.log(`test selection: created ${name}`); await store.create( stateObjectName(manifest.generatedAt.slice(0, 10), id), await gzipText(state), ); return 0; } /** How many of the worst surfaces a summary line names. */ const NAMED_SURFACES = 5; /** * The record surfaces that wrote a set of identities, worst first, as one * phrase. The count says how much there is to fix, and the surface says * which part of the tree it is in. */ export function namingSurfaces(keys: readonly string[]): string { const counts = new Map(); for (const key of keys) { const test = testIdentityOfKey(key); if (test === undefined) continue; const surface = surfaceName(test); counts.set(surface, (counts.get(surface) ?? 0) + 1); } // By count and then by name, so that two surfaces of the same size come // out in the same order every run. const ordered = [...counts].sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]) ); const named = ordered.slice(0, NAMED_SURFACES).map(([surface, count]) => `${surface} ${count}` ); const rest = ordered.length - named.length; return `${ordered.length} surface(s): ${named.join(", ")}` + (rest > 0 ? `, and ${rest} more` : ""); } /** What the job summary says: the shape of what this run decided. */ function summarize( manifest: ReturnType, topology: readonly Suite[], reference: ReturnType, observations: number, unplaced: Unplaced, left: readonly string[], declined: number, ): void { console.log( `test selection: folded ${observations} execution(s); the manifest ` + `holds ${manifest.entries.length} identities`, ); // Said every run, so that a model nobody measured is as visible as one // somebody did. The two halves are counted apart because they come // from different records: a lane writes one per capability it opens, // and a pair per batch, and a lane killed part way through a batch // leaves the pair unmatched and contributes a setup cost alone. const withCoverage = Object.keys( manifest.calibration.suitesWithCoverage ?? {}, ); const suites = new Set([ ...Object.keys(manifest.calibration.suites), ...withCoverage, ]).size; const measured = withCoverage.length; console.log( `test selection: the cost model holds ${suites} suite(s) and ` + `${Object.keys(manifest.calibration.setupCost).length} ` + `capability setup(s), and ${measured} of those suite(s) have a ` + `cost with coverage on`, ); // A suite's own figures are what a lane is charged for holding the suite and // for opening each of its units, so a model with no suite in it charges // nothing for either and a lane packed to its budget runs past the bound it // is packed to finish inside. A capability setup is measured from a lane's // own records and is unaffected, and the prologue is a fixed dial rather than // a measurement, so it is there either way; this names the suites rather than // everything a lane is charged. Four things end here — no lane has run, none // recorded what it measured, the fold declines the records of the ones that // did, or the fold stopped reading a figure it used to read — and the empty // map alone says none of them. if (suites === 0) { console.log( `test selection: no suite has a measured cost in the last ` + `${COST_WINDOW_DAYS} day(s), so a lane is charged nothing for ` + `holding one or for opening its units, and a lane packed against ` + `this manifest overruns. See docs/development/test-selection.md.`, ); // Said only where there is a figure to say, so that a run with // nothing to report claims nothing. A lane that ran and whose // measurement cannot be read is a different thing from a lane that // has not run, and it is the one an operator can act on. if (declined > 0) { console.log( `test selection: ${declined} lane measurement(s) this run read ` + `came from a run the fold could not place, so the model was ` + `fitted without them.`, ); } } for (const line of measuredCostLines(manifest, topology)) { console.log(`test selection: ${line}`); } if (unplaced.suiteLevel.length > 0) { console.log( `test selection: ${unplaced.suiteLevel.length} identities measure a ` + `whole invocation rather than one unit, so they are left out. The ` + `steps inside the invocation are measured separately, so nothing ` + `is missing and there is nothing to act on.`, ); } if (left.length > 0) { console.log( `test selection: ${left.length} identities have left the tree: no ` + `suite claims them and no run of them has been recorded inside ` + `the window a state keeps counters for. Their states are dropped ` + `from the aggregate.`, ); console.log( `test selection: those ${left.length} were recorded by ` + `${namingSurfaces(left)}`, ); } const gone = new Set(left); const unclaimed = unplaced.unclaimed.filter((key) => !gone.has(key)); if (unclaimed.length > 0) { console.log( `test selection: no suite claims ${unclaimed.length} identities the ` + `aggregate still carries, so no lane can be asked to run one. ` + `Each has run inside the window a state keeps counters for, or a ` + `configuration declares its unit unavailable. What puts an ` + `identity here, and what takes it out again, is in ` + `docs/development/test-selection.md.`, ); console.log( `test selection: those ${unclaimed.length} were recorded ` + `by ${namingSurfaces(unclaimed)}`, ); } if (unplaced.contested.length > 0) { console.log( `test selection: ${unplaced.contested.length} identities are ` + `claimed by more than one suite, which is a topology defect the ` + `drift guard fails on. They are left out rather than placed in ` + `whichever suite came first.`, ); console.log( `test selection: those ${unplaced.contested.length} were recorded ` + `by ${namingSurfaces(unplaced.contested)}`, ); } const held = new Map(); for (const entry of manifest.withheld) { held.set(entry.reason, (held.get(entry.reason) ?? 0) + 1); } for (const [reason, count] of [...held].sort()) { console.log(`test selection: ${count} withheld as ${reason}`); } const times = reference.lanes.map((lane) => lane.projectedSeconds); for (const lane of reference.lanes) { console.log( `test selection: lane ${lane.lane} would run ` + `${lane.selections.length} test(s) in ` + `${duration(lane.projectedSeconds)} of ${ duration(LANE_BUDGET_SECONDS) }`, ); } if (times.length > 0) { const spread = maxOf(times) - minOf(times); console.log( `test selection: ${reference.lanes.length} lanes, spread ` + duration(spread), ); } const selected = reference.lanes.reduce( (total, lane) => total + lane.selections.length, 0, ); console.log( `test selection: ${selected} of ${manifest.entries.length} identities ` + `fit the budget`, ); // The count and the surfaces say how much there is and where. const { named, rest } = costliestUnschedulable(reference.unschedulable); for (const entry of named) { console.log( `test selection: unschedulable, ${duration(entry.cost)}: ` + JSON.stringify(entry.test), ); } if (rest.length > 0) { console.log( `test selection: ${rest.length} further identities cost more than a ` + `lane can hold, recorded by ` + `${namingSurfaces(rest.map((entry) => testIdentityKey(entry.test)))}`, ); } } // Re-exported so an offline check can build the same day list. export { dayOf, manifestPrefix, newestAtOrBefore, partitionOf, serializeManifest, }; if (import.meta.main) { Deno.exit(await publish(Deno.args)); }