Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions BuildWatch.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = KA6H3Z2C59;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -265,7 +265,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = com.ryancurran.ios.BuildWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand All @@ -284,7 +284,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 9;
CURRENT_PROJECT_VERSION = 10;
DEVELOPMENT_TEAM = KA6H3Z2C59;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
Expand All @@ -297,7 +297,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = com.ryancurran.ios.BuildWatch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
Expand Down
62 changes: 57 additions & 5 deletions BuildWatch/Models/Job.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ nonisolated enum JobResult: String, Codable, Sendable, CaseIterable {

nonisolated enum JobState: String, Codable, Sendable {
case pending, running, completed
/// A task whose dependencies haven't resolved yet — a test still waiting on its build.
/// TreeHerder emits this alongside the other three and it is not rare: a live 691-job
/// push had 24 jobs in it. It was previously unmodelled, so the parser's unknown-state
/// fallback filed them as `.completed`, which made them invisible to `isRunning` and
/// could fire "Try push passed" with two dozen jobs not yet started.
case unscheduled

/// Not started, whether or not the queue has released it yet.
var isWaiting: Bool { self == .pending || self == .unscheduled }
}

nonisolated struct Job: Identifiable, Codable, Sendable {
Expand All @@ -66,6 +75,9 @@ nonisolated struct Job: Identifiable, Codable, Sendable {
let jobGroupSymbol: String
let state: JobState
let result: JobResult
/// When Taskcluster accepted the task. The basis of every queue-wait calculation —
/// `startTimestamp - submitTimestamp` is how long this job sat in its pool.
let submitTimestamp: Int?
let startTimestamp: Int?
let endTimestamp: Int?
let tier: Int
Expand All @@ -74,6 +86,45 @@ nonisolated struct Job: Identifiable, Codable, Sendable {
startTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
}

var submitDate: Date? {
submitTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
}

var endDate: Date? {
endTimestamp.map { Date(timeIntervalSince1970: TimeInterval($0)) }
}

/// How long this job waited in its worker pool before a worker picked it up.
/// Only known once it has started; that is exactly what makes it useful for
/// predicting the jobs in the same pool that haven't.
var queueWait: TimeInterval? {
guard let s = submitTimestamp, let st = startTimestamp, st > s else { return nil }
return TimeInterval(st - s)
}

/// Jobs sharing this key contend for the same workers, so they share a queue wait.
var poolKey: String { "\(platform)|\(platformOption)" }

/// Where this job sits in the build chain tests wait on, or `nil` if it isn't part of it.
///
/// A shippable macOS build is not one task, it is a pipeline: `instrumented-build-…`
/// produces a profiling binary, `generate-profile-…` runs it, and only then does
/// `build-…` produce the artifact tests consume. Missing the middle stage is why an
/// earlier version of this found no build to wait on at all on a live Talos push — the
/// `build-*` jobs were themselves still unscheduled, behind a running `generate-profile`.
///
/// Deliberately a name check. The alternative is the Taskcluster dependency graph, which
/// costs one request per task.
var buildStage: Int? {
if jobTypeName.hasPrefix("toolchain-") { return 0 }
if jobTypeName.hasPrefix("instrumented-build-") { return 1 }
if jobTypeName.hasPrefix("generate-profile-") { return 2 }
if jobTypeName.hasPrefix("build-")
|| jobTypeName.hasPrefix("spidermonkey-")
|| jobTypeName.contains("-build-") { return 3 }
return nil
}

var duration: TimeInterval? {
guard let s = startTimestamp, let e = endTimestamp, e > s else { return nil }
return TimeInterval(e - s)
Expand Down Expand Up @@ -105,7 +156,7 @@ nonisolated struct Job: Identifiable, Codable, Sendable {
}

var isRunning: Bool { state == .running }
var isPending: Bool { state == .pending }
var isPending: Bool { state.isWaiting }

var displayResult: JobResult {
state == .completed ? result : .unknown
Expand All @@ -118,9 +169,10 @@ nonisolated struct Job: Identifiable, Codable, Sendable {
/// Spoken status, so VoiceOver conveys what the coloured glyph conveys visually.
var statusDescription: String {
switch state {
case .pending: "pending"
case .running: elapsedString().map { "running for \($0)" } ?? "running"
case .completed: durationString.map { "\(result.displayName), took \($0)" } ?? result.displayName
case .pending: "pending"
case .unscheduled: "waiting on its build"
case .running: elapsedString().map { "running for \($0)" } ?? "running"
case .completed: durationString.map { "\(result.displayName), took \($0)" } ?? result.displayName
}
}

Expand Down Expand Up @@ -149,7 +201,7 @@ nonisolated struct PlatformGroup: Identifiable, Sendable {
var failures = 0, pending = 0, running = 0, successes = 0
for job in jobs {
switch job.state {
case .pending: pending += 1
case .pending, .unscheduled: pending += 1
case .running: running += 1
case .completed:
if job.result.isFailure { failures += 1 }
Expand Down
70 changes: 70 additions & 0 deletions BuildWatch/Models/JobDurationTable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

/// How long each kind of job *runs*, learned offline from real try pushes.
///
/// Run time is the one genuinely predictable part of a try push: measured across 369 try
/// pushes and 73,864 jobs, a job's duration has a median coefficient of variation of 5%,
/// and a plain per-job-type median predicts a held-out run to within 5% at the median.
/// Queue *wait* is the unpredictable part, and that is estimated live per worker pool from
/// the push itself — see `PushETA`.
///
/// Shipping this table is what makes the estimate work. The same estimator built purely
/// from the push's own completed jobs lands within ±25% only 12% of the time; with the
/// table it is 63%. There is no runtime cost — one bundled JSON read, lazily, once.
///
/// Regenerate with `tools/generate-duration-table.py`.
nonisolated final class JobDurationTable: Sendable {

static let shared = JobDurationTable()

private let exact: [String: Double]
private let family: [String: Double]
private let platform: [String: Double]
private let fallback: Double

/// Used when the bundle resource is missing entirely — the global median job.
private static let hardFallback: Double = 20.8

private init() {
guard
let url = Bundle.main.url(forResource: "JobDurations", withExtension: "json"),
let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(Payload.self, from: data)
else {
exact = [:]; family = [:]; platform = [:]; fallback = Self.hardFallback
return
}
exact = decoded.exact
family = decoded.family
platform = decoded.platform
fallback = decoded.global
}

private struct Payload: Decodable {
let exact: [String: Double]
let family: [String: Double]
let platform: [String: Double]
let global: Double
}

/// Expected run time in seconds, most specific match first.
///
/// The `family` tier exists because chunked suites are named `…-wdspec-headless-1`,
/// `…-2`, and so on. Collapsing the chunk number lets a chunk BuildWatch has never seen
/// inherit its siblings' timing, which cuts the table's miss rate from 14% to 8%.
func expectedRunTime(for job: Job) -> TimeInterval {
let minutes = exact[job.jobTypeName]
?? family[Self.familyKey(job.jobTypeName)]
?? platform["\(job.platform)|\(job.platformOption)"]
?? fallback
return minutes * 60
}

/// Strips a trailing chunk number: `…-wdspec-headless-1` → `…-wdspec-headless`.
static func familyKey(_ jobTypeName: String) -> String {
guard let dash = jobTypeName.lastIndex(of: "-") else { return jobTypeName }
let suffix = jobTypeName[jobTypeName.index(after: dash)...]
guard !suffix.isEmpty, suffix.allSatisfy(\.isNumber) else { return jobTypeName }
return String(jobTypeName[..<dash])
}
}
Loading